Talks to Airtable's REST API with curl and a personal access token: list/filter records, schema inspect, batch CRUD, and performUpsert syncs. Use when the user wants to read, create, update, delete, or idempotently sync rows in an Airtable base (app/tbl/rec IDs). Not for the Airtable web UI, OAuth MCP servers, or the Python SDK; do not use deprecated key-prefixed API keys.
Talks to Airtable's REST API with curl and a personal access token: list/filter records, schema inspect, batch CRUD, and performUpsert syncs. Use when the user wants to read, create, update, delete, or idempotently sync rows in an Airtable base (app/tbl/rec IDs). Not for the Airtable web UI, OAuth MCP servers, or the Python SDK; do not use deprecated key-prefixed API keys.
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.
When to Use
User asks to read, list, filter, or search Airtable records.
User asks to create, update, upsert, or delete records in an Airtable base.
User asks to inspect base/table schema (field names, types, select options).
User asks to sync data into or out of Airtable (idempotent upserts, batch inserts).
User mentions "Airtable", "base", "table", "records", or references an app... / tbl... / rec... ID.
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.
Store the token in ${HERMES_HOME:-~/.hermes}/.env (or via hermes setup):
AIRTABLE_API_KEY=pat_your_token_here
On Windows PowerShell, the equivalent home path is $env:USERPROFILE\.hermes\.env.
Note: legacy key... API keys were deprecated Feb 2024. Only PATs and OAuth tokens work now.
-s suppresses curl's progress bar — keep it set for every call so the tool output stays clean. 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)
"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).
Use this BEFORE mutating — confirms exact field names and IDs, surfaces options.choices for select fields, and shows primary-field names. Cache these locally in the session.
Step 4 — Read before you write
For "update X where Y", use filterByFormula first to resolve the rec... ID, then PATCH. Never guess record IDs.
Step 5 — Execute the operation
Choose from the common queries and mutations below.
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:
Find the base. List bases (step above) OR ask the user for the app... ID directly if the token lacks schema.bases:read.
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.
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.
Batch writes. Combine related creates into one 10-record POST to stay under the 5 req/sec budget.
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. 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.
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_HOME:-~/.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.
Error check — if any response is non-2xx, inspect the errors array for structured error codes (AUTHENTICATION_REQUIRED, INVALID_PERMISSIONS, MODEL_ID_NOT_FOUND, INVALID_MULTIPLE_CHOICE_OPTIONS).