Airtable REST API with curl: CRUD, filters, upserts in Hermes Agent
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 documentationThis skill gives Hermes Agent direct access to Airtable's REST API using nothing but curl and a personal access token. No MCP server, no OAuth dance, no Python SDK. If you need to read, write, or sync Airtable records from an automation, this is the leanest path. It is bundled with Hermes Agent, so it is ready to use out of the box.
What it does
With this skill, Hermes can list the bases and tables your token can see, inspect their schemas, fetch records with filters and sorting, create and update records individually or in batches, upsert by a merge field, and delete records. All of it happens through the terminal tool with curl. The skill handles the authentication header for you, so each command just needs the base and table IDs.
The real strength is the workflow it encodes: confirm auth, find the base, inspect the schema, read before you write, batch writes, and confirm destructive operations. That sequence prevents most of the common mistakes people make with the Airtable API, like guessing field names or record IDs.
Before you start
You need a Personal Access Token (PAT) from https://airtable.com/create/tokens. Tokens start with pat.... Legacy key... API keys were deprecated in February 2024, so only PATs and OAuth tokens work now.
When you create the token, grant at least these scopes:
data.records:readto read rowsdata.records:writeto create, update, or delete rowsschema.bases:readto list bases and tables
Crucially, in the same token UI you must 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. This trips up a lot of people: the token works on one base but not another, and it looks like an auth problem when it is really an access-list problem.
Store the token in ${HERMES_HOME:-~/.hermes}/.env or via hermes setup:
AIRTABLE_API_KEY=pat_your_token_here
When this skill is loaded, AIRTABLE_API_KEY flows from that file into the subprocess automatically. You do not need to re-export it before each curl call.
API basics
- Endpoint:
https://api.airtable.com/v0 - Auth header:
Authorization: Bearer $AIRTABLE_API_KEY - All requests use JSON (
Content-Type: application/jsonfor any POST/PATCH/PUT body). - Object IDs: bases
app..., tablestbl..., recordsrec..., fieldsfld.... 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.
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
-s suppresses curl's progress bar. Keep it set for every call so the tool output stays clean for Hermes. Pipe through python -m json.tool (always present) or jq (if installed) for readable JSON.
Field types and request body shapes
Airtable fields have specific JSON shapes when you write them. The table below shows what to send for each type.
| 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. For example, you can create a new select option on the fly or convert "42" to 42. This is handy when you are not sure the option exists yet.
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 returns every base the token has access to. If you get an empty list, check the token's Access list.
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
Use this BEFORE mutating anything. It confirms exact field names and IDs, surfaces options.choices for select fields, and shows primary-field names. Skipping this step is how you end up with INVALID_FIELD_NAME errors.
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 (filterByFormula)
Airtable formulas must be URL-encoded. Let Python stdlib do it, never hand-encode:
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
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 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 params MUST be URL-encoded (%5B / %5D).
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. This is a great way to reuse a filter you already built in the Airtable UI.
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 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)
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
Upsert by a merge field (no ID needed)
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. This is ideal for idempotent syncs, where you want to run the same operation repeatedly without creating 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. If the response includes "offset": "...", pass it back on the next call. Loop 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
This pattern is reliable and avoids loading everything into memory at once. The 100-record cap is a hard limit; there is no way to bump it.
Typical Hermes workflow
- Confirm auth.
curl -s -o /dev/null -w "%{http_code}\n" https://api.airtable.com/v0/meta/bases -H "Authorization: Bearer $AIRTABLE_API_KEY", expect200. - Find the base. List bases (step above) OR ask the user for the
app...ID directly if the token lacksschema.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",
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 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
filterByFormulaMUST 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.
PATCHmerges supplied fields into the record.PUTreplaces the record entirely and clears any field you didn't include. Default toPATCH. - Single-select options must exist. Writing
"Status": "Shipping"whenShippingisn't in the field's option list errors withINVALID_MULTIPLE_CHOICE_OPTIONSunless you pass"typecast": true(which auto-creates the option). - Per-base token scoping. A
403on 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
baseAand 5 req/sec onbaseBis fine; 6 req/sec onbaseAalone will throttle. Monitor theRetry-Afterheader on429.
Important notes for Hermes
- Always use the
terminaltool withcurl. Do NOT useweb_extract(it can't send auth headers) orbrowser_navigate(needs UI auth and is slow). AIRTABLE_API_KEYflows from${HERMES_HOME:-~/.hermes}/.envinto the subprocess automatically when this skill is loaded, no need to re-export it before eachcurlcall.- 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 throughpython urllib.parse.quotebefore splicing into a URL. - Pretty-print with
python -m json.tool(always present) rather thanjq(optional). Only reach forjqwhen 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
offsetuntil the field is absent. - Read the
errorsarray on non-2xx responses, Airtable returns structured error codes likeAUTHENTICATION_REQUIRED,INVALID_PERMISSIONS,MODEL_ID_NOT_FOUND,INVALID_MULTIPLE_CHOICE_OPTIONSthat tell you exactly what's wrong.
When not to use it
This skill is not for you if you need to work with Airtable through a browser or a web page. The source explicitly warns against web_extract and browser_navigate for this purpose. Also, if you are dealing with a very large dataset that requires complex joins or heavy transformations, the REST API's per-page and rate limits will make you work harder than a dedicated integration would. For simple CRUD and sync tasks, though, this is the fastest route.
Limits and gotchas
The main limits are the 5 requests per second per base, the 10-record batch cap for creates and deletes, and the 100-record page size for list endpoints. The per-base token scoping is the most confusing gotcha: a 403 on one base while another works means the token's Access list does not include that base. Also remember that empty fields are omitted from responses, so a missing key does not mean the field is absent. And always prefer PATCH over PUT unless you intentionally want to clear unspecified fields.
What pairs with this
This skill is part of the Productivity category in Hermes Agent. It pairs naturally with other bundled skills that handle data transformation or file I/O, since you can pipe JSON from Airtable into other tools. The source does not list specific related links, but the workflow described here (confirm auth, inspect schema, read before write, batch, confirm destructive ops) is a template you can apply to any REST API skill in Hermes.