Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Airtable REST API via curl. Records CRUD, filters, upserts.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-04 | ✗→✓ | ▲ Improved | 124% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 124% | 0% |
| case-02 | ✓→✗ | ▼ Worse | 177% | 0% |
| case-05 | ✓→✓ | = Same ✓ | 242% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 280% | 0% |
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.
pat...).data.records:read — read rowsdata.records:write — create / update / delete rowsschema.bases:read — list bases and tables403.${HERMES_HOME:-~/.hermes}/.env (or via hermes setup): AIRTABLE_API_KEY=pat_your_token_here
> Note: legacy key... API keys were deprecated Feb 2024. Only PATs and OAuth tokens work now.
https://api.airtable.com/v0Authorization: Bearer $AIRTABLE_API_KEYContent-Type: application/json for any POST/PATCH/PUT body).app..., tables tbl..., records rec..., fields fld.... IDs never change; names can. Prefer IDs in automations.429 → back off. Burst on a single base will be throttled.Base curl pattern:
bashcurl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=5" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
-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 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).
bashcurl -s "https://api.airtable.com/v0/meta/bases" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
bashcurl -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.
bashcurl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=10" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
bashcurl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
Airtable formulas must be URL-encoded. Let Python stdlib do it — never hand-encode:
bashFORMULA="{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:
{Email}='user@example.com'FIND('bug', LOWER({Title}))AND({Status}='Todo', {Priority}='High')OR({Owner}='alice', {Owner}='bob')NOT({Assignee}='')IS_AFTER({Due}, TODAY())bashcurl -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).
bashcurl -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.
bashcurl -s -X POST "https://api.airtable.com/v0/$BASE_ID/$TABLE" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fields":{"Name":"New task","Status":"Todo","Priority":"High"}}' | python3 -m json.tool
bashcurl -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.
bashcurl -s -X PATCH "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"fields":{"Status":"Done"}}' | python3 -m json.tool
bashcurl -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.
bashcurl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \ -H "Authorization: Bearer $AIRTABLE_API_KEY" | python3 -m json.tool
bashcurl -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
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:
bashOFFSET="" 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
curl -s -o /dev/null -w "%{http_code}\n" https://api.airtable.com/v0/meta/bases -H "Authorization: Bearer $AIRTABLE_API_KEY" — expect 200.app... ID directly if the token lacks schema.bases:read.GET /v0/meta/bases/$BASE_ID/tables — cache the exact field names and primary-field name locally in the session before mutating anything.filterByFormula first to resolve the rec... ID, then PATCH /v0/$BASE_ID/$TABLE/$RECORD_ID. Never guess record IDs.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."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 merges supplied fields into the record. PUT replaces the record entirely and clears any field you didn't include. Default to PATCH."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).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.baseA and 5 req/sec on baseB is fine; 6 req/sec on baseA alone will throttle. Monitor the Retry-After header on 429.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.{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.python3 -m json.tool (always present) rather than jq (optional). Only reach for jq when you need filtering/projection.offset until the field is absent.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.Other measured skills in the registry, with their headline benchmark lift.