mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
feat(skills/airtable): tailor skill to Hermes idioms + expand cookbook
Expand the airtable skill from bare CRUD to a full Hermes-shaped cookbook matching the linear/notion neighbors, and trim the description to fit the 60-char system-prompt cutoff. Hermes-specific additions: - Explicit 'use the terminal tool with curl — not web_extract or browser_navigate' guidance, matching the same note in linear. - Note that AIRTABLE_API_KEY flows from ~/.hermes/.env into the subprocess automatically via env_passthrough, so curl calls don't need to re-export it. - Prefer 'python3 -m json.tool' (always present) over jq (optional) for pretty-printing, with -s on every curl to keep output clean. - Read-before-write workflow that resolves record IDs via filterByFormula instead of guessing. Cookbook expansion (new vs original): - Field-type reference table (text, select, multi-select, attachment, linked record, user) with the exact write-shape Airtable expects. - typecast flag for auto-coercing values / auto-creating select options. - performUpsert PATCH for idempotent sync by merge field. - Batch create/delete endpoints (10-record cap per call). - Sort + fields query params with URL-encoding (%5B / %5D). - Named-view query that applies saved filter/sort server-side. - Full pagination loop template (while loop with offset). - Common filterByFormula patterns (exact match, contains, AND/OR, date comparison, NOT empty). - Rate-limit backoff guidance (Retry-After header, per-base budget). - Airtable error-code reference (AUTHENTICATION_REQUIRED, INVALID_PERMISSIONS, MODEL_ID_NOT_FOUND, INVALID_MULTIPLE_CHOICE_OPTIONS) so the agent can map failures to user-actionable fixes instead of just retrying. Also: description trimmed from 183 chars (truncated to 60 in system prompt, losing 'filter/upsert/delete' trigger terms) down to 59 chars that render whole: 'Airtable REST API via curl. Records CRUD, filters, upserts.' Catalog row updated to match. SKILL.md grew from 115 to 228 lines — still under the 500-line soft cap and below the linear skill (297 lines) which serves the same role for GraphQL.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: airtable
|
||||
description: Read/write Airtable bases via REST API using curl. List bases, tables, and records; create, update, and delete records. No dependencies beyond curl.
|
||||
version: 1.0.0
|
||||
description: Airtable REST API via curl. Records CRUD, filters, upserts.
|
||||
version: 1.1.0
|
||||
author: community
|
||||
license: MIT
|
||||
prerequisites:
|
||||
@@ -13,100 +13,216 @@ metadata:
|
||||
homepage: https://airtable.com/developers/web/api/introduction
|
||||
---
|
||||
|
||||
# Airtable REST API
|
||||
# Airtable — Bases, Tables & Records
|
||||
|
||||
Use Airtable's REST API via `curl` to list bases, inspect schemas, and run CRUD against records. No extra packages — `curl` plus Python stdlib for URL encoding is enough.
|
||||
Work with Airtable's REST API directly via `curl` using the `terminal` tool. No MCP server, no OAuth flow, no Python SDK — just `curl` and a personal access token.
|
||||
|
||||
## Setup
|
||||
## Prerequisites
|
||||
|
||||
1. Create a personal access token (PAT) at https://airtable.com/create/tokens
|
||||
1. Create a **Personal Access Token (PAT)** at https://airtable.com/create/tokens (tokens start with `pat...`).
|
||||
2. Grant these scopes (minimum):
|
||||
- `data.records:read` — read rows
|
||||
- `data.records:write` — create / update / delete rows
|
||||
- `schema.bases:read` — list bases and tables (step 2–3 of the procedure below)
|
||||
3. Add to `~/.hermes/.env` (or set via `hermes setup`):
|
||||
- `schema.bases:read` — list bases and tables
|
||||
3. **Important:** in the same token UI, add each base you want to access to the token's **Access** list. PATs are scoped per-base — a valid token on the wrong base returns `403`.
|
||||
4. Store the token in `~/.hermes/.env` (or via `hermes setup`):
|
||||
```
|
||||
AIRTABLE_API_KEY=pat_your_token_here
|
||||
```
|
||||
4. In the PAT UI, also add each base you want to access to the token's "Access" list. Tokens are scoped per-base.
|
||||
|
||||
> Note: legacy `key...` API keys were deprecated in Feb 2024. PATs (starting with `pat`) are the only supported format.
|
||||
> Note: legacy `key...` API keys were deprecated Feb 2024. Only PATs and OAuth tokens work now.
|
||||
|
||||
## API Basics
|
||||
|
||||
- **Base URL:** `https://api.airtable.com/v0`
|
||||
- **Endpoint:** `https://api.airtable.com/v0`
|
||||
- **Auth header:** `Authorization: Bearer $AIRTABLE_API_KEY`
|
||||
- **Object IDs:** bases `app...`, tables `tbl...`, records `rec...`. Prefer IDs over names when table names have spaces or may change.
|
||||
- **Rate limit:** 5 requests/sec/base. On `429`, back off and avoid parallel mutations into the same base.
|
||||
|
||||
## Quick Reference
|
||||
- **All requests** use JSON (`Content-Type: application/json` for any POST/PATCH/PUT body).
|
||||
- **Object IDs:** bases `app...`, tables `tbl...`, records `rec...`, fields `fld...`. IDs never change; names can. Prefer IDs in automations.
|
||||
- **Rate limit:** 5 requests/sec/base. `429` → back off. Burst on a single base will be throttled.
|
||||
|
||||
Base curl pattern:
|
||||
```bash
|
||||
AUTH="Authorization: Bearer $AIRTABLE_API_KEY"
|
||||
BASE_ID=appXXXXXXXXXXXXXX
|
||||
TABLE=Tasks # or tblXXXXXXXXXXXXXX
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=5" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
List records (first 10):
|
||||
`-s` suppresses curl's progress bar — keep it set for every call so the tool output stays clean for Hermes. Pipe through `python3 -m json.tool` (always present) or `jq` (if installed) for readable JSON.
|
||||
|
||||
## Field Types (request body shapes)
|
||||
|
||||
| Field type | Write shape |
|
||||
|---|---|
|
||||
| Single line text | `"Name": "hello"` |
|
||||
| Long text | `"Notes": "multi\nline"` |
|
||||
| Number | `"Score": 42` |
|
||||
| Checkbox | `"Done": true` |
|
||||
| Single select | `"Status": "Todo"` (name must already exist unless `typecast: true`) |
|
||||
| Multi-select | `"Tags": ["urgent", "bug"]` |
|
||||
| Date | `"Due": "2026-04-01"` |
|
||||
| DateTime (UTC) | `"At": "2026-04-01T14:30:00.000Z"` |
|
||||
| URL / Email / Phone | `"Link": "https://…"` |
|
||||
| Attachment | `"Files": [{"url": "https://…"}]` (Airtable fetches + rehosts) |
|
||||
| Linked record | `"Owner": ["recXXXXXXXXXXXXXX"]` (array of record IDs) |
|
||||
| User | `"AssignedTo": {"id": "usrXXXXXXXXXXXXXX"}` |
|
||||
|
||||
Pass `"typecast": true` at the top level of a create/update body to let Airtable auto-coerce values (e.g. create a new select option on the fly, convert `"42"` → `42`).
|
||||
|
||||
## Common Queries
|
||||
|
||||
### List bases the token can see
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=10" -H "$AUTH"
|
||||
curl -s "https://api.airtable.com/v0/meta/bases" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Create a record:
|
||||
### List tables + schema for a base
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/meta/bases/$BASE_ID/tables" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
Use this BEFORE mutating — confirms exact field names and IDs, surfaces `options.choices` for select fields, and shows primary-field names.
|
||||
|
||||
### List records (first 10)
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=10" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Get a single record
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Filter records (filterByFormula)
|
||||
Airtable formulas must be URL-encoded. Let Python stdlib do it — never hand-encode:
|
||||
```bash
|
||||
FORMULA="{Status}='Todo'"
|
||||
ENC=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$FORMULA")
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?filterByFormula=$ENC&maxRecords=20" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Useful formula patterns:
|
||||
- Exact match: `{Email}='user@example.com'`
|
||||
- Contains: `FIND('bug', LOWER({Title}))`
|
||||
- Multiple conditions: `AND({Status}='Todo', {Priority}='High')`
|
||||
- Or: `OR({Owner}='alice', {Owner}='bob')`
|
||||
- Not empty: `NOT({Assignee}='')`
|
||||
- Date comparison: `IS_AFTER({Due}, TODAY())`
|
||||
|
||||
### Sort + select specific fields
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?sort%5B0%5D%5Bfield%5D=Priority&sort%5B0%5D%5Bdirection%5D=asc&fields%5B%5D=Name&fields%5B%5D=Status" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
Square brackets in query params MUST be URL-encoded (`%5B` / `%5D`).
|
||||
|
||||
### Use a named view
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?view=Grid%20view&maxRecords=50" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
Views apply their saved filter + sort server-side.
|
||||
|
||||
## Common Mutations
|
||||
|
||||
### Create a record
|
||||
```bash
|
||||
curl -s -X POST "https://api.airtable.com/v0/$BASE_ID/$TABLE" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"fields":{"Name":"New task","Status":"Todo"}}'
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fields":{"Name":"New task","Status":"Todo","Priority":"High"}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Update a record (partial — PATCH preserves other fields):
|
||||
### Create up to 10 records in one call
|
||||
```bash
|
||||
curl -s -X POST "https://api.airtable.com/v0/$BASE_ID/$TABLE" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"typecast": true,
|
||||
"records": [
|
||||
{"fields": {"Name": "Task A", "Status": "Todo"}},
|
||||
{"fields": {"Name": "Task B", "Status": "In progress"}}
|
||||
]
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
Batch endpoints are capped at **10 records per request**. For larger inserts, loop in batches of 10 with a short sleep to respect 5 req/sec/base.
|
||||
|
||||
### Update a record (PATCH — merges, preserves unchanged fields)
|
||||
```bash
|
||||
curl -s -X PATCH "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d '{"fields":{"Status":"Done"}}'
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"fields":{"Status":"Done"}}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Delete a record:
|
||||
### Upsert by a merge field (no ID needed)
|
||||
```bash
|
||||
curl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" -H "$AUTH"
|
||||
curl -s -X PATCH "https://api.airtable.com/v0/$BASE_ID/$TABLE" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"performUpsert": {"fieldsToMergeOn": ["Email"]},
|
||||
"records": [
|
||||
{"fields": {"Email": "user@example.com", "Status": "Active"}}
|
||||
]
|
||||
}' | python3 -m json.tool
|
||||
```
|
||||
`performUpsert` creates records whose merge-field values are new, patches records whose merge-field values already exist. Great for idempotent syncs.
|
||||
|
||||
### Delete a record
|
||||
```bash
|
||||
curl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
## Procedure
|
||||
### Delete up to 10 records in one call
|
||||
```bash
|
||||
curl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE?records%5B%5D=rec1&records%5B%5D=rec2" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
|
||||
```
|
||||
|
||||
1. **Authenticate.** Confirm `AIRTABLE_API_KEY` is set. If empty, stop and ask the user to add it to `~/.hermes/.env`.
|
||||
2. **Find the base.** List all bases the token can see:
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/meta/bases" -H "$AUTH"
|
||||
```
|
||||
Requires `schema.bases:read`. If the token lacks that scope, ask the user for the base ID directly.
|
||||
3. **Inspect the schema.** List tables and fields for the chosen base:
|
||||
```bash
|
||||
curl -s "https://api.airtable.com/v0/meta/bases/$BASE_ID/tables" -H "$AUTH"
|
||||
```
|
||||
Use this to confirm table names, IDs, and field names before mutating data.
|
||||
4. **CRUD against the target table.**
|
||||
- Read: `GET /v0/$BASE_ID/$TABLE`
|
||||
- Create: `POST /v0/$BASE_ID/$TABLE` with `{"fields": {...}}`
|
||||
- Update: `PATCH /v0/$BASE_ID/$TABLE/$RECORD_ID` with only the fields to change (use `PUT` for full replacement)
|
||||
- Delete: `DELETE /v0/$BASE_ID/$TABLE/$RECORD_ID`
|
||||
5. **Paginate long lists.** The list endpoint caps at 100 records per page. If the response includes `"offset": "..."`, pass it back as `?offset=<value>` on the next call and repeat until the field is absent.
|
||||
## Pagination
|
||||
|
||||
List endpoints return at most **100 records per page**. If the response includes `"offset": "..."`, pass it back on the next call. Loop until the field is absent:
|
||||
|
||||
```bash
|
||||
OFFSET=""
|
||||
while :; do
|
||||
URL="https://api.airtable.com/v0/$BASE_ID/$TABLE?pageSize=100"
|
||||
[ -n "$OFFSET" ] && URL="$URL&offset=$OFFSET"
|
||||
RESP=$(curl -s "$URL" -H "Authorization: Bearer $AIRTABLE_API_KEY")
|
||||
echo "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); [print(r["id"], r["fields"].get("Name","")) for r in d["records"]]'
|
||||
OFFSET=$(echo "$RESP" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("offset",""))')
|
||||
[ -z "$OFFSET" ] && break
|
||||
done
|
||||
```
|
||||
|
||||
## Typical Hermes Workflow
|
||||
|
||||
1. **Confirm auth.** `curl -s -o /dev/null -w "%{http_code}\n" https://api.airtable.com/v0/meta/bases -H "Authorization: Bearer $AIRTABLE_API_KEY"` — expect `200`.
|
||||
2. **Find the base.** List bases (step above) OR ask the user for the `app...` ID directly if the token lacks `schema.bases:read`.
|
||||
3. **Inspect the schema.** `GET /v0/meta/bases/$BASE_ID/tables` — cache the exact field names and primary-field name locally in the session before mutating anything.
|
||||
4. **Read before you write.** For "update X where Y", `filterByFormula` first to resolve the `rec...` ID, then `PATCH /v0/$BASE_ID/$TABLE/$RECORD_ID`. Never guess record IDs.
|
||||
5. **Batch writes.** Combine related creates into one 10-record POST to stay under the 5 req/sec budget.
|
||||
6. **Destructive ops.** Deletions can't be undone via API. If the user says "delete all Xs", echo back the filter + record count and confirm before firing.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`filterByFormula` must be URL-encoded.** Use Python stdlib — no extra packages:
|
||||
```bash
|
||||
ENC=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "{Status}='Todo'")
|
||||
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?filterByFormula=$ENC" -H "$AUTH"
|
||||
```
|
||||
- **Empty fields are omitted from responses.** If a record looks like it's missing fields, inspect the table schema (step 3) before concluding the field doesn't exist.
|
||||
- **Tokens are per-base.** The PAT UI requires adding each base to the token's Access list. A 403 on a specific base usually means the base wasn't granted, not that the token is wrong.
|
||||
- **PATCH vs PUT.** `PATCH` merges the supplied fields into the existing record; `PUT` replaces the record entirely, wiping any fields you didn't include. Default to `PATCH` unless you genuinely want to clear other fields.
|
||||
- **`filterByFormula` MUST be URL-encoded.** Field names with spaces or non-ASCII also need encoding (`{My Field}` → `%7BMy%20Field%7D`). Use Python stdlib (pattern above) — never hand-escape.
|
||||
- **Empty fields are omitted from responses.** A missing `"Assignee"` key doesn't mean the field doesn't exist — it means this record's value is empty. Check the schema (step 3) before concluding a field is missing.
|
||||
- **PATCH vs PUT.** `PATCH` merges supplied fields into the record. `PUT` replaces the record entirely and clears any field you didn't include. Default to `PATCH`.
|
||||
- **Single-select options must exist.** Writing `"Status": "Shipping"` when `Shipping` isn't in the field's option list errors with `INVALID_MULTIPLE_CHOICE_OPTIONS` unless you pass `"typecast": true` (which auto-creates the option).
|
||||
- **Per-base token scoping.** A `403` on one base while another works means the token's Access list doesn't include that base — not a scope or auth issue. Send the user to https://airtable.com/create/tokens to grant it.
|
||||
- **Rate limits are per base, not per token.** 5 req/sec on `baseA` and 5 req/sec on `baseB` is fine; 6 req/sec on `baseA` alone will throttle. Monitor the `Retry-After` header on `429`.
|
||||
|
||||
## Verification
|
||||
## Important Notes for Hermes
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "https://api.airtable.com/v0/meta/bases" \
|
||||
-H "Authorization: Bearer $AIRTABLE_API_KEY"
|
||||
```
|
||||
|
||||
Expect `200` with a `bases` array. `401` means the key is wrong; `403` means the token is valid but lacks `schema.bases:read` (use step 2 workaround).
|
||||
- **Always use the `terminal` tool with `curl`.** Do NOT use `web_extract` (it can't send auth headers) or `browser_navigate` (needs UI auth and is slow).
|
||||
- **`AIRTABLE_API_KEY` flows from `~/.hermes/.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call.
|
||||
- **Escape curly braces in formulas carefully.** In a heredoc body, `{Status}` is literal. In a shell argument, `{Status}` is safe outside `{...}` brace-expansion context — but pass dynamic strings through `python3 urllib.parse.quote` before splicing into a URL.
|
||||
- **Pretty-print with `python3 -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering/projection.
|
||||
- **Pagination is per-page, not global.** Airtable's 100-record cap is a hard limit; there is no way to bump it. Loop with `offset` until the field is absent.
|
||||
- **Read the `errors` array** on non-2xx responses — Airtable returns structured error codes like `AUTHENTICATION_REQUIRED`, `INVALID_PERMISSIONS`, `MODEL_ID_NOT_FOUND`, `INVALID_MULTIPLE_CHOICE_OPTIONS` that tell you exactly what's wrong.
|
||||
|
||||
@@ -132,7 +132,7 @@ If a skill is missing from this list but present in the repo, the catalog is reg
|
||||
|
||||
| Skill | Description | Path |
|
||||
|-------|-------------|------|
|
||||
| [`airtable`](/docs/user-guide/skills/bundled/productivity/productivity-airtable) | Read/write Airtable bases via REST API using curl. List bases, tables, and records; create, update, and delete records. No dependencies beyond curl. | `productivity/airtable` |
|
||||
| [`airtable`](/docs/user-guide/skills/bundled/productivity/productivity-airtable) | Airtable REST API via curl. Records CRUD, filters, upserts. | `productivity/airtable` |
|
||||
| [`google-workspace`](/docs/user-guide/skills/bundled/productivity/productivity-google-workspace) | Gmail, Calendar, Drive, Contacts, Sheets, and Docs integration for Hermes. Uses Hermes-managed OAuth2 setup, prefers the Google Workspace CLI (`gws`) when available for broader API coverage, and falls back to the Python client libraries... | `productivity/google-workspace` |
|
||||
| [`linear`](/docs/user-guide/skills/bundled/productivity/productivity-linear) | Manage Linear issues, projects, and teams via the GraphQL API. Create, update, search, and organize issues. Uses API key auth (no OAuth needed). All operations via curl — no dependencies. | `productivity/linear` |
|
||||
| [`maps`](/docs/user-guide/skills/bundled/productivity/productivity-maps) | Location intelligence — geocode a place, reverse-geocode coordinates, find nearby places (46 POI categories), driving/walking/cycling distance + time, turn-by-turn directions, timezone lookup, bounding box + area for a named place, and P... | `productivity/maps` |
|
||||
|
||||
Reference in New Issue
Block a user