Appendix for the unified Airtable skill. Full payload examples, element schemas, curl templates.
# Capture from DevTools → Network → any v0.3 request → Copy Cookie header
COOKIE="__Host-airtable-session=...; ..."
BASE_ID="appXXXXXXXXXXXXXX"HEADERS=(
-H "x-airtable-application-id: $BASE_ID"
-H "x-airtable-inter-service-client: webClient"
-H "x-airtable-page-load-id: pgloXXXXXXXXXXXXX"
-H "x-requested-with: XMLHttpRequest"
-H "x-time-zone: UTC"
-H "x-user-locale: en"
-H "cookie: $COOKIE"
)curl -s "https://airtable.com/v0.3/application/$BASE_ID/read?includeAllData=true" \
"${HEADERS[@]}" | jq '.data.workflowSectionsById'PAGE_ID="pagXXXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/page/$PAGE_ID/readDraft?stringifiedObjectParams=%7B%22expectedPageLayoutSchemaVersion%22%3A26%7D" \
"${HEADERS[@]}" | jq '.data.value.elementById | keys | length'WORKFLOW_ID="wflXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/read" \
"${HEADERS[@]}" | jq '.data.workflow.name'DEPLOYMENT_ID="wdpXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflowDeployment/$DEPLOYMENT_ID/read?stringifiedObjectParams=%7B%7D" \
"${HEADERS[@]}" | jq '.data'Faster than parsing application/{baseId}/read?includeAllData=true when you only need the workflow inventory. Returns id, name, deployment status, version, trigger, and graph summary for every workflow.
curl -s "https://airtable.com/v0.3/application/$BASE_ID/listWorkflows" \
"${HEADERS[@]}" | jq '.data.workflows | map({id, name, deploymentStatus, version})'Returns the most recent runs of a single workflow with createdTime + status. Paginated via offset. Use this to find runaway automations or compute per-workflow run rate.
WORKFLOW_ID="wflXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/listExecutions" \
"${HEADERS[@]}" | jq '.data.workflowExecutions | map({createdTime, status})'
# Pagination:
OFFSET="<value from .data.offset>"
curl -s "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/listExecutions?offset=$OFFSET" \
"${HEADERS[@]}"Page size is fixed at 20. To compute current run rate without paginating thousands of records, fetch the latest 20 and divide 19 / (newest_ts - oldest_ts).
State-changing POSTs. Both require secretSocketId and x-airtable-page-load-id from the user's active browser session — see gotcha below.
# Disable: POST unregister with empty params
SOCKET_ID="socXXXXXXXXXXXXXX" # from active browser websocket
PAGE_LOAD="pglXXXXXXXXXXXXXXX" # from active browser tab
curl -s -X POST "https://airtable.com/v0.3/workflow/$WORKFLOW_ID/unregister" \
"${HEADERS[@]}" \
-H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
-H "x-airtable-page-load-id: $PAGE_LOAD" \
-H "origin: https://airtable.com" \
--data-urlencode "stringifiedObjectParams={}" \
--data-urlencode "requestId=req$(openssl rand -hex 7 | head -c14)" \
--data-urlencode "secretSocketId=$SOCKET_ID"
# Enable: POST create on a NEW (client-generated) deployment ID
NEW_DEP="wfd$(openssl rand -hex 7 | head -c14)"
curl -s -X POST "https://airtable.com/v0.3/workflowDeployment/$NEW_DEP/create" \
"${HEADERS[@]}" \
-H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
-H "x-airtable-page-load-id: $PAGE_LOAD" \
-H "origin: https://airtable.com" \
--data-urlencode "stringifiedObjectParams={\"workflowId\":\"$WORKFLOW_ID\"}" \
--data-urlencode "requestId=req$(openssl rand -hex 7 | head -c14)" \
--data-urlencode "secretSocketId=$SOCKET_ID"Success returns {"msg":"SUCCESS","data":null}. After enable, the workflow's targetWorkflowDeploymentId is the new value. Verify with listWorkflows.
Use case — break a feedback loop in a runaway automation: disable → 5s pause → re-enable. Note that toggling does NOT fix the underlying loop condition — it only resets execution state. If the trigger condition still matches, the storm resumes.
Internal-API step writes are full-object replaces, not patches. Every step (trigger or action) carries an input.config map that binds the step's named inputs (e.g. recordId, tableId, value, email, body) to either literal values or references to upstream data — trigger record IDs, prior-step outputs, formulas. If you POST back a step without its existing input.config, those bindings vanish — the step still executes but every reference resolves to empty. Common symptoms: "Update record" hits the wrong record (or no record), "Send email" sends with blank body, "Run script" receives an empty input.config() object inside the script.
Mandatory round-trip pattern:
# 1. Fetch full deployment (contains every step's complete config)
DEPLOYMENT_ID="wdpXXXXXXXXXXXXX"
curl -s "https://airtable.com/v0.3/workflowDeployment/$DEPLOYMENT_ID/read?stringifiedObjectParams=%7B%7D" \
"${HEADERS[@]}" > /tmp/wfd.json
# 2. Inspect the target step — confirm what input.config currently holds
jq '.data.workflow.stepsById["<stepId>"].config' /tmp/wfd.json
# 3. Mutate ONLY the target leaf (jq edit-in-place, write to a new file).
# Example: change one mapped field's value while leaving every other
# binding in input.config untouched.
jq '.data.workflow.stepsById["<stepId>"].config.input.config["<inputName>"].value = "<new>"' \
/tmp/wfd.json > /tmp/wfd.new.json
# 4. Diff to confirm only the intended leaf changed.
diff <(jq -S . /tmp/wfd.json) <(jq -S . /tmp/wfd.new.json)
# 5. POST back the COMPLETE step (the write endpoint expects the whole step
# object — anything missing is treated as cleared, not preserved).Sanity checks before writing:
jq '.data.workflow.stepsById["<stepId>"].config.input.config | keys' /tmp/wfd.new.jsonshould match the same query against/tmp/wfd.json. Any key dropped from the new file is a binding you are about to destroy.- For "Run script" steps,
input.configis exactly the object the script'sinput.config()call returns at runtime. A missing key means the script readsundefined. - For "Update record" / "Find records" steps, the binding map includes
tableIdand the per-fieldvaluereferences — losing them retargets the action to the wrong table or clears the field write set.
The same discipline applies to manually authored automation edits in the Airtable UI when the change is scripted via the internal API: read full → mutate one leaf → write full. Never assemble a step from scratch unless you are creating a brand-new step.
State-changing POST endpoints (unregister, workflowDeployment/.../create, and similar) reject requests with HTTP 401 INVALID_AUTH_TOKEN unless the body includes a valid secretSocketId AND the headers include x-airtable-page-load-id from an active browser tab. GET endpoints (listWorkflows, listExecutions, read) do NOT require these — they only need the session cookie.
Both values rotate per browser session:
secretSocketId(soc...) — tied to the user's active websocket connection. Server validates it against the live ws session list.x-airtable-page-load-id(pgl...) — tied to the page that loaded the current SPA bundle.
To get them (DevTools route): open the Airtable web UI in Chrome, open DevTools → Network, perform any state-change action (e.g. toggle an automation off/on once), and copy the secretSocketId from the request body and x-airtable-page-load-id from request headers.
To get them (headless via AppleScript + Chrome): inject this snippet into a live Airtable tab and read both IDs out of performance.getEntriesByType('resource'):
JSON.stringify((function(){
var e = performance.getEntriesByType('resource').slice(-80);
var p = new Set(), s = new Set();
e.forEach(function(x){
var m = x.name.match(/pgl[A-Za-z0-9]{12,20}/); if (m) p.add(m[0]);
var n = x.name.match(/soc[A-Za-z0-9]{12,20}/); if (n) s.add(n[0]);
});
return { pgl: Array.from(p), soc: Array.from(s) };
})())Full pattern (Bash + AppleScript + JS file) documented in the "Internal API — Workspace + Application Management" section → "Move from headless: AppleScript → Chrome → fetch() inside live SPA tab".
Generated values DO NOT work — the server checks the websocket registry, so random socXXX... strings always fail. The IDs must come from a live SPA session.
See "Internal API — Workspace + Application Management" → "Write path requires live secretSocketId" for an applied recipe (workspace move via AppleScript→Chrome→fetch).
curl -s -X POST "https://airtable.com/v0.3/application/$BASE_ID/readQueries" \
"${HEADERS[@]}" \
-H "content-type: application/x-www-form-urlencoded; charset=UTF-8" \
--data-urlencode "stringifiedObjectParams={
\"queries\": [{
\"id\": \"qryCustom1\",
\"spec\": {
\"source\": {\"type\": \"table\", \"tableId\": \"tblXXXXXXXXXXXXXXX\"},
\"columnIds\": [\"fldSTATUS\", \"fldDATE\"],
\"sorts\": [{\"columnId\": \"fldDATE\", \"ascending\": true}],
\"filters\": {
\"conjunction\": \"and\",
\"filterSet\": [{
\"columnId\": \"fldSTATUS\",
\"operator\": \"isAnyOf\",
\"value\": [\"selOPTION1\", \"selOPTION2\"]
}]
}
}
}],
\"subscribeToRealtimeUpdates\": false,
\"allowMsgpackOfResult\": false
}"Tip: Set allowMsgpackOfResult: false to get JSON instead of msgpack for debugging.
{
"nodeType": "element",
"id": "pel...",
"type": "cellEditor",
"source": {
"type": "column",
"columnId": "fld..."
},
"isReadOnly": false,
"label": {"isEnabled": true, "value": "Custom Label"},
"shouldShowDescription": true,
"description": [{"type": "paragraph", "children": [{"text": "Help text"}]}],
"visibilityFilters": {"conjunction": "and", "filterSet": [...]},
"visualVariant": {"type": "grid", "size": "medium"},
"foreignRowSelectionConstraint": {
"filters": {"conjunction": "and", "filterSet": [{"columnId": "fld...", "operator": "|", "value": null}]}
},
"foreignRowEmbeddedFormButton": {
"isEnabled": true,
"buttonLabel": "Add new",
"formFieldVisibility": "allNonComputed",
"formFields": {}
}
}{
"nodeType": "element",
"id": "pel...",
"type": "section",
"visualVariant": "grid",
"title": "Section Title",
"shouldDisplayTitle": true,
"shouldDisplayDescription": false,
"labelLayoutVariant": "stacked",
"style": null,
"visibilityFilters": {"conjunction": "and", "filterSet": [...]}
}{
"nodeType": "element",
"id": "pel...",
"type": "queryContainer",
"source": {
"type": "foreignKey",
"tableId": "tbl...",
"foreignColumnId": "fld...",
"foreignRow": {"type": "row", "tableId": "tbl...", "outputId": "peo..."}
},
"staticFilters": null,
"presetFilters": null,
"savedFilterSets": null,
"activeFilterType": "custom",
"allRowsLabel": null,
"endUserControls": {
"isFilterEnabled": false,
"isSortEnabled": false,
"isSearchEnabled": false,
"isGroupLevelsEnabled": false,
"isHideEmptyParentsEnabled": false
},
"outputs": {"query": {"type": "query", "id": "peo..."}},
"viewCanvasAreas": [{"canvasAreaId": "pla..."}],
"isPdfExportEnabled": true,
"label": {"isEnabled": true, "value": "Actions"}
}{
"nodeType": "element",
"id": "pel...",
"type": "levels",
"leafTableId": "tbl...",
"sourceLevel": 1,
"rowHeight": "medium",
"isReadOnly": true,
"editability": {},
"label": {"isEnabled": true, "value": "Actions"},
"queryByLevel": {},
"levelsConfig": {},
"expandedRowByTableId": {}
}{
"nodeType": "element",
"id": "pel...",
"type": "button",
"action": {"type": "openUrl", "url": {"type": "column", "columnId": "fld..."}},
"colorTheme": "red",
"buttonText": null,
"visibilityFilters": {"conjunction": "and", "filterSet": [...]}
}{
"nodeType": "element",
"id": "pel...",
"type": "attachmentCarousel",
"source": {"type": "column", "columnId": "fld..."},
"isReadOnly": false,
"previewImageCoverFitType": "fit",
"numAttachmentsPerCarouselPage": 1,
"visibilityFilters": {"conjunction": "or", "filterSet": [...]}
}{
"id": "pls...",
"nodeType": "slotElement",
"parentId": "pel...",
"slotType": "sectionGridRows",
"elementId": "pel...",
"index": "a0"
}slotType: sectionGridRows (rows in section) or sectionGridRowChildren (elements in row).
index: alphabetical sort key (a0, a3, a4).
rootCanvasAreaId
→ canvasAreaById[id].canvasId
→ fullCanvasElementById[canvasId].elementId
→ elementById[elementId] (root: recordContainer or levels)
→ slotElementsById filtered by parentId → child elements
→ recurse
curl "https://api.airtable.com/v0/$BASE_ID/Orders?\
fields[]=Subject&fields[]=Status&fields[]=Start%20Date&\
filterByFormula=AND(%7BStatus%7D%3D'Scheduled'%2CIS_AFTER(%7BStart%20Date%7D%2CTODAY()))&\
sort[0][field]=Start%20Date&sort[0][direction]=asc&\
pageSize=100" \
-H "Authorization: Bearer $PAT"curl -X POST "https://api.airtable.com/v0/$BASE_ID/Orders" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"records": [{
"fields": {
"Title": "Sample Order",
"Status": "Open",
"Start Date": "2026-04-01T10:00:00.000Z",
"End Date": "2026-04-01T11:00:00.000Z",
"Customer": ["recXXX"],
"Owner": ["recYYY"],
"Channel": "Web",
"Priority": 4,
"Notes": "Sample notes",
"Price": 500,
"Rating": 2,
"External": true
}
}],
"typecast": true
}'curl -X PATCH "https://api.airtable.com/v0/$BASE_ID/Contacts" \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"performUpsert": {"fieldsToMergeOn": ["Email"]},
"records": [{"fields": {"Email": "client@example.com", "Name": "Updated Name"}}],
"typecast": true
}'{
"cursor": 43,
"mightHaveMore": true,
"payloads": [{
"timestamp": "2026-03-28T10:30:00.000Z",
"baseTransactionNumber": 42,
"actionMetadata": {
"source": "client",
"sourceMetadata": {"user": {"id": "usrXXX", "email": "x@y.com"}}
},
"changedTablesById": {
"tblXXX": {
"changedRecordsById": {
"recABC": {
"current": {"cellValuesByFieldId": {"fldXXX": "new"}},
"previous": {"cellValuesByFieldId": {"fldXXX": "old"}}
}
},
"createdRecordsById": {},
"destroyedRecordIds": []
}
}
}]
}Sources: client, publicApi, formSubmission, automation, system, sync, anonymousUser.
let response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer TOKEN'},
body: JSON.stringify({key: 'value'})
});
let data = await response.json();const table = base.getTable('Records');
const query = await table.selectRecordsAsync({fields: ['ID']});
const maxId = Math.max(0, ...query.records
.map(r => parseInt(r.getCellValueAsString('ID').replace('ACME-', '')) || 0));
output.set('newId', `ACME-${String(maxId + 1).padStart(5, '0')}`);const table = base.getTable('Contacts');
const query = await table.selectRecordsAsync({fields: ['Email', 'Name']});
const seen = new Map();
const duplicates = [];
for (const record of query.records) {
const email = record.getCellValueAsString('Email').toLowerCase();
if (seen.has(email)) duplicates.push({id: record.id, email});
else seen.set(email, record.id);
}
output.text(`Found ${duplicates.length} duplicates`);
output.table(duplicates);const source = base.getTable('Source');
const target = base.getTable('Target');
const srcQ = await source.selectRecordsAsync({fields: ['Key', 'Value']});
const tgtQ = await target.selectRecordsAsync({fields: ['Key', 'Value']});
const tgtMap = new Map(tgtQ.records.map(r => [r.getCellValueAsString('Key'), r]));
const updates = [], creates = [];
for (const sr of srcQ.records) {
const key = sr.getCellValueAsString('Key');
const value = sr.getCellValueAsString('Value');
const existing = tgtMap.get(key);
if (existing) {
if (existing.getCellValueAsString('Value') !== value)
updates.push({id: existing.id, fields: {'Value': value}});
} else {
creates.push({fields: {'Key': key, 'Value': value}});
}
}
while (updates.length) await target.updateRecordsAsync(updates.splice(0, 50));
while (creates.length) await target.createRecordsAsync(creates.splice(0, 50));async function airtableRequest(url, options, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(url, options);
if (response.status === 429) {
await new Promise(r => setTimeout(r, Math.max(30000, 2 ** attempt * 1000)));
continue;
}
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
return response.json();
}
throw new Error('Max retries exceeded');
}| Token | Output | Example |
|---|---|---|
YYYY |
4-digit year | 2026 |
YY |
2-digit year | 26 |
Q |
Quarter | 1-4 |
M / MM |
Month | 3 / 03 |
MMM / MMMM |
Month name | Mar / March |
D / DD |
Day | 5 / 05 |
Do |
Day ordinal | 5th |
d / dd / ddd / dddd |
Weekday | 5 / Fr / Fri / Friday |
H / HH |
Hour 24h | 9 / 09 |
h / hh |
Hour 12h | 9 / 09 |
m / mm |
Minute | 5 / 05 |
s / ss |
Second | 5 / 05 |
SSS |
Milliseconds | 123 |
A / a |
AM/PM | AM / am |
X |
Unix seconds | 1774692000 |
x |
Unix ms | 1774692000000 |
W |
ISO week | 13 |
The public Meta API refuses to create calculated fields (UNSUPPORTED_FIELD_TYPE_FOR_CREATE: "Creating rollup fields is not supported at this time" — same for formula, lookup, count, aiText). The internal /v0.3/column/{fieldId}/create endpoint fully supports them — this is the endpoint the Airtable web UI itself uses.
Two supported paths exist for creating calculated fields. Pick per task:
| Situation | Path |
|---|---|
| One-off field, human at the keyboard | Omni AI — natural language, in-browser, zero setup |
| Bulk / repeated / scripted / CI-driven | Internal Web API (this section) |
| Need reproducibility across environments | Internal Web API |
| Don't want to maintain an undocumented-endpoint integration | Omni AI |
Risk of this path: the /v0.3/column/ endpoints are not part of Airtable's public contract. Airtable can change the payload shape, add CSRF validation, or block non-browser clients without notice. If the browser UI still works but your script 4xx's, assume the payload shape drifted — re-capture a HAR and diff.
Reverse-engineered from a browser HAR capture (April 2026). No _csrf token is required for these endpoints — only session cookies.
| Method | URL | Purpose |
|---|---|---|
| POST | /v0.3/column/{newFieldId}/create |
Create a new field (text, formula, rollup, lookup, count, select, foreignKey, button, …). The {newFieldId} is generated client-side — the server uses the ID you put in the URL as the new field's ID. |
| POST | /v0.3/column/{fieldId}/updateConfig |
Update an existing field's type/typeOptions (e.g. add filters to a rollup, change formula text). Returns {"data": {"actionId": "act..."}}. |
| POST | /v0.3/table/{tableId}/getUnsavedColumnConfigResultType |
Validate a formula before saving. Returns {"pass": true, "resultType": "text"}. Optional but recommended for formulas — catches syntax errors before create. |
Client-generated field IDs. The Airtable web client generates a random fld... ID (17 chars, starts with fld, then 14 chars of A-Za-z0-9) and puts it in the URL. The server trusts it. If you send two creates with the same ID, the second will conflict. Use a short random generator — e.g. Python "fld" + "".join(random.choices(string.ascii_letters + string.digits, k=14)).
Cookie-based session. Same cookies as the existing "Internal Web API" section of SKILL.md — capture from DevTools → Network → any /v0.3/ request → copy the full Cookie header. The important cookie is __Host-airtable-session; Airtable also sets brwIds, __Host-airtable-session.sig, and several analytics cookies, so copy the whole header as one blob.
Never hardcode. Store the full cookie string in 1Password under the appropriate client/base vault — op://{vault}/airtable-web-session/cookie — and retrieve with op read at runtime. One secret, one consumer: each base gets its own item; do not share cookies across clients.
export AIRTABLE_COOKIE="$(op read 'op://{vault}/airtable-web-session/cookie')"Session lifetime is whatever the browser session is — typically weeks, but any logout or password change invalidates it. If a request returns 401 or redirects to /login, re-capture.
x-airtable-application-id: {baseId}
x-airtable-inter-service-client: webClient
x-airtable-page-load-id: pglo{14 chars} # any stable random value per session is fine
x-requested-with: XMLHttpRequest
x-time-zone: {IANA tz, e.g. UTC}
x-user-locale: en
origin: https://airtable.com
referer: https://airtable.com/{baseId}/{tableId}/{viewId}
content-type: application/x-www-form-urlencoded; charset=UTF-8
cookie: {full cookie blob}
origin and referer are checked loosely — missing them gets 403. x-airtable-page-load-id is checked for presence/format, not against any registry.
Every create / updateConfig body is application/x-www-form-urlencoded with one field of interest:
stringifiedObjectParams={url-encoded JSON}&requestId=req{14 chars}&secretSocketId=soc{14 chars}
requestId— anyreq+ 14 random chars; appears in idempotency / log correlation but the server does not require strict uniquenesssecretSocketId— anysoc+ 14 random chars; only used for realtime push fan-out, safe to invent
{
"tableId": "tbl...",
"name": "Label",
"config": { "default": null, "type": "text", "typeOptions": null },
"description": null,
"activeViewId": "viw...",
"afterOverallColumnIndex": 4,
"origin": "gridAddFieldButton"
}afterOverallColumnIndex controls column position (0-indexed, places new column after that index). activeViewId can be any view on the target table; origin is telemetry — any string works but "gridAddFieldButton" is safest.
{
"tableId": "tbl...",
"name": "Calculation",
"config": {
"default": null,
"type": "formula",
"typeOptions": {
"formulaText": "DATETIME_DIFF(TODAY(), {Created}, 'days')"
}
},
"description": null,
"activeViewId": "viw...",
"afterOverallColumnIndex": 7,
"origin": "gridAddFieldButton"
}Formula text uses field NAMES (wrapped in {} if they contain spaces), not field IDs. Same syntax as the UI formula editor. Validate first via getUnsavedColumnConfigResultType:
// POST /v0.3/table/{tableId}/getUnsavedColumnConfigResultType
{
"config": {
"default": null,
"type": "formula",
"typeOptions": { "formulaText": "TODAY()&Status" }
}
}
// → {"msg":"SUCCESS","data":{"pass":true,"resultType":"text"}}resultType returned: text, number, date, boolean, or the parent field type if the formula resolves to a select. If pass: false, the create will return 422.
Formula output type coercion (select). To make a formula output a single-select with colored chips, add to typeOptions:
{
"formulaText": "...",
"formulaSelectFallbackChoice": "selFORMULADEFAULT",
"choices": {
"selFORMULADEFAULT": { "id": "selFORMULADEFAULT", "name": "Default", "color": "gray" },
"sel{14chars}": { "id": "sel{14chars}", "name": "OptionA", "color": "blue" }
},
"choiceOrder": ["selFORMULADEFAULT", "sel{14chars}"],
"disableColors": false,
"formulaOutputColumnType": "select"
}Minimum required fields: relationColumnId, foreignTableRollupColumnId, formulaText.
{
"tableId": "tbl...",
"name": "Total Paid",
"config": {
"default": null,
"type": "rollup",
"typeOptions": {
"relationColumnId": "fld{LINK FIELD on this table}",
"foreignTableRollupColumnId": "fld{FIELD on linked table to aggregate}",
"formulaText": "SUM(values)"
}
},
"description": null,
"activeViewId": "viw...",
"afterOverallColumnIndex": 10,
"origin": "gridAddFieldButton"
}Aggregation formulas — formulaText is a formula expression over the implicit array variable values:
SUM(values),AVERAGE(values),MIN(values),MAX(values),COUNT(values),COUNTA(values),COUNTALL(values)ARRAYUNIQUE(values),ARRAYCOMPACT(values),ARRAYJOIN(values, ', ')AND(values),OR(values)- Any regular formula — e.g.
IF(SUM(values) > 100, "hi", "lo")
Filter on rollup source rows — filters is optional in both create and updateConfig. Observed working in the HAR via updateConfig after create (the UI flow always created first with empty filter, then updated with filters), but the structure is identical to create bodies for other field types, so it should work in create too. Safe path: create without filters, then updateConfig with filters.
"typeOptions": {
"relationColumnId": "fld...",
"foreignTableRollupColumnId": "fld...",
"formulaText": "SUM(values)",
"filters": {
"conjunction": "and",
"filterSet": [
{
"id": "flt{14chars}",
"columnId": "fld{column on the LINKED table to filter by}",
"operator": "=",
"value": false
}
]
},
"sorts": [
{ "id": "srt{14chars}", "columnId": "fld...", "ascending": true }
]
}Checkbox filter operators — observed in HAR: contains for text. For checkbox fields, the standard Airtable filter operators are = with value: true/value: false, and isEmpty / isNotEmpty. Checkbox unchecked stores as null in cell data but filter comparison with =false works in the UI. If =false fails, fall back to isEmpty (for unchecked) or isNotEmpty (for checked).
Each filter needs a client-generated id — flt + 14 random chars. Same for sorts: srt + 14 chars.
Count is just a filter-aware counter over linked records. Minimum: relationColumnId.
{
"tableId": "tbl...",
"name": "Active Registrations",
"config": {
"default": null,
"type": "count",
"typeOptions": {
"relationColumnId": "fld{LINK FIELD}",
"filters": {
"conjunction": "and",
"filterSet": [
{ "id": "flt{14chars}", "columnId": "fld{col on linked table}", "operator": "=", "value": false }
]
}
}
},
"description": null,
"activeViewId": "viw...",
"afterOverallColumnIndex": 11,
"origin": "gridAddFieldButton"
}{
"tableId": "tbl...",
"name": "Linked Names",
"config": {
"default": null,
"type": "lookup",
"typeOptions": {
"relationColumnId": "fld{LINK FIELD}",
"foreignTableRollupColumnId": "fld{FIELD on linked table to read}",
"rowLimit": { "limit": 1, "firstOrLast": "last" },
"filters": {
"conjunction": "and",
"filterSet": [
{ "id": "flt{14chars}", "columnId": "fld...", "operator": "contains", "value": "das" }
]
},
"sorts": [
{ "id": "srt{14chars}", "columnId": "fld...", "ascending": true }
]
}
},
"description": null,
"activeViewId": "viw...",
"afterOverallColumnIndex": 11,
"origin": "gridAddFieldButton"
}rowLimit is optional (omit for all rows). firstOrLast: "last" takes the latest by sort order.
Not captured in the HAR used for this reverse-engineering. The shape matches other calculated fields — type: "aiText", typeOptions with a prompt template. Capture fresh from a session where you create an AI field, then update this section.
On success:
{ "msg": "SUCCESS", "data": null }(The field is now live. Re-read the table schema via MCP describe_table or Meta API to get the full created config.)
On failure — returns 422 with:
{ "error": { "type": "VALIDATION_FAILED", "message": "Sorry, there was a problem creating this field. The options are not valid." } }Observed cause: rollup created with empty relationColumnId, or formula that fails parse.
Warnings (e.g. schema dependency impact) return 422 with type: "COLUMN_CONFIG_UPDATE_WARNING" and a base64-JSON body containing hasDependencyCheckWarning: true. To bypass, re-send with schemaDependenciesCheckParams.columnUpdateSkipWarningPrefs.skipColumnConfigExternalSyncWarning: true and skipColumnConfigChangeWarning: true.
import os, json, secrets, string, urllib.parse, requests
def _rid(prefix, n=14):
alphabet = string.ascii_letters + string.digits
return prefix + "".join(secrets.choice(alphabet) for _ in range(n))
BASE_ID = os.environ["AIRTABLE_BASE_ID"]
TABLE_ID = os.environ["AIRTABLE_TABLE_ID"]
VIEW_ID = os.environ["AIRTABLE_VIEW_ID"] # any view on that table
COOKIE = os.environ["AIRTABLE_COOKIE"] # full Cookie header from op read
HEADERS = {
"x-airtable-application-id": BASE_ID,
"x-airtable-inter-service-client": "webClient",
"x-airtable-page-load-id": _rid("pglo"),
"x-requested-with": "XMLHttpRequest",
"x-time-zone": "UTC",
"x-user-locale": "en",
"origin": "https://airtable.com",
"referer": f"https://airtable.com/{BASE_ID}/{TABLE_ID}/{VIEW_ID}",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"cookie": COOKIE,
}
def create_column(config, name, after_index=0, description=None):
new_field_id = _rid("fld")
params = {
"tableId": TABLE_ID,
"name": name,
"config": config,
"description": description,
"activeViewId": VIEW_ID,
"afterOverallColumnIndex": after_index,
"origin": "gridAddFieldButton",
}
body = urllib.parse.urlencode({
"stringifiedObjectParams": json.dumps(params),
"requestId": _rid("req"),
"secretSocketId": _rid("soc"),
})
r = requests.post(
f"https://airtable.com/v0.3/column/{new_field_id}/create",
headers=HEADERS, data=body, timeout=30,
)
r.raise_for_status()
data = r.json()
if data.get("msg") != "SUCCESS":
raise RuntimeError(f"create failed: {data}")
return new_field_id
def update_column_config(field_id, typeOptions_config):
"""typeOptions_config is the FULL config object (default, type, typeOptions)."""
params = {
**typeOptions_config,
"activeViewId": VIEW_ID,
"schemaDependenciesCheckParams": {
"columnUpdateSkipWarningPrefs": {
"skipColumnConfigExternalSyncWarning": False,
"skipColumnConfigChangeWarning": False,
}
},
}
body = urllib.parse.urlencode({
"stringifiedObjectParams": json.dumps(params),
"requestId": _rid("req"),
"secretSocketId": _rid("soc"),
})
r = requests.post(
f"https://airtable.com/v0.3/column/{field_id}/updateConfig",
headers=HEADERS, data=body, timeout=30,
)
r.raise_for_status()
return r.json()Usage — a rollup SUM(values) of Payments.amount:
fid = create_column(
config={
"default": None,
"type": "rollup",
"typeOptions": {
"relationColumnId": "fldLINK",
"foreignTableRollupColumnId": "fldAMOUNT",
"formulaText": "SUM(values)",
},
},
name="Total Paid",
after_index=20,
)
print("created", fid)- Public Meta API still refuses these field types. Always use the internal endpoint for create; use the public API for reads/updates of records after creation.
- Field ID collisions. Client generates the
fld...ID — roll a cryptographically random 14-char suffix. Don't reuse IDs. relationColumnIdmust be a link field on the SAME table as the rollup/count/lookup.foreignTableRollupColumnIdmust be a field on the linked table (the target of the link).- Rollup
formulaTextsyntax differs from regular formulas — usesvaluesas the implicit array.SUM({Amount})will NOT work; useSUM(values)whereforeignTableRollupColumnIdalready points atAmount. - Filter
idrequired. Every filter entry and sort entry must have a uniqueflt.../srt...id — the server rejects filters without one. - Warning bypass. If
updateConfigreturns 422 withCOLUMN_CONFIG_UPDATE_WARNING, re-send with bothskipColumnConfigExternalSyncWarning: trueandskipColumnConfigChangeWarning: true. - Referer must be a valid base/table/view URL on airtable.com. A blank referer returns 403.
- Cookies expire silently. A 302 redirect to
/loginHTML instead of JSON means re-capture the cookie. - No rate limit documented, but UI throttles to ~1 field-create per 500 ms. Don't loop faster than 2 QPS or you'll hit the generic 429.
- Filters with
msg:"SUCCESS", data:null— success returnsdata: nullon create. Don't confuse with failure. updateConfigreturns anactionId— you can poll it via the base realtime socket, or just re-read schema after ~500 ms.- Formula preview endpoint (
getUnsavedColumnConfigResultType) only validates formulas — it ignoresrelationColumnIdvalidity for rollups. Do your own existence check before POSTing.
The public REST API does not expose any PAT-management endpoints — listing, creating, regenerating, renaming, and revoking PATs are all UI-only as far as Airtable's public docs go. The Builder Hub uses internal /v0.3/user/{userId}/... endpoints for all five operations. Reverse-engineered from a HAR capture (May 2026, Builder Hub session).
When to use this:
- Programmatic rotation of PATs across many automations / consumers
- Audits ("which PATs exist, what scopes, accessible to which bases")
- CI-driven PAT lifecycle (mint → use → revoke at end of job)
When NOT to use this:
- One-off mint/revoke — Builder Hub UI is faster
- Anywhere the cookie-session liveness is fragile (CI on a shared runner)
Same cookie-based auth as the rest of the internal API. The PAT-management endpoints DO require _csrf (unlike /v0.3/column/...). Capture both the cookie blob AND the CSRF token from any logged-in /v0.3/... request — the CSRF token is in the x-csrf-token request header and rotates per session.
# In secret-capture or a 1P item, store both:
op://{vault}/airtable-web-session/cookie # full Cookie header
op://{vault}/airtable-web-session/csrf-token # x-csrf-token header valueAll bodies use the stringifiedObjectParams envelope — the params are JSON-encoded as a string field inside the outer JSON. Don't send the params as a nested object; the server expects a string.
| Method | URL | Purpose |
|---|---|---|
| GET | /v0.3/user/{userId}/getPersonalAccessTokensForDevelopersHub |
List the calling user's PATs (id, name, scopes, accessible workspaces/applications, last-used). Does NOT return the token value — values are write-only at mint time. |
| POST | /v0.3/user/{userId}/createPersonalAccessToken |
Mint a new PAT. Response includes the plaintext token value once — capture and route to consumer immediately. |
| POST | /v0.3/user/{userId}/regeneratePersonalAccessToken |
In-place value swap — same tokenId, new value. Old value invalidated server-side at the moment the new value is returned. No revoke step needed; this IS rotation. |
| POST | /v0.3/user/{userId}/updatePersonalAccessTokenName |
Rename a PAT. |
| POST | /v0.3/user/{userId}/destroyMultiplePersonalAccessTokens |
Revoke one or more PATs by id. |
// POST createPersonalAccessToken
{
"stringifiedObjectParams": "{\"name\":\"my-rotation-pat\",\"explicitTokenResources\":{\"type\":\"specificModelIds\",\"workspaceIds\":[],\"applicationIds\":[\"appXXXXXXXXXXXXXX\"]},\"scopes\":[\"data.records:read\",\"data.records:write\",\"webhook:manage\"]}",
"requestId": "req<random16>",
"_csrf": "<csrf-token>"
}
// POST regeneratePersonalAccessToken
{
"stringifiedObjectParams": "{\"tokenId\":\"patXXXXXXXXXXXXXX\"}",
"requestId": "req<random16>",
"_csrf": "<csrf-token>"
}
// POST updatePersonalAccessTokenName
{
"stringifiedObjectParams": "{\"tokenId\":\"patXXXXXXXXXXXXXX\",\"name\":\"new-name\"}",
"requestId": "req<random16>",
"_csrf": "<csrf-token>"
}
// POST destroyMultiplePersonalAccessTokens
{
"stringifiedObjectParams": "{\"tokenIds\":[\"patXXXXXXXXXXXXXX\",\"patYYYYYYYYYYYYYY\"]}",
"requestId": "req<random16>",
"_csrf": "<csrf-token>"
}{ "msg": "SUCCESS", "data": { ... } }createPersonalAccessTokenandregeneratePersonalAccessTokenreturn the plaintext PAT value indata.personalAccessToken— single chance to capture it.getPersonalAccessTokensForDevelopersHubreturnsdata.tokens(array of token metadata; values masked).destroyMultiplePersonalAccessTokensreturnsdata: nullon success.updatePersonalAccessTokenNamereturnsdata.name.
The scopes array in createPersonalAccessToken accepts the same scope strings exposed in Builder Hub UI. Common ones:
data.records:readdata.records:writedata.recordComments:readdata.recordComments:writeschema.bases:readschema.bases:writewebhook:manageblock:manageenterprise.account:read(enterprise plans only)
explicitTokenResources controls which workspaces / bases the PAT can access:
type |
Behavior |
|---|---|
allCurrentAndFuture |
Every workspace + base the user has access to, including future ones. |
specificModelIds |
Limited to the listed workspaceIds[] and/or applicationIds[]. Empty arrays means none — be careful, you can mint a PAT with no resources by accident. |
- The token value appears once. Both
createandregeneratereturn the plaintext value in the response and never again. Capture into the consumer immediately —secret-captureskill is the safe path so the value never enters the agent transcript. regenerateis the safest rotation primitive. Same token id, scopes, and resource list — only the value changes. Consumers that store the value (only) need updating; consumers that store the token id can keep going. Use this overcreate+destroywhenever the goal is a value-only swap.- Old value dies the moment the new one is returned. There is no overlap window. Stage the consumer update so the old value's last successful call happens BEFORE you call regenerate, and the next call uses the new value.
destroyMultipleis bulk + idempotent. Sending an already-revoked id returns success. Useful for a "revoke all PATs matching a name prefix" sweep.- CSRF token rotates per session. Cache it for the duration of a script's run; re-capture on
403 Invalid CSRF. - Listing returns metadata only — never the value. A "show me what PATs exist" audit is safe; a "what is the value of this PAT" lookup is impossible (correctly).
- PAT id is
pat+ 14 chars[A-Za-z0-9]— same prefix as the value but a different identifier. The id is stable across regenerations; the value changes. - The user's own user id (
usrXXX...) is in the URL path. Find it in the response of any/v0.3/user/{userId}/...call you've already captured, or viagetMe-style endpoints that some Builder Hub screens hit.
import json, secrets, string, urllib.request
def _airtable_internal_post(path: str, params: dict, *, cookie: str, csrf: str, base_id: str | None = None) -> dict:
"""POST to /v0.3/... with the stringifiedObjectParams envelope."""
body = json.dumps({
"stringifiedObjectParams": json.dumps(params, separators=(",", ":")),
"requestId": "req" + "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(16)),
"_csrf": csrf,
}).encode()
req = urllib.request.Request(
f"https://airtable.com{path}",
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Cookie": cookie,
"x-csrf-token": csrf,
"x-airtable-inter-service-client": "webClient",
"x-requested-with": "XMLHttpRequest",
"x-airtable-application-id": base_id or "appAAAAAAAAAAAAAA",
},
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
def regenerate_pat(user_id: str, token_id: str, *, cookie: str, csrf: str) -> str:
"""Rotate a PAT in place. Returns the new plaintext token value (capture once)."""
res = _airtable_internal_post(
f"/v0.3/user/{user_id}/regeneratePersonalAccessToken",
{"tokenId": token_id},
cookie=cookie, csrf=csrf,
)
if res.get("msg") != "SUCCESS":
raise RuntimeError(f"regenerate failed: {res}")
return res["data"]["personalAccessToken"]
def list_pats(user_id: str, *, cookie: str) -> list[dict]:
req = urllib.request.Request(
f"https://airtable.com/v0.3/user/{user_id}/getPersonalAccessTokensForDevelopersHub",
headers={
"Cookie": cookie,
"x-airtable-inter-service-client": "webClient",
"x-requested-with": "XMLHttpRequest",
},
)
with urllib.request.urlopen(req) as r:
return json.loads(r.read())["data"]["tokens"]
def destroy_pats(user_id: str, token_ids: list[str], *, cookie: str, csrf: str) -> None:
res = _airtable_internal_post(
f"/v0.3/user/{user_id}/destroyMultiplePersonalAccessTokens",
{"tokenIds": token_ids},
cookie=cookie, csrf=csrf,
)
if res.get("msg") != "SUCCESS":
raise RuntimeError(f"destroy failed: {res}")# Goal: swap the PAT value without ever exposing it to logs/transcripts.
# The new value is captured directly into the consumer (env file, 1P, n8n).
new_value = regenerate_pat(USER_ID, TOKEN_ID, cookie=COOKIE, csrf=CSRF)
# `new_value` is in process memory only. Do NOT print it.
write_to_consumer(new_value) # e.g. ssh + sed -i, or n8n credential update
del new_valueFor agentic flows where the value should never enter the agent's process memory, use the secret-capture skill's ssh adapter — the user pastes the regenerated value into a hidden dialog, and the adapter routes it directly to the target without round-tripping through the agent.
Discovered while trying to relieve one workspace's automation-run quota by moving production bases to a sibling workspace. The read path works headlessly; the write path needs a live SPA session (cookies alone are insufficient).
- Read: you need workspace → bases mapping (which bases live in which workspace). Public Meta API doesn't return this. Use the
listApplicationsAndPageBundlesForDisplayendpoint. - Write: drive the operator's logged-in Chrome via AppleScript + injected
fetch(). Headless HTTP replay fails — see "Write path requires livesecretSocketId" below.
| Endpoint | Method | Purpose |
|---|---|---|
/v0.3/user/{userId}/listApplicationsAndPageBundlesForDisplay?stringifiedObjectParams={"shouldIncludePageBundleSharingApplications":true,"shouldIncludePageBundleIndex":true}&requestId=req<rand> |
GET | Enumerate all workspaces + bases visible to user. |
/v0.3/user/{userId}/getMostRecentlyOpenedWorkspaces?stringifiedObjectParams={}&requestId=req<rand> |
GET | Recently-opened workspaces (less useful — listApplicationsAndPageBundlesForDisplay returns everything). |
/v0.3/user/{userId}/getFavorites?stringifiedObjectParams={}&requestId=req<rand> |
GET | Pinned/starred bases. |
/v0.3/workspace/{srcWorkspaceId}/moveApplication |
POST | Needs live secretSocketId + x-airtable-page-load-id — see "Write path" below. |
{
"msg": "SUCCESS",
"data": {
"workspaceRecordById": {
"wspXXXXXXXXXXXXXX": {
"id": "wspXXXXXXXXXXXXXX",
"name": "Workspace A",
"visibleApplicationOrder": ["appXXXXXXXXXXXXXX", "appYYYYYYYYYYYYYY", ...],
"createdTime": "2025-04-05T04:05:46.000Z",
"sharedWithCurrentUser": {"sharedBy": "usr...", "directPermissionLevel": "owner", ...}
}
},
"applicationRecordById": {
"appXXXXXXXXXXXXXX": {
"id": "appXXXXXXXXXXXXXX",
"name": "Example Base",
"color": "blue",
"icon": "users",
"createdTime": "...",
"currentUserEffectivePermissionLevel": "owner",
"isOverPlanLimits": false,
...
}
},
"pageBundles": [...],
"isPossiblyMissingPageBundles": false
}
}workspaceRecordById[wid].visibleApplicationOrder is the canonical workspace → bases mapping. Auth: cookies-only.
Chrome stores three separate __Host-airtable-session rows in the cookies DB — one each for host_key='airtable.com', 'app.airtable.com', 'www.airtable.com'. The __Host- prefix means each is host-only, so the browser only sends the matching one per request, but browser_cookie3.chrome(domain_name='airtable.com') returns all three concatenated, which the server then rejects.
Use this dedupe pattern (prefer host_key='airtable.com'):
import browser_cookie3, os, sys
prof = sys.argv[1] if len(sys.argv) > 1 else 'Default'
cf = os.path.expanduser(f'~/Library/Application Support/Google/Chrome/{prof}/Cookies')
cj = browser_cookie3.chrome(cookie_file=cf, domain_name='airtable.com')
prio = {'airtable.com': 0, '.airtable.com': 1, 'app.airtable.com': 2, 'www.airtable.com': 3}
by_name = {}
for c in cj:
p = prio.get(c.domain, 99)
prev = by_name.get(c.name)
if prev is None or p < prio.get(prev.domain, 99):
by_name[c.name] = c
print('; '.join(f'{c.name}={c.value}' for c in by_name.values()))Consume inline via $(...) — never assign to a long-lived shell variable per secret-capture discipline.
When the operator runs multiple Chrome profiles (each holding a different Airtable login), there may be no Default profile. Map profile dir → name + Google account by reading ~/Library/Application Support/Google/Chrome/Local State:
jq -r '.profile.info_cache | to_entries | .[] | "\(.key)\t\(.value.name)\t\(.value.user_name // "")"' \
~/Library/Application\ Support/Google/Chrome/Local\ StateThe Chrome profile's Google account does NOT guarantee the Airtable account inside it. To verify which profile holds the Airtable session for a given workspace, hit listApplicationsAndPageBundlesForDisplay and check whether the target workspace is in workspaceRecordById.
The session payload itself (__Host-airtable-session, base64 JSON) carries csrfSecret, userId, loggedInTime, and highSecurityModeEnabledTime. That's stable across a session.
Separately, state-changing endpoints check a userSignature + userSignature.sig cookie pair that the SPA generates client-side per-action:
userSignature=<userId><ISO-timestamp-rounded-to-minute>
userSignature.sig=<HMAC base64url>
Format example: userSignature=usrXXXXXXXXXXXXXX2026-01-01T12:27:00.000Z. The HMAC is derived from the session's csrfSecret. TTL appears to be ~5 minutes — the cookie is renewed whenever the user clicks anything in the SPA.
This cookie IS readable from Chrome's cookie DB (it's a normal first-party cookie on .airtable.com), so browser_cookie3 picks it up automatically — but only if the user has clicked something in Airtable within the last ~5 minutes. Stale cookie DBs won't have it.
POST /v0.3/workspace/{wsid}/moveApplication (and other workspace-structure writes) returns 401 INVALID_AUTH_TOKEN from headless HTTP replay — even with all the right cookies + headers — because the body needs a secretSocketId value that the server validates against the live websocket connection owned by the user's active SPA session. Fabricated soc<rand> values are rejected. The header x-airtable-page-load-id must similarly match the SPA's current page load.
GET endpoints (listApplicationsAndPageBundlesForDisplay, getFavorites, workflowDeployment/.../read) don't require either — they're auth-cookie-only.
PerimeterX cookies (_pxvid / _pxhd / pxcts) are present but not in play for this path. The block is socket-binding, not fingerprint. The AppleScript-driven fetch approach below confirms this — once the request rides the live SPA session, the same cookies/headers that failed via curl succeed.
The reliable path: drive the operator's actual Chrome (the profile logged into the target Airtable account) and call fetch() from inside an open airtable.com tab. The browser supplies cookies + live socket binding automatically.
Prerequisite (one-time): Chrome menu → View → Developer → Allow JavaScript from Apple Events → tick.
Get the live IDs from the SPA without DevTools:
JSON.stringify((function(){
var e = performance.getEntriesByType('resource').slice(-80);
var p = new Set(), s = new Set();
e.forEach(function(x){
var m = x.name.match(/pgl[A-Za-z0-9]{12,20}/); if (m) p.add(m[0]);
var n = x.name.match(/soc[A-Za-z0-9]{12,20}/); if (n) s.add(n[0]);
});
return { pgl: Array.from(p), soc: Array.from(s) };
})())Returns the active pageLoadId and secretSocketId. Both rotate per SPA session — re-extract whenever the operator reloads the page.
Move payload (form-encoded body):
stringifiedObjectParams={"applicationId":"app...","targetWorkspaceId":"wsp...","targetIndex":1}
&requestId=req<rand>
&secretSocketId=<live-soc-from-performance-entries>
Required headers:
accept: application/json, text/javascript, */*; q=0.01
content-type: application/x-www-form-urlencoded; charset=UTF-8
x-airtable-inter-service-client: webClient
x-airtable-page-load-id: <live-pgl-from-performance-entries>
x-requested-with: XMLHttpRequest
x-time-zone: <user's-tz>
x-user-locale: en
Bash + AppleScript pattern that works:
# Write the fetch as a JS file (avoids AppleScript's quoting hell)
cat > /tmp/move.js <<'JS'
(function(){
window.__r = null;
var body = new URLSearchParams({
stringifiedObjectParams: JSON.stringify({applicationId:"<APP>",targetWorkspaceId:"<DST>",targetIndex:1}),
requestId: "req" + Math.random().toString(36).slice(2,18),
secretSocketId: "<LIVE_SOC>"
});
fetch("https://airtable.com/v0.3/workspace/<SRC>/moveApplication", {
method:"POST", credentials:"include",
headers:{
"accept":"application/json, text/javascript, */*; q=0.01",
"content-type":"application/x-www-form-urlencoded; charset=UTF-8",
"x-airtable-inter-service-client":"webClient",
"x-airtable-page-load-id":"<LIVE_PGL>",
"x-requested-with":"XMLHttpRequest",
"x-time-zone":"<operator-timezone>",
"x-user-locale":"en"
},
body: body.toString()
}).then(function(r){return r.text().then(function(t){window.__r={status:r.status,body:t};});});
return "fired";
})()
JS
# Load JS via shell-out from AppleScript (avoids => and other JS tokens breaking AS parser)
osascript -e 'set jsCode to (do shell script "cat /tmp/move.js")
tell application "Google Chrome"
return execute (tab 2 of window 1) javascript jsCode
end tell'
# Poll for completion (async fetch + stash on window.__r)
sleep 2
osascript -e 'tell application "Google Chrome"
return execute (tab 2 of window 1) javascript "JSON.stringify(window.__r)"
end tell'Why pass JS via do shell script "cat …": AppleScript's parser chokes on => and other JS tokens even when JSON-encoded. Loading the JS body as a string from a file works around it cleanly.
Why poll instead of await: AppleScript's execute javascript returns the sync expression value; it cannot await a Promise. Fire-and-stash via window.__r, then poll.
| Operation | Method |
|---|---|
listApplicationsAndPageBundlesForDisplay (GET) |
cookies-only via curl ✅ |
getFavorites, getMostRecentlyOpenedWorkspaces (GET) |
cookies-only ✅ |
workflowDeployment/{id}/read (GET) |
cookies-only ✅ |
listWorkflows, listExecutions (GET) |
cookies-only ✅ |
application/{baseId}/readQueries (POST data fetch) |
cookies-only ✅ |
workflowDeployment/{NEW_id}/create (enable automation) |
needs live secretSocketId |
workflow/{wflId}/unregister (disable automation) |
needs live secretSocketId |
column/{fieldId}/create (create calculated field) |
needs live secretSocketId |
workspace/{wsid}/moveApplication (move base) |
needs live secretSocketId + pageLoadId — use AppleScript→Chrome→fetch pattern above |
The boundary is: any endpoint that mutates user-visible state via the realtime sync layer requires the socket binding. GET reads and PAT-management endpoints don't.
- Operator has Chrome open on Airtable in the profile logged into the target account; ensure "Allow JavaScript from Apple Events" is on.
- Agent extracts live
pageLoadId+secretSocketIdviaperformance.getEntriesByType('resource')injection. - Agent reads inventory via
listApplicationsAndPageBundlesForDisplayand presents per-base move plan. - On approval: agent fires
moveApplicationPOSTs sequentially with ~800ms gap, using fire-and-stash + poll for results. - Re-fetch the listing and verify the source workspace ended with the expected residual (typically zero) and the destination has the expected new count.
Worked example: 11 bases moved between sibling workspaces in ~30 seconds wall-clock once IDs were extracted.