HeadlessOps
Scripting Guide

Best Practices

Idempotency, error handling, pagination, and documentation conventions

Scripting Best Practices

Idempotency

All automation steps must be safe to retry. If a step fails halfway through and the platform retries it, it must not create duplicate records or corrupt state.

export async function run(input: Record<string, any>, ctx: StepContext) {
  const orderId = input.order_id;

  // 1. Check if already processed
  const existing = await ctx.stores.processed_orders.find_one({ order_id: orderId });
  if (existing) {
    ctx.log("already processed, skipping", { order_id: orderId });
    return { status: "skipped" };
  }

  // 2. Perform the action
  // ...

  // 3. Record success
  await ctx.stores.processed_orders.insert({
    order_id: orderId,
    processed_at: "2026-06-04T00:00:00Z",
  });
  return { status: "success" };
}

Error Handling

Never swallow exceptions. Let them propagate so the platform can track failures and trigger retries.

// ✅ Correct — check status and throw
if (!response.ok) {
  throw new Error(`API returned ${response.status}: ${response.statusText}`);
}

// ❌ Wrong — silent swallow
try {
  // ...
} catch (e) {
  // empty catch — never do this
}

Always define an on_failure step in integration.yaml for critical steps:

  - id: process
    name: Process Record
    description: Main processing logic
    file: process.ts
    on_failure: handle_error

  - id: handle_error
    name: Handle Error
    description: Logs failure and sends alert
    file: handle_error.ts
    input:
      error_message: "${{ error.message }}"
      failed_step: "${{ error.step }}"

Timeouts

Always set explicit timeouts. The default step timeout is 30 seconds.

const response = await ctx.http.get("https://api.example.com/data", {
  timeout: 25000,  // leave headroom below the 30s step limit
});

Pagination

Never assume a single API call returns all records. Always paginate:

// ✅ Correct — paginate until exhausted
let allRecords: any[] = [];
let page = 1;
while (true) {
  const response = await ctx.http.get(`https://api.example.com/records?page=${page}&limit=100`, {
    headers: { Authorization: `Bearer ${ctx.credentials.API_KEY}` },
    timeout: 25000,
  });
  const data = await response.json();
  const batch = data.records || [];
  if (batch.length === 0) break;
  allRecords = allRecords.concat(batch);
  page++;
}

// ❌ Wrong — hard-coded limit may silently truncate
const response = await ctx.http.get("https://api.example.com/records?limit=250", { timeout: 25000 });

Never use a round-number limit (100, 250, 500) as a final fetch without verifying the total count.

Incremental Development

Never write and run a complex script in one shot without probing the target system first:

  1. Fetch ONE record and log its raw structure
  2. Write transformation logic for that one record, run again
  3. Expand to the full dataset only after step 2 succeeds
export async function run(input: Record<string, any>, ctx: StepContext) {
  // Probe step — log raw structure first
  const response = await ctx.http.get("https://api.example.com/records?limit=1", {
    headers: { Authorization: `Bearer ${ctx.credentials.API_KEY}` },
    timeout: 25000,
  });
  const records = await response.json();
  ctx.log("sample record", { record: records[0] });
  return { sample: records[0] };
}

knowledge_base.md Conventions

Include a knowledge_base.md in your integration package to document context for future developers and agents:

# knowledge_base.md — my-integration

## Purpose
What this integration does and why.

## API Schema
Document field names, types, and constraints discovered from the target API.

## Credentials Used
- `MY_API_KEY` — Bearer token for api.example.com

## Known Limitations
- The API returns at most 500 records per page
- Rate limit: 100 requests/minute

fix_log.md Conventions

When fixing an error, create or update fix_log.md in the integration package:

# Fix Log — my-integration

## 2026-06-04 — Schema mismatch on contact_name field
- **Issue**: Step failed with TypeError: undefined is not an object (run_abc123)
- **Root Cause**: The CRM API changed the field from `contact_name` to `full_name`
- **Resolution**: Updated `process.ts` to use `data.full_name || data.contact_name`
- **Status**: Resolved & Verified