Durable workflows (code recipes)
TypeScript-defined workflow logic with conditionals, branching, and resumable state.
A durable recipe is a TypeScript function that orchestrates an Oxygen workflow with programming-language control flow. Use a recipe when the motion needs conditionals, branching, or repeated steps that a template cannot express cleanly.
Setup
The recipe SDK is not published to npm — npm install @oxygen/recipe-sdk will 404. It ships bundled inside the Oxygen CLI, and oxygen workflows apply compiles your recipe for you. Only the type RecipeContext import is type-erased; the defineRecipe value is bundled into your recipe by apply (esbuild inlines the SDK straight from the CLI), so you never install or ship the SDK yourself.
Scaffold a ready-to-edit project with the CLI:
npm install -g @oxygen-agent/cli # if you don't have the `oxygen` command yet
oxygen workflows init # starter recipe + tsconfig + vendored SDK typesinit vendors the SDK's type declarations into .oxygen/types/ and wires a tsconfig.json path mapping, so your editor resolves @oxygen/recipe-sdk (and the @oxygen/workflows types it re-exports) with full autocomplete — no npm install required.
Imports and dependencies
apply bundles your recipe with esbuild, so you can split logic across relative TypeScript files (import { score } from "./scoring") — they are inlined into one bundle (capped at 2 MB).
npm packages and Node built-ins are not usable. The bundled output must pass a safety check that rejects require, dynamic import(), Node modules (fs, http, child_process, …), process, fetch, timers, Date.now(), and Math.random() — and the sandbox provides no module system or network at runtime anyway. Virtually any real npm package trips at least one of these, so keep recipe logic self-contained and do all external work through ctx.tools.run (see Determinism rules).
Secrets
There is no process.env in the recipe sandbox — deliberately. Recipes never hold credentials:
- Store the credential on the integration connection — OAuth or API key via
oxygen integrations connect, oroxygen custom-integrations connect <slug> --secrets-file ./.envfor Custom HTTP integrations. - Allowlist that provider's tool in
toolsand call it withctx.tools.run(...).
Oxygen decrypts and injects the credential server-side when the tool executes; the secret never appears in recipe code, bundles, or run logs.
Shape
A recipe is the default export of defineRecipe(...):
import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";
export default defineRecipe({
id: "my-recipe",
name: "My recipe",
// Allowlist of every tool id this recipe may call (see The ctx API below).
tools: ["firecrawl.scrape", "oxygen.rows_upsert"],
trigger: { type: "api" },
inputSchema: {
type: "object",
properties: { sourceUrl: { type: "string" } },
},
async run(ctx: RecipeContext) {
const input = ctx.input as { sourceUrl?: string };
ctx.log("info", "recipe started", { mode: ctx.mode, input });
return { ok: true, at: await ctx.now() };
},
});defineRecipe takes:
| Field | Purpose |
|---|---|
id | Stable workflow id (used by apply, call, get). |
name | Display name. |
tools | Allowlist of tool ids the recipe may call. Required, non-empty. |
trigger | How the workflow starts — see Triggers. Defaults to api. |
status | "active" or "disabled". Applies active unless set — use "disabled" for templates you enable later. |
inputSchema | JSON Schema for the call payload, surfaced as ctx.input. |
run(ctx) | The recipe body. ctx is the runtime API. |
ctx.input is the workflow's call payload; ctx.mode is the execution mode (smoke_test, dry_run, or live).
The ctx API
Every helper is durable: each call is checkpointed under a stable key (explicit for writes, derived for reads), so on retry completed steps short-circuit instead of re-running. See Attempt budget and resume for when retries happen and how resume works.
| Helper | Purpose |
|---|---|
ctx.tools.run(toolId, input, { key }) | Call a provider tool. |
ctx.tables.create(input, { key }) | Create a table. |
ctx.tables.describe(table, opts) | Read a table's schema and stats. |
ctx.rows.select(table, opts) | Read rows (filters, fields, limit). |
ctx.rows.upsert(table, rows, { key, upsertKey }) | Write rows, upserting by upsertKey. |
ctx.columns.add(table, column, { key }) | Add a column. |
ctx.columns.run(table, column, { key }) | Run a column over a row scope. |
ctx.context.profile.get() | Read workspace context. |
ctx.crm.assert(object, identity, values, { key }) | Upsert a CRM record. |
ctx.crm.logActivity(input, { key }) | Log a CRM activity. |
ctx.approvals.require(input) | Pause for approval before a side effect. |
ctx.step(key, { run }) | Memoize a block of logic under a stable key. |
ctx.log(level, message, payload?) | Structured run log. |
ctx.now() / ctx.uuid() | Deterministic clock / id — never Date.now() or Math.random(). |
Tools and the allowlist
Recipes reach the outside world only through ctx.tools.run — raw fetch() and other network/OS access are disabled in the recipe sandbox. Every tool id you call must be listed in tools, or the runtime throws recipe_tool_not_allowed.
That includes the native table, row, column, context, and CRM helpers: they dispatch through oxygen.* tools, so declare those too.
| Helper | Tool id to allowlist |
|---|---|
ctx.tables.create | oxygen.table_create |
ctx.tables.describe | oxygen.table_describe |
ctx.rows.select | oxygen.rows_query |
ctx.rows.upsert | oxygen.rows_upsert |
ctx.columns.add | oxygen.column_add |
ctx.columns.run | oxygen.column_run_enqueue |
ctx.context.profile.get | oxygen.context_profile_get |
ctx.crm.assert | oxygen.crm_record_assert |
ctx.crm.logActivity | oxygen.crm_activity_log |
Find provider tool ids with oxygen tools search <query>.
Triggers
| Trigger | Shape | Starts the workflow when… |
|---|---|---|
| API | { type: "api" } | you run oxygen workflows call <id>. |
| Webhook | { type: "webhook", trigger_id, idempotency_key_path? } | an external system POSTs to the workflow's webhook URL. |
| Cron | { type: "cron", cron: "0 * * * *", timezone? } | the schedule fires. |
| Event | { type: "event", source, event } | a matching tenant event is emitted. |
Recipe code can't call fetch directly — the sandbox blocks raw network — but ctx.tools.run runs tools worker-side with full network access, so any allowlisted catalog tool can reach an external system. To reach an external HTTP/JSON API, call oxygen.http_json_request (SSRF-guarded HTTPS GET) or oxygen.http_json_post (same guardrails, JSON request body; write-classed, so dry-run previews it) — both for anonymous public endpoints only. For endpoints that need credentials, register a Custom HTTP integration (oxygen custom-integrations apply, which mints custom_http.* tools with stored auth); for web pages, use a scrape tool like firecrawl.scrape. Pair any of these with a cron trigger + ctx.rows.upsert to poll on a schedule, or use a webhook trigger to ingest data an external system pushes to you.
Example: scheduled pull into a table
A cron recipe that reads a list of URLs from a sources table, fetches each, and upserts the result into scraped_pages — the observable, resumable version of "poll on a schedule and save to the DB." The URLs live in a table because a cron run has no call payload, and the bundle safety check rejects URLs hardcoded in recipe code.
Create the tables, add rows to sources, then apply — it registers disabled, so enable it once your sources are in:
oxygen tables create sources # add a `url` column, then your rows
oxygen tables create scraped_pages
oxygen workflows apply --file ./scheduled-sources-to-table.ts
oxygen workflows enable <workflow-id>import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";
type SourceRow = { url?: string | null };
export default defineRecipe({
id: "scheduled-sources-to-table",
name: "Scheduled sources → table",
status: "disabled",
trigger: { type: "cron", cron: "0 * * * *", timezone: "UTC" },
// Native table reads/writes need their oxygen.* tool ids allowlisted too.
tools: ["firecrawl.scrape", "oxygen.rows_query", "oxygen.rows_upsert"],
async run(ctx: RecipeContext) {
const sources = await ctx.rows.select<{ rows?: SourceRow[] }>("sources", {
fields: ["url"],
limit: 100,
});
const results: Array<Record<string, unknown>> = [];
for (const source of sources.rows ?? []) {
if (!source.url) continue;
const page = await ctx.tools.run<{ markdown?: string | null }>(
"firecrawl.scrape",
{ url: source.url },
{ key: `scrape:${source.url}` },
);
results.push({ url: source.url, content: page?.markdown ?? null, fetched_at: await ctx.now() });
}
if (results.length > 0) {
await ctx.rows.upsert("scraped_pages", results, { key: "save", upsertKey: "url" });
}
return { ok: true, scraped: results.length };
},
});The full annotated version lives in docs/examples/recipes/scheduled-page-to-table-recipe.ts.
Determinism rules
Recipes must be replayable. The runtime records each step's outcome; on retry, completed steps short-circuit.
| Allowed | Not allowed |
|---|---|
Deterministic logic over input and prior step outputs | Wall-clock or entropy-based step keys (e.g. Date.now()) |
Calling tools via ctx.tools.run | Raw fetch() or network to external systems |
Reading and writing tables via ctx.* | Filesystem, subprocesses, OS APIs |
| Conditional branches over fetched data | Top-level Promise.race with non-recipe promises |
A non-deterministic step key means the runtime re-executes on retry and loses memoization. Use stable strings derived from your data: "save:" + url, not "step-" + Date.now(). (ctx.uuid() is deterministic across retries, but its value depends on call order, so keys built from it shift when you add or remove earlier ctx.uuid() calls — still prefer data-derived keys.)
Attempt budget and resume
A recipe run executes in attempts. Each attempt has a wall-clock budget — 10 minutes — covering everything the attempt does: sandbox startup, recipe logic, and every tool call. The budget is a platform-level setting shared by all workspaces; there is no per-workspace or per-workflow override today.
Hitting the budget does not fail the run. The runtime stops the attempt shortly before the limit, records recipe_run_attempt_budget_exhausted on the run, and returns it to the queue. The next attempt resumes instead of restarting:
- Every
ctx.tools.run,ctx.step, and table/CRM helper result is persisted as a durable checkpoint the moment it completes, so finished work survives the stop. - The next attempt replays the recipe from the top: completed checkpoints short-circuit with their saved output instead of re-running, so execution fast-forwards to the first unfinished step and only new work spends budget.
Two bounds to design around:
- Total forward progress is bounded by the run's attempt budget. Budget exhaustion and transient step failures (timeouts, rate limits, network errors) consume attempts from the same budget; a run that exhausts it fails with its last error. Whether a failed step retries at all varies by run mode and side-effect class — external-write steps in
livemode are never retried automatically (and must pass an explicit checkpointkey). - A single tool call must fit inside one attempt's remaining budget. Checkpoints resume between calls, never mid-call, so a call too large to finish within an attempt can never complete no matter how many attempts remain. Paginate instead of fetching everything in one call.
Pausing for ctx.approvals.require does not consume an attempt: the run parks until a human decides, then resumes with its attempt budget intact.
Scaling high-fanout recipes
A recipe that discovers items and then enriches each one inline — scrape a post, list its engagers, fetch every profile and company — multiplies tool calls per run, and growing volume eventually outruns any attempt budget. Restructure before that wall:
- Keep the recipe to discovery. Fetch → paginate → dedupe →
ctx.rows.upsertthe raw items into a table, then stop. - Move per-row enrichment into columns. Define tool or AI columns for the profile and company lookups, then hand them off with
ctx.columns.run— it enqueues a background column run over the selected rows, executed by the table-action infrastructure with its own per-row retries, rate limiting, and credit cap, outside the recipe's budget. - Cap work per run and let the schedule absorb growth. Bound posts, pages, or rows per invocation; with a cron trigger and stable upsert keys, each tick picks up what is new, so volume raises the number of ticks instead of the size of one run.
AI column outputs
AI column outputs are JSON envelopes, not bare scalars. Before feeding one into a downstream tool step, project the field you need into its own column with ctx.columns.add(table, { kind: "formula", ... }, { key }) (allowlist oxygen.column_add), then read that column. See Columns for the formula definition and expression syntax.
Registering a recipe
oxygen workflows init # scaffold a local project (first time)
oxygen workflows lint --file ./my-recipe.ts --json
oxygen workflows apply --file ./my-recipe.ts --json
oxygen workflows get <workflow-id> --json
oxygen workflows call <workflow-id> --input-json '{"sourceUrl":"..."}' --mode dry_run --json
oxygen workflows enable <workflow-id> --jsonapply compiles, validates, and registers the recipe as a workflow.
Runtime boundaries
Recipes should use Oxygen primitives for side effects: tables, rows, columns, tools, context, crm, and approvals. That keeps results observable as runs and cells instead of hiding work in local files or untracked network calls.