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'sinput:block inintegration.yamlctx— 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:
| Field | Type | Description |
|---|---|---|
type | string | 'api_key' or 'oauth2' |
description | `string | null` |
metadata | `object | null` |
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:
| Option | Type | Description |
|---|---|---|
headers | Record<string, string> | Request headers |
timeout | number | Timeout in milliseconds |
body | string | Raw request body (for POST/PUT/PATCH) |
json | object | JSON 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
| Method | Returns |
|---|---|
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 → numberctx.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}`);
}| Option | Type | Description |
|---|---|---|
host | string | Target host IP or hostname |
user | string | SSH username |
key | string | PEM private key string, typically from ctx.credentials.NAME |
command | string | Shell command to execute on the remote host |
timeout | number | Timeout 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 usefetch,axios, ornode-fetchdirectly.Always set
timeout=. The step runtime enforces a 30-second timeout, and setting an explicittimeoutensures clear error messages instead of silent kills.
integration.yaml Schema
Complete reference for the integration.yaml manifest format used in HeadlessOps automations. Covers trigger types, step routing, npm package dependencies, webhook authentication, HTML responses, and synchronous response modes.
JavaScript Runtime Behavior
Handler signatures, the process.env polyfill, built-in modules, scoped /tmp, and response auto-conversion for TypeScript/JavaScript steps.