Airtable REST API with curl: A Hermes Agent Skill Guide
Airtable REST API via curl. Records CRUD, filters, upserts.
Written by Neura Market from the official Hermes Agent documentation for Airtable. Commands, paths, and version numbers are reproduced from the source unchanged.
Read the official documentationThe Airtable skill gives Hermes Agent direct access to Airtable's REST API through curl calls made with the terminal tool. There is no MCP server, no OAuth dance, and no Python SDK in the loop: a personal access token plus shell commands is the whole stack. Reach for it when an agent needs to read, filter, create, update, or delete records in a base, or when you want idempotent syncs driven by a merge field.
What it does
The skill teaches the agent the request shapes Airtable expects and the conventions that keep those requests reliable. It covers listing bases and tables, inspecting schema before a write, reading single records, filtering with filterByFormula, sorting and projecting fields, using saved views, creating records one at a time or in batches of up to 10, patching records, upserting by merge field, deleting records individually or in batches, and walking paginated list responses with an offset cursor.
Because everything runs through curl, the agent can compose requests dynamically: build a formula, URL-encode it with Python's standard library, splice it into a URL, and parse the JSON response. That flexibility is the point. The skill is a reference for how to shape those calls correctly, not a wrapper that hides the API.
Before you start
You need a Personal Access Token from https://airtable.com/create/tokens. Tokens start with pat.... The token needs at least these scopes:
data.records:readfor reading rowsdata.records:writefor creating, updating, and deleting rowsschema.bases:readfor listing bases and tables
One step trips people up more than any other: in the same token UI, every base you want to touch must be added to the token's Access list. PATs are scoped per base. A token that is otherwise valid but not granted access to a particular base returns 403 on that base, which can look like an auth failure when it is really a permissions gap.
Store the token in ${HERMES_HOME:-~/.hermes}/.env, or set it through hermes setup:
AIRTABLE_API_KEY=pat_your_token_here
Legacy key... API keys were deprecated in February 2024. Only PATs and OAuth tokens work now, so an old key sitting in an environment file will not get you anywhere.
The skill is bundled with Hermes Agent, so it is installed by default. It lives at skills/productivity/airtable, is version 1.1.0, is authored by the community, and ships under the MIT license. It runs on linux, macos, and windows.
API basics
The endpoint is https://api.airtable.com/v0. Every request authenticates with Authorization: Bearer $AIRTABLE_API_KEY, and any request with a body sends JSON with Content-Type: application/json.
Object IDs follow a prefix convention: bases are app..., tables are tbl..., records are rec..., and fields are fld.... IDs never change while names can, so automations should prefer IDs. The rate limit is 5 requests per second per base. A 429 means back off; bursting against a single base will get throttled.
The base curl pattern looks like this:
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=5" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
Keep -s on every call. It suppresses curl's progress bar, which keeps the tool output clean for Hermes to read. Piping through python -m json.tool gives readable JSON and is always available; jq works too if it happens to be installed.
Field types and request body shapes
Each Airtable field type expects a particular JSON shape on write. The skill documents them as a table:
| 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"} |
Adding "typecast": true at the top level of a create or update body lets Airtable coerce values for you. That covers creating a new select option on the fly and converting a string like "42" into the number 42.
Common queries
List bases the token can see
curl -s "https://api.airtable.com/v0/meta/bases" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
This is the discovery call. It tells you which bases the token's Access list actually covers, which is the fastest way to diagnose a suspected permissions problem.
List tables and schema for a base
curl -s "https://api.airtable.com/v0/meta/bases/$BASE_ID/tables" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
Run this before any mutation. It confirms exact field names and IDs, surfaces options.choices for select fields, and shows primary-field names. Caching that schema in the session before writing saves a lot of failed requests.
List records (first 10)
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?maxRecords=10" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
Get a single record
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
Filter records with filterByFormula
Airtable formulas must be URL-encoded. Let Python's standard library do the work rather than hand-encoding:
FORMULA="{Status}='Todo'"
ENC=$(python -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" | python -m json.tool
Formula patterns worth keeping in mind:
- 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 and select specific fields
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" | python -m json.tool
Square brackets in query parameters must be URL-encoded as %5B and %5D. That is why the sort and field parameters look the way they do above.
Use a named view
curl -s "https://api.airtable.com/v0/$BASE_ID/$TABLE?view=Grid%20view&maxRecords=50" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
Views apply their saved filter and sort server-side, so pointing at a view is often simpler than reconstructing the same logic in a formula.
Common mutations
Create a record
curl -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"}}' | python -m json.tool
Create up to 10 records in one call
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"}}
]
}' | python -m json.tool
Batch endpoints cap at 10 records per request. For larger inserts, loop in batches of 10 with a short sleep between them to stay inside the 5 requests per second per base budget.
Update a record with PATCH
curl -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"}}' | python -m json.tool
PATCH merges the supplied fields into the record and leaves everything you did not mention untouched. That is usually what you want.
Upsert by a merge field
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"}}
]
}' | python -m json.tool
performUpsert creates records whose merge-field values are new and patches records whose merge-field values already exist. No record ID is needed. This is the tool for idempotent syncs: run the same payload twice and you do not get duplicates.
Delete a record
curl -s -X DELETE "https://api.airtable.com/v0/$BASE_ID/$TABLE/$RECORD_ID" \
-H "Authorization: Bearer $AIRTABLE_API_KEY" | python -m json.tool
Delete up to 10 records in one call
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" | python -m json.tool
Pagination
List endpoints return at most 100 records per page. When the response includes an "offset": "..." field, pass it back on the next call and keep looping until the field is absent:
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" | python -c 'import json,sys; d=json.load(sys.stdin); [print(r["id"], r["fields"].get("Name","")) for r in d["records"]]'
OFFSET=$(echo "$RESP" | python -c 'import json,sys; d=json.load(sys.stdin); print(d.get("offset",""))')
[ -z "$OFFSET" ] && break
done
The 100-record cap is a hard limit. There is no parameter to raise it, so any complete read of a large table has to be a loop.
A typical Hermes workflow
The skill lays out a sequence that keeps an agent out of trouble:
- Confirm auth.
curl -s -o /dev/null -w "%{http_code}\n" https://api.airtable.com/v0/meta/bases -H "Authorization: Bearer $AIRTABLE_API_KEY"should return200. - Find the base. List bases as shown above, or ask the user for the
app...ID directly if the token lacksschema.bases:read. - Inspect the schema. Call
GET /v0/meta/bases/$BASE_ID/tablesand cache the exact field names and primary-field name in the session before mutating anything. - Read before you write. For an "update X where Y" request, use
filterByFormulafirst to resolve therec...ID, thenPATCH /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 requests per second budget.
- Handle destructive operations carefully. Deletions cannot be undone through the API. If the user says "delete all Xs", echo back the filter and the record count and confirm before firing.
Limits and gotchas
The pitfalls the skill calls out are worth reading twice, because most of them produce errors that look like something else.
filterByFormula must be URL-encoded. Field names with spaces or non-ASCII characters need encoding too, so {My Field} becomes %7BMy%20Field%7D. Use the Python standard library pattern shown earlier rather than hand-escaping.
Empty fields are omitted from responses. A missing "Assignee" key does not mean the field does not exist on the table. It means this record's value is empty. Check the schema before concluding a field is missing.
PATCH versus PUT. PATCH merges the supplied fields into the record. PUT replaces the record entirely and clears any field you did not include. Default to PATCH.
Single-select options must already exist. Writing "Status": "Shipping" when Shipping is not in the field's option list errors with INVALID_MULTIPLE_CHOICE_OPTIONS unless you pass "typecast": true, which auto-creates the option.
Token scoping is per base. A 403 on one base while another works means the token's Access list does not include that base. It is not a scope or auth problem. Send the user to https://airtable.com/create/tokens to grant access.
Rate limits are per base, not per token. Five requests per second on baseA and five on baseB is fine. Six per second on baseA alone will throttle. Watch the Retry-After header on 429 responses.
Notes specific to running inside Hermes
Always use the terminal tool with curl. Do not use web_extract, which cannot send auth headers, and do not use browser_exec, which needs UI auth and is slow.
AIRTABLE_API_KEY flows from ${HERMES_HOME:-~/.hermes}/.env into the subprocess automatically when this skill is loaded. There is 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 a brace-expansion context, but dynamic strings should still go through python urllib.parse.quote before being spliced into a URL.
Pretty-print with python -m json.tool, which is always present, rather than jq, which is optional. Reach for jq only when you need filtering or projection.
Pagination is per page, not global. Loop with offset until the field is absent.
On non-2xx responses, read the errors array. Airtable returns structured error codes such as AUTHENTICATION_REQUIRED, INVALID_PERMISSIONS, MODEL_ID_NOT_FOUND, and INVALID_MULTIPLE_CHOICE_OPTIONS that tell you exactly what went wrong.
When not to use it
The skill is explicit that web_extract and browser_exec are the wrong tools for Airtable work. web_extract cannot attach auth headers, and browser_exec depends on UI authentication and is slow. If you find yourself reaching for either, switch back to terminal and curl.
What pairs with it
This skill sits in the Productivity category alongside the rest of the bundled productivity skills. If your workflow moves data between Airtable and other systems, the same terminal and curl pattern carries over to any REST API that accepts a bearer token, so the request-shaping habits here transfer directly. For anything that needs a different auth model or a UI-only surface, look at the other bundled skills rather than stretching this one.