HeadlessOps
Scripting Guide

TypeScript ctx API

Complete reference for the ctx object available in every step function

TypeScript ctx API Reference

Every step function is an async TypeScript function:

export async function run(
  input: Record<string, any>,
  ctx: StepContext
): Promise<Record<string, any>> {
  // ...
}
  • input — object of resolved values from the step's input: block in integration.yaml
  • ctx — the platform context object documented below

The return value becomes the step's output, accessible to downstream steps via ${{ steps.STEP_ID.output.field }}.


ctx.log(message, data?)

Emit a structured log entry visible in the run history UI.

ctx.log("step_name: start", { inputKey: input.some_field });
ctx.log("step_name: done", { resultCount: results.length });
ctx.log("skipping duplicate", { reason: "already_processed", id: recordId });

Best practice: Always call ctx.log at the start and end of every step. Use a consistent "step_name: event" format.


ctx.credentials.NAME

Access a named credential from the workspace vault. Returns the decrypted string value (the API key or OAuth2 access token).

// API key — returns the raw secret string
const apiKey = ctx.credentials.STRIPE_KEY;

// OAuth2 — access token is automatically managed and refreshed
const accessToken = ctx.credentials.SLACK_AUTH;

// Use with ctx.http
const response = await ctx.http.get("https://api.stripe.com/v1/charges", {
  headers: { Authorization: `Bearer ${ctx.credentials.STRIPE_KEY}` },
  timeout: 25000,
});

Credentials are resolved at execution time — never hardcode secrets in step files or YAML.


ctx.credentials.meta.NAME

Access the metadata attached to a credential. Returns an object with three fields:

FieldTypeDescription
typestring'api_key' or 'oauth2'
description`stringnull`
metadata`objectnull`
const meta = ctx.credentials.meta.STRIPE_KEY;
console.log(meta.type);         // 'api_key'
console.log(meta.description);  // 'Stripe production key'
console.log(meta.metadata);     // { account_id: 'acct_123', mode: 'live' }

// Safe access when metadata may be null
const accountId = meta.metadata?.account_id;

Use ctx.credentials.meta when your step needs to branch on per-credential configuration (e.g. different base URLs per tenant) without hardcoding values.


ctx.config.KEY

Access a workspace config / environment variable.

const baseUrl = ctx.config.API_BASE_URL;
const timeoutSeconds = parseInt(ctx.config.STEP_TIMEOUT || "25");

ctx.output(data)

Set the integration-level response body — the value returned to the HTTP caller when response_mode: synchronous is set, and stored as run.output in the runs API.

This is not the same as the step return value.

How It Works
return { ... }Sets the step output, accessible to downstream steps via ${{ steps.STEP_ID.output.field }}
ctx.output(data)Sets the integration-level response body returned to the HTTP caller (synchronous webhooks)

For the final step in a synchronous webhook integration, call both:

export async function run(input: Record<string, any>, ctx: StepContext) {
  const result = { status: "ok", processed_id: input.record_id };
  ctx.output(result);   // returned to the HTTP caller
  return result;        // available to downstream steps and run logs
}

To return HTML from a synchronous webhook, include a __html key in the value passed to ctx.output():

export async function run(input: Record<string, any>, ctx: StepContext) {
  const html = `<html><body><h1>Hello</h1><p>Record ${input.record_id} processed.</p></body></html>`;
  ctx.output({ __html: html });  // Response Content-Type: text/html
  return { status: "ok" };
}

ctx.http — Built-in HTTP Client

ctx.http is a built-in fetch-compatible HTTP client. It is auto-instrumented — all calls appear in run logs with timing and status. An AbortController timeout is applied automatically to prevent hung requests from leaking worker slots.

ctx.http.get / post / put / patch / delete

const response = await ctx.http.get("https://api.example.com/endpoint", {
  headers: { Authorization: `Bearer ${ctx.credentials.API_TOKEN}` },
  timeout: 25000,  // milliseconds — default step timeout is 30s
});

const data = await response.json();

All standard HTTP methods are supported. The second argument is an options object:

OptionTypeDescription
headersRecord<string, string>Request headers
timeoutnumberTimeout in milliseconds
bodystringRaw request body (for POST/PUT/PATCH)
jsonobjectJSON body — automatically sets Content-Type: application/json

HttpResponse Methods

The response object supports:

const json = await response.json();        // Parse as JSON
const text = await response.text();        // Read as string
const buffer = await response.arrayBuffer(); // Read as binary ArrayBuffer

response.status;     // HTTP status code
response.headers;    // Response headers (ReadonlyMap)
response.ok;         // true if status 200–299
response.statusText; // e.g. "OK", "Not Found"

Binary Response Handling (arrayBuffer)

When an API returns binary data (images, PDFs, Office documents), use arrayBuffer():

export async function run(input: Record<string, any>, ctx: StepContext) {
  const response = await ctx.http.get("https://api.example.com/report.pdf", {
    headers: { Authorization: `Bearer ${ctx.credentials.API_TOKEN}` },
    timeout: 25000,
  });

  const buffer = await response.arrayBuffer();
  ctx.log("downloaded report", { size: buffer.byteLength });

  // Forward to another endpoint
  const uploadResp = await ctx.http.post("https://storage.example.com/upload", {
    headers: { "Content-Type": "application/octet-stream" },
    body: Buffer.from(buffer).toString("base64"),
    timeout: 25000,
  });

  return { uploaded: uploadResp.ok, size: buffer.byteLength };
}

ctx.stores — Data Store Access

Access named Data Stores via attribute syntax: ctx.stores.STORE_NAME.

Underscore → Hyphen Auto-Conversion

Underscores in attribute names are automatically converted to hyphens for slug lookup:

ctx.stores.processed_invoices   // looks up slug: "processed-invoices"
ctx.stores.my_data_store        // looks up slug: "my-data-store"

Always name stores with hyphens in the platform (e.g. processed-invoices) and access them with underscores in TypeScript (e.g. ctx.stores.processed_invoices).

Method Summary

MethodReturns
insert(data)Full row object: {id, key, value, ..., created_at, updated_at}
find(query)Array of all matching rows
find_one(query)Single row object, or null if no match
update(query, data){updated: number, records: [{...}]}
delete(query){deleted: true}

Number Column Type Coercion

NUMBER columns are returned as strings even if you inserted an integer. Convert manually:

const record = await ctx.stores.my_store.find_one({ key: "counter" });
const count = parseInt(record.count);   // string → number

ctx.stores.SLUG.insert(data)

Write a new record to a named Data Store. Returns the full inserted row object.

const row = await ctx.stores.processed_invoices.insert({
  invoice_id: "inv_123",
  processed_at: "2026-01-15T10:00:00Z",
  amount: 4999,
});
ctx.log("inserted row", { id: row.id });

ctx.stores.SLUG.find(query)

Returns an array of all rows matching the query object. Always returns an array (empty if no matches).

const results = await ctx.stores.processed_invoices.find({ status: "pending" });
for (const row of results) {
  ctx.log("found row", { id: row.id, status: row.status });
}

ctx.stores.SLUG.find_one(query)

Returns the first matching row as an object, or null if no match.

const record = await ctx.stores.processed_invoices.find_one({ invoice_id: invoiceId });
if (record) {
  return { status: "skipped", reason: "already_processed" };
}

ctx.stores.SLUG.update(query, data)

Update existing records matching a query. Returns {updated: number, records: [{...}]}.

const result = await ctx.stores.processed_invoices.update(
  { invoice_id: "inv_123" },
  { status: "sent", sent_at: "2026-01-15T11:00:00Z" }
);
ctx.log("updated rows", { count: result.updated });

ctx.stores.SLUG.delete(query)

Delete records matching a query. Returns {deleted: true}.

await ctx.stores.processed_invoices.delete({ invoice_id: "inv_123" });

Idempotency Pattern

Use data stores to make steps safe to retry:

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", order_id: orderId };
  }

  // 2. Perform the action
  // ... call external API ...

  // 3. Record success
  await ctx.stores.processed_orders.insert({
    order_id: orderId,
    processed_at: "2026-01-15T10:00:00Z",
  });

  ctx.log("processed order", { order_id: orderId });
  return { status: "success", order_id: orderId };
}

Full Step Example

export async function run(input: Record<string, any>, ctx: StepContext) {
  ctx.log("fetch_contact: start", { email: input.email });

  // Access credentials
  const apiKey = ctx.credentials.HUBSPOT_API_KEY;

  // Access credential metadata (e.g. account-specific base URL)
  const meta = ctx.credentials.meta.HUBSPOT_API_KEY;
  const baseUrl = meta.metadata?.base_url || "https://api.hubapi.com";

  // Make an HTTP call via ctx.http
  const response = await ctx.http.get(
    `${baseUrl}/contacts/v1/contact/email/${input.email}/profile`,
    {
      headers: { Authorization: `Bearer ${apiKey}` },
      timeout: 25000,
    }
  );
  if (!response.ok) {
    throw new Error(`HubSpot API returned ${response.status}: ${response.statusText}`);
  }
  const data = await response.json();
  const contactId = data.vid;

  // Check for duplicate in data store
  const existing = await ctx.stores.synced_contacts.find_one({ contact_id: String(contactId) });
  if (existing) {
    ctx.log("fetch_contact: already synced", { contact_id: contactId });
    return { contact_id: contactId, status: "already_synced" };
  }

  // Record the contact
  await ctx.stores.synced_contacts.insert({
    contact_id: String(contactId),
    email: input.email,
    synced_at: "2026-01-15T10:00:00Z",
  });

  ctx.log("fetch_contact: done", { contact_id: contactId });
  return {
    contact_id: contactId,
    name: data?.properties?.firstname?.value || "",
    status: "synced",
  };
}

ctx.ssh.exec(options) — Remote Command Execution

Execute a shell command on a remote host over SSH directly from a step, without an external exec proxy. Store the private key as a normal api_key credential and pass it via ctx.credentials.NAME.

const result = await ctx.ssh.exec({
  host: "167.233.39.61",
  user: "deploy",
  key: ctx.credentials.DEV_VPS_SSH_KEY,  // api_key credential containing the PEM private key
  command: "/opt/ffmpeg-converter/download_and_convert.sh 'URL' 'ID'",
  timeout: 270_000,
});

ctx.log("ssh exec done", { exitCode: result.exitCode });
if (result.exitCode !== 0) {
  throw new Error(`SSH command failed: ${result.stderr}`);
}
OptionTypeDescription
hoststringTarget host IP or hostname
userstringSSH username
keystringPEM private key string, typically from ctx.credentials.NAME
commandstringShell command to execute on the remote host
timeoutnumberTimeout in milliseconds (defaults to the step timeout)

Returns { stdout: string, stderr: string, exitCode: number }. Connections are reused within a single run. To retrieve a binary file, run something like base64 -w0 /path/to/file on the remote host and decode result.stdout in the step (see __base64 webhook output below to return it directly to a caller).


HTTP Calls — ctx.http vs httpx

ctx.http is the built-in fetch-compatible HTTP client. It is auto-instrumented and applies an AbortController timeout to prevent hung requests from blocking worker slots.

const response = await ctx.http.post("https://api.example.com/endpoint", {
  headers: { Authorization: `Bearer ${ctx.credentials.API_TOKEN}` },
  json: { key: "value" },
  timeout: 25000,     // Always set explicit timeout — step limit is 30s
});
if (!response.ok) {
  throw new Error(`API returned ${response.status}`);
}
return response.json();

Always use ctx.http. Do not use fetch, axios, or node-fetch directly.

Always set timeout=. The step runtime enforces a 30-second timeout, and setting an explicit timeout ensures clear error messages instead of silent kills.