OXYGENOxygen/ Docs
Authoring

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 npmnpm 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 types

init 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:

  1. Store the credential on the integration connection — OAuth or API key via oxygen integrations connect, or oxygen custom-integrations connect <slug> --secrets-file ./.env for Custom HTTP integrations.
  2. Allowlist that provider's tool in tools and call it with ctx.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:

FieldPurpose
idStable workflow id (used by apply, call, get).
nameDisplay name.
toolsAllowlist of tool ids the recipe may call. Required, non-empty.
triggerHow the workflow starts — see Triggers. Defaults to api.
status"active" or "disabled". Applies active unless set — use "disabled" for templates you enable later.
inputSchemaJSON 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.

HelperPurpose
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.

HelperTool id to allowlist
ctx.tables.createoxygen.table_create
ctx.tables.describeoxygen.table_describe
ctx.rows.selectoxygen.rows_query
ctx.rows.upsertoxygen.rows_upsert
ctx.columns.addoxygen.column_add
ctx.columns.runoxygen.column_run_enqueue
ctx.context.profile.getoxygen.context_profile_get
ctx.crm.assertoxygen.crm_record_assert
ctx.crm.logActivityoxygen.crm_activity_log

Find provider tool ids with oxygen tools search <query>.

Triggers

TriggerShapeStarts 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.

AllowedNot allowed
Deterministic logic over input and prior step outputsWall-clock or entropy-based step keys (e.g. Date.now())
Calling tools via ctx.tools.runRaw fetch() or network to external systems
Reading and writing tables via ctx.*Filesystem, subprocesses, OS APIs
Conditional branches over fetched dataTop-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 live mode are never retried automatically (and must pass an explicit checkpoint key).
  • 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:

  1. Keep the recipe to discovery. Fetch → paginate → dedupe → ctx.rows.upsert the raw items into a table, then stop.
  2. 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.
  3. 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> --json

apply 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.

  • Workflows — what a recipe compiles to.
  • Templates — when a parameterized template is enough.
  • Approvalsctx.approvals.require semantics.

On this page