---
name: airtable-scripting
description: "Write, debug, and optimize scripts for Airtable automations and the Scripting Extension. Trigger on: Airtable scripting, automation scripts, Scripting Extension, fetch() or remoteFetchAsync() in Airtable, input.config(), output.set(), output.text(), output.markdown(), output.table(), input.textAsync(), script errors, sync/ingestion scripts, retry logic, pagination, record read/write, timeout or rate limit debugging. Covers base.getTable(), selectRecordsAsync(), updateRecordAsync(), createRecordsAsync(), input.secret(). Covers both Automation scripts and the interactive Scripting Extension - not the REST API from external systems."
---

# Airtable Automation Scripting

This skill covers writing scripts that run inside Airtable's two scripting environments. **Identify which environment the user is working in before writing any code.**

## CRITICAL: Two Different Environments

Airtable has **two** scripting environments with **different APIs**:

| | Scripting Extension | Automation Script |
|---|---|---|
| HTTP requests | `remoteFetchAsync()` | `fetch()` |
| User output | `output.text()`, `output.markdown()`, `output.table()`, `output.inspect()` | `output.set(key, value)` |
| User input | `input.textAsync()`, `input.buttonsAsync()`, etc. | `input.config()`, `input.secret()` |
| Runs when | User clicks Run | Trigger fires |
| Timeout | Less strict | 180s hard kill |

**Using the wrong API for the environment will throw an error.** `remoteFetchAsync is not defined` in automations. `output.text is not a function` if the method doesn't exist. Always confirm which environment before writing code.

For Scripting Extension details, read `scripting-extension.md`.
For Automation Script details, continue below.

## Reference Files

Read these before writing scripts. Each covers a specific domain:

| File | When to Read |
|---|---|
| `scripting-extension.md` | **Any script in the Scripting Extension (not automation).** Covers output methods, interactive inputs, remoteFetchAsync, field types and cell values, table/field metadata, Script Settings, and the discovery script pattern. Read this first if the user is working in the extension. |
| `runtime-constraints.md` | **Always.** Read before writing any script. Covers every hard limit in the runtime. |
| `patterns.md` | Building production scripts: pagination, UPSERT sync, chained automations, System Panel, push/write-back, Databricks SQL integration. |
| `retry-and-timeout.md` | Any script that calls external APIs. Covers backoff strategies, equal jitter, time-budget-aware retries, timeout graceful degradation, and concurrent automation stampeding. |
| `observability.md` | Any script beyond a quick prototype. Covers structured console logging, error-to-field writing, run summary blocks, partial result handling, and debugging failed runs. |
| `marketo-api.md` | Any script calling Marketo REST API. Covers auth quirks, error codes, timestamp format issues, global cursor pagination, token operations, and bulk export. |

---

## Runtime Constraints (Critical)

These are non-negotiable. Every script must account for them.

| Constraint | Limit | Notes |
|---|---|---|
| Script execution timeout | **180 seconds** | Hard ceiling. No extensions. Use a 170s time budget with 10s safety margin for cleanup writes. |
| `fetch()` call limit | **50 calls per script** | Hard limit across the entire script execution. Includes retries. Use a `trackedFetch()` wrapper to count and enforce. |
| `setTimeout` / `setInterval` | **Not available** | The runtime does not support these. Use a busy-wait loop: `while (Date.now() < end) {}` |
| `input.config()` | **Call once only** | Calling it a second time throws: `input.config may only be called once`. Destructure into a variable at the top. |
| `input.secret()` | **Separate from config** | Secrets (API keys, tokens) use `input.secret("SecretName")`, not `input.config()`. |
| richText fields via `input.config()` | **Do not serialize reliably** | Always read richText fields directly: `record.getCellValueAsString("Field Name")` after `selectRecordAsync()`. Never pass richText through input variables. |
| `output.set()` value size | **~100KB** | Large payloads silently truncate or fail. Filter/compress data before setting output. |
| Batch write size | **50 records** | `createRecordsAsync()` and `updateRecordsAsync()` accept max 50 records per call. |
| Console output | **No structured logging** | `console.log()` is the only output mechanism. Build structured log lines manually. |
| Template literals | **Use with caution** | Airtable's older runtime can be inconsistent with backticks after multi-edit patches. String concatenation (`"text " + var`) is always safe. |

### The Busy-Wait Pattern

This is the only way to introduce delays in Airtable scripts:

```javascript
function busyWait(ms) {
  var end = Date.now() + ms;
  while (Date.now() < end) { /* Airtable has no setTimeout */ }
}
```

`await` on a synchronous function is harmless, so existing `await busyWait(500)` calls work fine.

### The Single-Config Pattern

```javascript
// CORRECT: one call, destructure from the result
var config = input.config();
var recordId = config.RecordId;
var baseUrl = config.Base_URL;

// Secrets are separate and can be called individually
var apiKey = input.secret("MyApiKey");

// WRONG: multiple calls throw an error
var a = input.config().RecordId;   // works
var b = input.config().Base_URL;   // throws: "input.config may only be called once"
```

---

## Script Structure Template

Every automation script should follow this skeleton:

```javascript
/**
 * ============================================================
 * AirtOPSy -- [Script Name]
 * ============================================================
 * Pipeline:  [Automation name] (script N of M)
 * Purpose:   [One sentence: what this script does]
 * API:       [Endpoints called, if any]
 * Tables:    Reads: [tables]. Writes: [tables].
 * Note:      [Any known constraints or warnings]
 * ============================================================
 */

/***** Inputs *****/
var config = input.config();   // can only be called once per script
var RECORD_ID = config.RecordId;
var BASE_URL = config.Base_URL;
var TOKEN = input.secret("MyApiSecret");

/***** Config *****/
var TABLE_NAME = "Target Table";
var TIME_LIMIT_MS = 170000;       // 180s ceiling minus 10s safety
var BATCH_SIZE = 50;              // Airtable write batch max
var RUN_ID = "sync-" + Date.now();
var RUN_START = Date.now();

/***** Helpers *****/
function elapsed() { return Date.now() - RUN_START; }
function timeRemaining() { return TIME_LIMIT_MS - elapsed(); }
function isoNow() { return new Date().toISOString(); }

function busyWait(ms) {
  var end = Date.now() + ms;
  while (Date.now() < end) {}
}

/***** Guardrails *****/
console.log("========================================");
console.log("Script Name - Startup");
console.log("========================================");
console.log("Run ID: " + RUN_ID);
console.log("Timestamp: " + isoNow());

if (!BASE_URL) throw new Error("Missing input: Base_URL");
if (!RECORD_ID) throw new Error("Missing input: RecordId");

/***** Main Logic *****/
try {
  // ... script body ...
} catch (err) {
  console.log("FATAL: " + err.message);
  // Write error state to Airtable so it's visible, not silent
}

/***** Cleanup / Outputs *****/
console.log("========================================");
console.log("Complete - " + elapsed() + "ms");
console.log("========================================");
```

---

## Reading and Writing Records

### Reading records

```javascript
var table = base.getTable("Table Name");

// Load specific fields (always specify fields to avoid loading unnecessary data)
var query = await table.selectRecordsAsync({
  fields: ["Field A", "Field B", "Status"]
});

// Iterate
for (var record of query.records) {
  var fieldA = record.getCellValue("Field A");           // typed value (number, array, etc.)
  var fieldB = record.getCellValueAsString("Field B");   // string representation
}

// Load a single record by ID (use for richText or when you have the ID)
var singleRecord = await table.selectRecordAsync(recordId);
var richTextContent = singleRecord.getCellValueAsString("Rich Text Field");
```

### Writing records

```javascript
// Update one record
await table.updateRecordAsync(recordId, {
  "Status": { name: "Done" },          // single select
  "Count": 42,                          // number
  "Notes": "Some text",                 // long text / single line
  "Linked Records": [{ id: "recXYZ" }], // link field
  "Checkbox Field": true                // checkbox
});

// Batch create (max 50 per call)
var creates = records.map(function(r) {
  return { fields: { "Name": r.name, "Value": r.value } };
});
for (var i = 0; i < creates.length; i += BATCH_SIZE) {
  var batch = creates.slice(i, i + BATCH_SIZE);
  await table.createRecordsAsync(batch);
}

// Batch update (max 50 per call)
var updates = records.map(function(r) {
  return { id: r.id, fields: { "Status": { name: "Processed" } } };
});
for (var i = 0; i < updates.length; i += BATCH_SIZE) {
  var batch = updates.slice(i, i + BATCH_SIZE);
  await table.updateRecordsAsync(batch);
}
```

### Always wrap writes in try/catch

```javascript
try {
  await table.updateRecordAsync(recordId, payload);
  console.log("Write succeeded");
} catch (err) {
  console.log("Write failed: " + err.message);
  console.log("Payload: " + JSON.stringify(payload).slice(0, 500));
  // Do NOT re-throw if partial results are still valuable
}
```

---

## API Calls and Retry Logic

Read `patterns.md` for the full retry helper with equal jitter backoff and Marketo-specific error code handling. The core pattern:

### Safe JSON Fetch

```javascript
async function fetchJson(url, options) {
  var res = await fetch(url, options || {});
  var raw = await res.text();
  console.log("HTTP " + res.status + " - " + raw.length + " chars");

  var json;
  try {
    json = JSON.parse(raw);
  } catch (e) {
    throw new Error("Non-JSON response (HTTP " + res.status + "): " + raw.slice(0, 300));
  }
  return json;
}
```

Always use `res.text()` then `JSON.parse()` in a try/catch. Never use `res.json()` directly because non-JSON error pages produce opaque parse failures.

### Retry with Equal Jitter Backoff

```javascript
async function fetchWithRetry(url, headers, retries) {
  retries = retries || 3;
  var BASE_DELAY = 2000;
  for (var i = 0; i < retries; i++) {
    try {
      var res = await fetch(url, { headers: headers });
      var raw = await res.text();
      var json = JSON.parse(raw);

      if (json.success === false) {
        var errCode = (json.errors && json.errors[0] && json.errors[0].code) || "";
        var errMsg = (json.errors && json.errors[0] && json.errors[0].message) || "Unknown";

        // Auth failures: no point retrying with same token
        if (errCode === "601" || errCode === "602") {
          throw new Error("Auth failure (" + errCode + "): " + errMsg);
        }
        // Rate limits: wait and retry
        if (errCode === "606" || errCode === "607" || errCode === "615") {
          var half = BASE_DELAY * Math.pow(2, i) / 2;
          var jitter = half + Math.random() * half;  // equal jitter
          console.log("Rate limited (" + errCode + "), waiting " + Math.round(jitter) + "ms");
          busyWait(jitter);
          continue;
        }
        throw new Error("API error (" + errCode + "): " + errMsg);
      }
      return json;
    } catch (err) {
      if (i === retries - 1) throw err;
      var half = BASE_DELAY * Math.pow(2, i) / 2;
      var jitter = half + Math.random() * half;
      console.log("Retry " + (i + 1) + "/" + retries + " after " + Math.round(jitter) + "ms: " + err.message);
      busyWait(jitter);
    }
  }
}
```

Equal jitter prevents retry stampeding when multiple automations run concurrently against shared API rate limits. Each retry waits at least half the backoff interval, randomizing the rest.

### trackedFetch Wrapper (50-call limit)

For scripts that make many API calls, track the count:

```javascript
var fetchCount = 0;
var FETCH_LIMIT = 50;

async function trackedFetch(url, options) {
  fetchCount++;
  if (fetchCount > FETCH_LIMIT) {
    throw new Error("fetch() limit reached (" + FETCH_LIMIT + " calls)");
  }
  console.log("[fetch " + fetchCount + "/" + FETCH_LIMIT + "] " + url.slice(0, 100));
  return fetch(url, options);
}
```

---

## Time Budget Management

Scripts must check remaining time before starting expensive operations:

```javascript
var TIME_LIMIT_MS = 170000;
var RUN_START = Date.now();

function timeRemaining() {
  return TIME_LIMIT_MS - (Date.now() - RUN_START);
}

// Before each page of API results
if (timeRemaining() < 10000) {
  console.log("Time budget low (" + timeRemaining() + "ms) - stopping gracefully");
  break;
}

// Before each batch write (estimate cost dynamically)
var estimatedWriteMs = batch.length * 50;  // ~50ms per record write
if (timeRemaining() < estimatedWriteMs + 5000) {
  console.log("Not enough time for next batch - stopping");
  break;
}
```

---

## Common Patterns

Read `patterns.md` for full implementations of:

1. **UPSERT (sync) pattern** - Dedup key lookup, field-by-field diff, create or update. Safe to re-run without duplication.
2. **Scout-then-collect** - Lightweight discovery script counts/identifies targets; follow-up script does deep pagination and writes.
3. **Chained automations** - Split long-running work across multiple chained script actions with `StartOffset` inputs and exit-reason conditionals.
4. **System Panel integration** - Scripts set status to "In Progress" at start, resolve to "Done"/"Error" with resync flag management.
5. **Bearer token self-refresh** - Acquire token at start, detect auth failures mid-run, re-acquire without restarting.
6. **Marketo timestamp normalization** - Marketo returns non-standard timestamps that break `new Date()`. Always clean with regex.
7. **Write-back (push) pattern** - Stage changes in a richText field, validate against live state, push via API, refresh live view.

Read `retry-and-timeout.md` for:

8. **Retry decision framework** - When to retry vs. bail, based on error type and remaining budget.
9. **Backoff strategy comparison** - Full jitter vs. equal jitter vs. decorrelated, and why equal jitter is the default.
10. **Time-budget-aware retries** - Skip retries when there is not enough time. Dynamic cost estimation before each operation.
11. **Graceful timeout pattern** - How to stop cleanly, write partial results, and hand off to chained scripts.
12. **Concurrent stampeding prevention** - Pre-call governance delays, jitter, and cross-automation awareness.

Read `observability.md` for:

13. **Commenting best practices** - Script header blocks, section dividers, inline "why not what" comments, automation context documentation, and comment density guidelines. Designed for clean context switching across rotating headcounts.
14. **Error-to-field writing** - Always write final state to Airtable fields, not just console.
15. **Console logging conventions** - Run envelope, emoji prefix taxonomy, elapsed time on every line.
16. **Run summary blocks** - Structured end-of-script output for automation history scanning.
17. **Partial result handling** - Incremental write checkpoints, partial completion flags, downstream data completeness signaling.
18. **Debugging failed runs** - Step-by-step investigation process for automation history.

---

## Design Principles

These principles come from building and maintaining dozens of production automation scripts:

**Technology transferability.** No hardcoded values. All governance levers (URLs, table names, thresholds) should be tunable without refactoring. Use `input.config()` or constants at the top of the script.

**Field-level observability.** Errors should always write something to Airtable fields rather than failing silently. A partially filled record with an error note is more valuable than an empty record with a console log nobody will see.

**Partial results over total failure.** If a script fetches 8 of 10 pages before timing out, write the 8 pages. Never discard partial work because the final step failed. Wrap cleanup writes in try/catch so a write failure does not lose the data.

**Idempotent by default.** Every sync or push script should be safe to re-run. UPSERT keyed on source system IDs, not Airtable record IDs. Running a script twice should produce the same result as running it once.

**Alert channel discipline.** When scripts post to Slack or set status fields, keep the signal clean. Dedicated alert channels with thread discipline (all discussion in-thread) keep the feed scannable.

---

## Debugging Checklist

When an automation script fails:

1. **Check the console log first.** Look for the last successful operation before the error.
2. **"setTimeout is not defined"** - Replace with `busyWait()`. This is the most common runtime error.
3. **"input.config may only be called once"** - Consolidate to a single call at the top.
4. **Non-JSON response errors** - The API returned HTML (error page, 502, etc.). Add raw text logging before JSON.parse.
5. **Silent timeout (no error, no output)** - Script exceeded 180s. Add time budget checks.
6. **"Request method not supported"** - Check the Marketo endpoint. Some asset endpoints only support POST despite documentation suggesting GET.
7. **Empty results with `moreResult: true`** - Marketo's global cursor pattern. Do not exit on the first empty page. Activities for a specific lead can appear on later pages.
8. **Rate limit errors (606/607/615)** - Add equal jitter backoff. Check if other automations run concurrently against the same API instance.
9. **Stale bearer token** - Tokens expire mid-run in long pipelines. Add token self-refresh logic.
10. **Field write failures** - Field name mismatch, wrong value type, or missing linked record. Always log the payload on write failure.

### Extension-Specific Errors

11. **"remoteFetchAsync is not defined"** - You are in an Automation script, not the Scripting Extension. Use `fetch()` instead.
12. **"output.text is not a function"** - You are in an Automation script. Use `output.set(key, value)` or `console.log()`.
13. **"output.set is not a function"** - You are in the Scripting Extension. Use `output.text()`, `output.markdown()`, or `output.table()`.
14. **403 / blocked responses from remoteFetchAsync** - The target site (e.g., Craigslist) blocks Airtable's server IPs. remoteFetchAsync proxies through Airtable servers, which may be on blocked IP ranges. Consider using n8n or a local script instead.
15. **Single select write error "invalid cell value"** - Must use `{ name: "Option" }` object, not a bare string `"Option"`.
