[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7C46M89CAKC5ECMZZHSDXWM":3,"skill-same:01M7C46M89CAKC5ECMZZHSDXWM":62},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":17,"publishedAt":18,"linkOnly":19,"markdownContent":20,"markdownBody":21,"frontMatter":22,"refName":42,"commitSha":43,"skillDirectory":44,"forksCount":13,"repositoryHtmlUrl":45,"repositoryHomepageUrl":6,"ownerHtmlUrl":46,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":47,"install":48,"resources":61},"01M7C46M89CAKC5ECMZZHSDXWM","airtable",null,"Airtable REST API via curl. Records CRUD, filters, upserts.","fengwhang\u002Fmercury","fengwhang","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F325040254?v=4","01M7C3K54Y41KVGPHEMKEP9X70","01M7C3K55NTZ4B36YP2JY1NMJ0",0,"MIT","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fhermes\u002Fskills\u002Fproductivity\u002Fairtable\u002FSKILL.md",[],[],"2026-11-15 14:19:17",false,"---\nname: airtable\ndescription: Airtable REST API via curl. Records CRUD, filters, upserts.\nversion: 1.1.0\nauthor: community\nlicense: MIT\nplatforms: [linux, macos, windows]\nprerequisites:\n  env_vars: [AIRTABLE_API_KEY]\n  commands: [curl]\nmetadata:\n  mercury:\n    tags: [Airtable, Productivity, Database, API]\n    homepage: https:\u002F\u002Fairtable.com\u002Fdevelopers\u002Fweb\u002Fapi\u002Fintroduction\n---\n\n# Airtable — Bases, Tables & Records\n\nWork 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.\n\n## Prerequisites\n\n1. Create a **Personal Access Token (PAT)** at https:\u002F\u002Fairtable.com\u002Fcreate\u002Ftokens (tokens start with `pat...`).\n2. Grant these scopes (minimum):\n   - `data.records:read` — read rows\n   - `data.records:write` — create \u002F update \u002F delete rows\n   - `schema.bases:read` — list bases and tables\n3. **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`.\n4. Store the token in `$MERCURY_HOME\u002F.env` (or via `mercury setup`):\n   ```\n   AIRTABLE_API_KEY=pat_your_token_here\n   ```\n\n> Note: legacy `key...` API keys were deprecated Feb 2024. Only PATs and OAuth tokens work now.\n\n## API Basics\n\n- **Endpoint:** `https:\u002F\u002Fapi.airtable.com\u002Fv0`\n- **Auth header:** `Authorization: Bearer $AIRTABLE_API_KEY`\n- **All requests** use JSON (`Content-Type: application\u002Fjson` for any POST\u002FPATCH\u002FPUT body).\n- **Object IDs:** bases `app...`, tables `tbl...`, records `rec...`, fields `fld...`. IDs never change; names can. Prefer IDs in automations.\n- **Rate limit:** 5 requests\u002Fsec\u002Fbase. `429` → back off. Burst on a single base will be throttled.\n\nBase curl pattern:\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?maxRecords=5\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n`-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.\n\n## Field Types (request body shapes)\n\n| Field type | Write shape |\n|---|---|\n| Single line text | `\"Name\": \"hello\"` |\n| Long text | `\"Notes\": \"multi\\nline\"` |\n| Number | `\"Score\": 42` |\n| Checkbox | `\"Done\": true` |\n| Single select | `\"Status\": \"Todo\"` (name must already exist unless `typecast: true`) |\n| Multi-select | `\"Tags\": [\"urgent\", \"bug\"]` |\n| Date | `\"Due\": \"2026-04-01\"` |\n| DateTime (UTC) | `\"At\": \"2026-04-01T14:30:00.000Z\"` |\n| URL \u002F Email \u002F Phone | `\"Link\": \"https:\u002F\u002F…\"` |\n| Attachment | `\"Files\": [{\"url\": \"https:\u002F\u002F…\"}]` (Airtable fetches + rehosts) |\n| Linked record | `\"Owner\": [\"recXXXXXXXXXXXXXX\"]` (array of record IDs) |\n| User | `\"AssignedTo\": {\"id\": \"usrXXXXXXXXXXXXXX\"}` |\n\nPass `\"typecast\": true` at the top level of a create\u002Fupdate body to let Airtable auto-coerce values (e.g. create a new select option on the fly, convert `\"42\"` → `42`).\n\n## Common Queries\n\n### List bases the token can see\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002Fmeta\u002Fbases\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### List tables + schema for a base\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002Fmeta\u002Fbases\u002F$BASE_ID\u002Ftables\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\nUse this BEFORE mutating — confirms exact field names and IDs, surfaces `options.choices` for select fields, and shows primary-field names.\n\n### List records (first 10)\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?maxRecords=10\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### Get a single record\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### Filter records (filterByFormula)\nAirtable formulas must be URL-encoded. Let Python stdlib do it — never hand-encode:\n```bash\nFORMULA=\"{Status}='Todo'\"\nENC=$(python -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=\"\"))' \"$FORMULA\")\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?filterByFormula=$ENC&maxRecords=20\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\nUseful formula patterns:\n- Exact match: `{Email}='user@example.com'`\n- Contains: `FIND('bug', LOWER({Title}))`\n- Multiple conditions: `AND({Status}='Todo', {Priority}='High')`\n- Or: `OR({Owner}='alice', {Owner}='bob')`\n- Not empty: `NOT({Assignee}='')`\n- Date comparison: `IS_AFTER({Due}, TODAY())`\n\n### Sort + select specific fields\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?sort%5B0%5D%5Bfield%5D=Priority&sort%5B0%5D%5Bdirection%5D=asc&fields%5B%5D=Name&fields%5B%5D=Status\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\nSquare brackets in query params MUST be URL-encoded (`%5B` \u002F `%5D`).\n\n### Use a named view\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?view=Grid%20view&maxRecords=50\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\nViews apply their saved filter + sort server-side.\n\n## Common Mutations\n\n### Create a record\n```bash\ncurl -s -X POST \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"fields\":{\"Name\":\"New task\",\"Status\":\"Todo\",\"Priority\":\"High\"}}' | python -m json.tool\n```\n\n### Create up to 10 records in one call\n```bash\ncurl -s -X POST \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\n    \"typecast\": true,\n    \"records\": [\n      {\"fields\": {\"Name\": \"Task A\", \"Status\": \"Todo\"}},\n      {\"fields\": {\"Name\": \"Task B\", \"Status\": \"In progress\"}}\n    ]\n  }' | python -m json.tool\n```\nBatch endpoints are capped at **10 records per request**. For larger inserts, loop in batches of 10 with a short sleep to respect 5 req\u002Fsec\u002Fbase.\n\n### Update a record (PATCH — merges, preserves unchanged fields)\n```bash\ncurl -s -X PATCH \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"fields\":{\"Status\":\"Done\"}}' | python -m json.tool\n```\n\n### Upsert by a merge field (no ID needed)\n```bash\ncurl -s -X PATCH \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\n    \"performUpsert\": {\"fieldsToMergeOn\": [\"Email\"]},\n    \"records\": [\n      {\"fields\": {\"Email\": \"user@example.com\", \"Status\": \"Active\"}}\n    ]\n  }' | python -m json.tool\n```\n`performUpsert` creates records whose merge-field values are new, patches records whose merge-field values already exist. Great for idempotent syncs.\n\n### Delete a record\n```bash\ncurl -s -X DELETE \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### Delete up to 10 records in one call\n```bash\ncurl -s -X DELETE \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?records%5B%5D=rec1&records%5B%5D=rec2\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n## Pagination\n\nList 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:\n\n```bash\nOFFSET=\"\"\nwhile :; do\n  URL=\"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?pageSize=100\"\n  [ -n \"$OFFSET\" ] && URL=\"$URL&offset=$OFFSET\"\n  RESP=$(curl -s \"$URL\" -H \"Authorization: Bearer $AIRTABLE_API_KEY\")\n  echo \"$RESP\" | python -c 'import json,sys; d=json.load(sys.stdin); [print(r[\"id\"], r[\"fields\"].get(\"Name\",\"\")) for r in d[\"records\"]]'\n  OFFSET=$(echo \"$RESP\" | python -c 'import json,sys; d=json.load(sys.stdin); print(d.get(\"offset\",\"\"))')\n  [ -z \"$OFFSET\" ] && break\ndone\n```\n\n## Typical Hermes Workflow\n\n1. **Confirm auth.** `curl -s -o \u002Fdev\u002Fnull -w \"%{http_code}\\n\" https:\u002F\u002Fapi.airtable.com\u002Fv0\u002Fmeta\u002Fbases -H \"Authorization: Bearer $AIRTABLE_API_KEY\"` — expect `200`.\n2. **Find the base.** List bases (step above) OR ask the user for the `app...` ID directly if the token lacks `schema.bases:read`.\n3. **Inspect the schema.** `GET \u002Fv0\u002Fmeta\u002Fbases\u002F$BASE_ID\u002Ftables` — cache the exact field names and primary-field name locally in the session before mutating anything.\n4. **Read before you write.** For \"update X where Y\", `filterByFormula` first to resolve the `rec...` ID, then `PATCH \u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID`. Never guess record IDs.\n5. **Batch writes.** Combine related creates into one 10-record POST to stay under the 5 req\u002Fsec budget.\n6. **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.\n\n## Pitfalls\n\n- **`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.\n- **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.\n- **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`.\n- **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).\n- **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:\u002F\u002Fairtable.com\u002Fcreate\u002Ftokens to grant it.\n- **Rate limits are per base, not per token.** 5 req\u002Fsec on `baseA` and 5 req\u002Fsec on `baseB` is fine; 6 req\u002Fsec on `baseA` alone will throttle. Monitor the `Retry-After` header on `429`.\n\n## Important Notes for Hermes\n\n- **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).\n- **`AIRTABLE_API_KEY` flows from `$MERCURY_HOME\u002F.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call.\n- **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 `python urllib.parse.quote` before splicing into a URL.\n- **Pretty-print with `python -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering\u002Fprojection.\n- **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.\n- **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.\n","# Airtable — Bases, Tables & Records\n\nWork 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.\n\n## Prerequisites\n\n1. Create a **Personal Access Token (PAT)** at https:\u002F\u002Fairtable.com\u002Fcreate\u002Ftokens (tokens start with `pat...`).\n2. Grant these scopes (minimum):\n   - `data.records:read` — read rows\n   - `data.records:write` — create \u002F update \u002F delete rows\n   - `schema.bases:read` — list bases and tables\n3. **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`.\n4. Store the token in `$MERCURY_HOME\u002F.env` (or via `mercury setup`):\n   ```\n   AIRTABLE_API_KEY=pat_your_token_here\n   ```\n\n> Note: legacy `key...` API keys were deprecated Feb 2024. Only PATs and OAuth tokens work now.\n\n## API Basics\n\n- **Endpoint:** `https:\u002F\u002Fapi.airtable.com\u002Fv0`\n- **Auth header:** `Authorization: Bearer $AIRTABLE_API_KEY`\n- **All requests** use JSON (`Content-Type: application\u002Fjson` for any POST\u002FPATCH\u002FPUT body).\n- **Object IDs:** bases `app...`, tables `tbl...`, records `rec...`, fields `fld...`. IDs never change; names can. Prefer IDs in automations.\n- **Rate limit:** 5 requests\u002Fsec\u002Fbase. `429` → back off. Burst on a single base will be throttled.\n\nBase curl pattern:\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?maxRecords=5\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n`-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.\n\n## Field Types (request body shapes)\n\n| Field type | Write shape |\n|---|---|\n| Single line text | `\"Name\": \"hello\"` |\n| Long text | `\"Notes\": \"multi\\nline\"` |\n| Number | `\"Score\": 42` |\n| Checkbox | `\"Done\": true` |\n| Single select | `\"Status\": \"Todo\"` (name must already exist unless `typecast: true`) |\n| Multi-select | `\"Tags\": [\"urgent\", \"bug\"]` |\n| Date | `\"Due\": \"2026-04-01\"` |\n| DateTime (UTC) | `\"At\": \"2026-04-01T14:30:00.000Z\"` |\n| URL \u002F Email \u002F Phone | `\"Link\": \"https:\u002F\u002F…\"` |\n| Attachment | `\"Files\": [{\"url\": \"https:\u002F\u002F…\"}]` (Airtable fetches + rehosts) |\n| Linked record | `\"Owner\": [\"recXXXXXXXXXXXXXX\"]` (array of record IDs) |\n| User | `\"AssignedTo\": {\"id\": \"usrXXXXXXXXXXXXXX\"}` |\n\nPass `\"typecast\": true` at the top level of a create\u002Fupdate body to let Airtable auto-coerce values (e.g. create a new select option on the fly, convert `\"42\"` → `42`).\n\n## Common Queries\n\n### List bases the token can see\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002Fmeta\u002Fbases\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### List tables + schema for a base\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002Fmeta\u002Fbases\u002F$BASE_ID\u002Ftables\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\nUse this BEFORE mutating — confirms exact field names and IDs, surfaces `options.choices` for select fields, and shows primary-field names.\n\n### List records (first 10)\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?maxRecords=10\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### Get a single record\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### Filter records (filterByFormula)\nAirtable formulas must be URL-encoded. Let Python stdlib do it — never hand-encode:\n```bash\nFORMULA=\"{Status}='Todo'\"\nENC=$(python -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=\"\"))' \"$FORMULA\")\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?filterByFormula=$ENC&maxRecords=20\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\nUseful formula patterns:\n- Exact match: `{Email}='user@example.com'`\n- Contains: `FIND('bug', LOWER({Title}))`\n- Multiple conditions: `AND({Status}='Todo', {Priority}='High')`\n- Or: `OR({Owner}='alice', {Owner}='bob')`\n- Not empty: `NOT({Assignee}='')`\n- Date comparison: `IS_AFTER({Due}, TODAY())`\n\n### Sort + select specific fields\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?sort%5B0%5D%5Bfield%5D=Priority&sort%5B0%5D%5Bdirection%5D=asc&fields%5B%5D=Name&fields%5B%5D=Status\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\nSquare brackets in query params MUST be URL-encoded (`%5B` \u002F `%5D`).\n\n### Use a named view\n```bash\ncurl -s \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?view=Grid%20view&maxRecords=50\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\nViews apply their saved filter + sort server-side.\n\n## Common Mutations\n\n### Create a record\n```bash\ncurl -s -X POST \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"fields\":{\"Name\":\"New task\",\"Status\":\"Todo\",\"Priority\":\"High\"}}' | python -m json.tool\n```\n\n### Create up to 10 records in one call\n```bash\ncurl -s -X POST \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\n    \"typecast\": true,\n    \"records\": [\n      {\"fields\": {\"Name\": \"Task A\", \"Status\": \"Todo\"}},\n      {\"fields\": {\"Name\": \"Task B\", \"Status\": \"In progress\"}}\n    ]\n  }' | python -m json.tool\n```\nBatch endpoints are capped at **10 records per request**. For larger inserts, loop in batches of 10 with a short sleep to respect 5 req\u002Fsec\u002Fbase.\n\n### Update a record (PATCH — merges, preserves unchanged fields)\n```bash\ncurl -s -X PATCH \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\"fields\":{\"Status\":\"Done\"}}' | python -m json.tool\n```\n\n### Upsert by a merge field (no ID needed)\n```bash\ncurl -s -X PATCH \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" \\\n  -H \"Content-Type: application\u002Fjson\" \\\n  -d '{\n    \"performUpsert\": {\"fieldsToMergeOn\": [\"Email\"]},\n    \"records\": [\n      {\"fields\": {\"Email\": \"user@example.com\", \"Status\": \"Active\"}}\n    ]\n  }' | python -m json.tool\n```\n`performUpsert` creates records whose merge-field values are new, patches records whose merge-field values already exist. Great for idempotent syncs.\n\n### Delete a record\n```bash\ncurl -s -X DELETE \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n### Delete up to 10 records in one call\n```bash\ncurl -s -X DELETE \"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?records%5B%5D=rec1&records%5B%5D=rec2\" \\\n  -H \"Authorization: Bearer $AIRTABLE_API_KEY\" | python -m json.tool\n```\n\n## Pagination\n\nList 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:\n\n```bash\nOFFSET=\"\"\nwhile :; do\n  URL=\"https:\u002F\u002Fapi.airtable.com\u002Fv0\u002F$BASE_ID\u002F$TABLE?pageSize=100\"\n  [ -n \"$OFFSET\" ] && URL=\"$URL&offset=$OFFSET\"\n  RESP=$(curl -s \"$URL\" -H \"Authorization: Bearer $AIRTABLE_API_KEY\")\n  echo \"$RESP\" | python -c 'import json,sys; d=json.load(sys.stdin); [print(r[\"id\"], r[\"fields\"].get(\"Name\",\"\")) for r in d[\"records\"]]'\n  OFFSET=$(echo \"$RESP\" | python -c 'import json,sys; d=json.load(sys.stdin); print(d.get(\"offset\",\"\"))')\n  [ -z \"$OFFSET\" ] && break\ndone\n```\n\n## Typical Hermes Workflow\n\n1. **Confirm auth.** `curl -s -o \u002Fdev\u002Fnull -w \"%{http_code}\\n\" https:\u002F\u002Fapi.airtable.com\u002Fv0\u002Fmeta\u002Fbases -H \"Authorization: Bearer $AIRTABLE_API_KEY\"` — expect `200`.\n2. **Find the base.** List bases (step above) OR ask the user for the `app...` ID directly if the token lacks `schema.bases:read`.\n3. **Inspect the schema.** `GET \u002Fv0\u002Fmeta\u002Fbases\u002F$BASE_ID\u002Ftables` — cache the exact field names and primary-field name locally in the session before mutating anything.\n4. **Read before you write.** For \"update X where Y\", `filterByFormula` first to resolve the `rec...` ID, then `PATCH \u002Fv0\u002F$BASE_ID\u002F$TABLE\u002F$RECORD_ID`. Never guess record IDs.\n5. **Batch writes.** Combine related creates into one 10-record POST to stay under the 5 req\u002Fsec budget.\n6. **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.\n\n## Pitfalls\n\n- **`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.\n- **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.\n- **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`.\n- **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).\n- **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:\u002F\u002Fairtable.com\u002Fcreate\u002Ftokens to grant it.\n- **Rate limits are per base, not per token.** 5 req\u002Fsec on `baseA` and 5 req\u002Fsec on `baseB` is fine; 6 req\u002Fsec on `baseA` alone will throttle. Monitor the `Retry-After` header on `429`.\n\n## Important Notes for Hermes\n\n- **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).\n- **`AIRTABLE_API_KEY` flows from `$MERCURY_HOME\u002F.env` into the subprocess automatically** when this skill is loaded — no need to re-export it before each `curl` call.\n- **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 `python urllib.parse.quote` before splicing into a URL.\n- **Pretty-print with `python -m json.tool`** (always present) rather than `jq` (optional). Only reach for `jq` when you need filtering\u002Fprojection.\n- **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.\n- **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.\n",{"name":5,"author":23,"license":14,"version":24,"metadata":25,"platforms":33,"description":7,"prerequisites":37},"community","1.1.0",{"mercury":26},{"tags":27,"homepage":32},[28,29,30,31],"Airtable","Productivity","Database","API","https:\u002F\u002Fairtable.com\u002Fdevelopers\u002Fweb\u002Fapi\u002Fintroduction",[34,35,36],"linux","macos","windows",{"commands":38,"env_vars":40},[39],"curl",[41],"AIRTABLE_API_KEY","main","94a12cab2ce32e2020d179b5d579d0cbcbd0f206","hermes\u002Fskills\u002Fproductivity\u002Fairtable","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury","https:\u002F\u002Fgithub.com\u002Ffengwhang","2026-10-09 08:26:42",{"source":8,"skillName":5,"commands":49,"prompt":59,"zipAvailable":60},[50,53,56],{"tool":51,"command":52},"npx","npx skills add fengwhang\u002Fmercury --skill airtable",{"tool":54,"command":55},"bunx","bunx skills add fengwhang\u002Fmercury --skill airtable",{"tool":57,"command":58},"pnpm","pnpm dlx skills add fengwhang\u002Fmercury --skill airtable","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fhermes\u002Fskills\u002Fproductivity\u002Fairtable\u002FSKILL.md",true,[],[63,70,77,84,90,97],{"skillNo":64,"name":65,"nameZh":6,"description":66,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":67,"categories":68,"keywords":69,"publishedAt":18},"01M7C4BTY3ND38CN200ZCTH6NV","greet","A greeting skill for testing","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fomp\u002Fpackages\u002Fcoding-agent\u002Ftest\u002Fmarketplace\u002Ffixtures\u002Fvalid-marketplace\u002Fplugins\u002Fhello-plugin\u002Fskills\u002Fgreet\u002FSKILL.md",[],[],{"skillNo":71,"name":72,"nameZh":6,"description":73,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":74,"categories":75,"keywords":76,"publishedAt":18},"01M7C4BSNZTPW8JR5NGCQXGSEZ","valid-skill","A valid skill for testing purposes.","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fomp\u002Fpackages\u002Fcoding-agent\u002Ftest\u002Ffixtures\u002Fskills\u002Fvalid-skill\u002FSKILL.md",[],[],{"skillNo":78,"name":79,"nameZh":6,"description":80,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":81,"categories":82,"keywords":83,"publishedAt":18},"01M7C4BRA7J8C06KSVW100F1W1","unknown-field","A skill with an unknown frontmatter field.","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fomp\u002Fpackages\u002Fcoding-agent\u002Ftest\u002Ffixtures\u002Fskills\u002Funknown-field\u002FSKILL.md",[],[],{"skillNo":85,"name":86,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":87,"categories":88,"keywords":89,"publishedAt":18},"01M7C4BNB04KQGCJ835JE8AR13","no-frontmatter","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fomp\u002Fpackages\u002Fcoding-agent\u002Ftest\u002Ffixtures\u002Fskills\u002Fno-frontmatter\u002FSKILL.md",[],[],{"skillNo":91,"name":92,"nameZh":6,"description":93,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":94,"categories":95,"keywords":96,"publishedAt":18},"01M7C4BM38TZFYT1AJ6SKHPZZ2","child-skill","A nested skill in a subdirectory.","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fomp\u002Fpackages\u002Fcoding-agent\u002Ftest\u002Ffixtures\u002Fskills\u002Fnested\u002Fchild-skill\u002FSKILL.md",[],[],{"skillNo":98,"name":99,"nameZh":6,"description":100,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":6,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":101,"categories":102,"keywords":103,"publishedAt":18},"01M7C4BJDN7RFFCM28CGX15Q97","different-name","A skill with a name that doesn't match the directory.","https:\u002F\u002Fgithub.com\u002Ffengwhang\u002Fmercury\u002Fblob\u002Fmain\u002Fomp\u002Fpackages\u002Fcoding-agent\u002Ftest\u002Ffixtures\u002Fskills\u002Fname-mismatch\u002FSKILL.md",[],[]]