Skip to content
Go To Dashboard

Agent authoring

This is the deep reference for authoring Sapiom agents — the step model and the patterns you reach for once you’re past the Quickstart. Your scaffolded project also ships an AGENTS.md with the same guidance inline, for your coding agent to read locally.

An agent is one defineAgent({ name, entry, steps }). Each step is a defineStep({ name, next, run }); run(input, ctx) is ordinary async code that returns a directive telling the engine what to do next.

import { defineAgent, defineStep, goto, terminate } from "@sapiom/agent";
const start = defineStep({
name: "start",
next: ["finish"], // the steps this one may hand off to
async run(input, ctx) {
return goto("finish", { greeting: "hello" });
},
});
const finish = defineStep({
name: "finish",
next: [],
terminal: true, // a terminal step ends the agent
async run(input, ctx) {
return terminate({ done: true });
},
});
export const agent = defineAgent({
name: "my-agent",
entry: "start",
steps: { start, finish },
});

defineStep accepts:

FieldPurpose
namethe step’s id
nextstep names this step may goto (the graph edges)
terminaltrue if this step ends the agent
run(input, ctx)your code; returns a directive
inputSchemaoptional Zod schema validating this step’s input
timeoutMsoptional per-step timeout
canFailset true to allow returning fail()
pausedeclares a pause/resume signal (see below)

Inside run, ctx gives you ctx.input, ctx.shared (a typed cross-step store), ctx.logger, ctx.attempts (this step’s retry count), ctx.executionId, and ctx.sapiom (the capability client).

Advance with goto(targetStep, input); end with terminate(output). The value passed to goto becomes the next step’s input. For data several steps need, use ctx.shared (a typed key/value store). The entry input reaches only the entry step’s input — to use any of it in later steps, write it into ctx.shared from the entry step (as compose does below). Validate a step’s input with inputSchema (Zod):

import {
defineAgent,
defineStep,
goto,
terminate,
type AgentExecutionContext,
} from "@sapiom/agent";
import { z } from "zod/v4";
interface Shared extends Record<string, unknown> {
salutation: string;
}
const inputSchema = z.object({ name: z.string().min(1) });
const compose = defineStep({
name: "compose",
next: ["format"],
inputSchema,
async run(input, ctx: AgentExecutionContext<Shared>) {
ctx.shared.set("salutation", `Hello, ${input.name}`);
return goto("format", {});
},
});
const format = defineStep({
name: "format",
next: [],
terminal: true,
async run(_input, ctx: AgentExecutionContext<Shared>) {
return terminate({ greeting: `${ctx.shared.get("salutation")}!` });
},
});
export const agent = defineAgent<z.infer<typeof inputSchema>, Shared>({
name: "greeting",
entry: "compose",
steps: { compose, format },
});

There’s no magic auto-retry — you express failure handling explicitly, which keeps it visible in the graph. The common shape is a bounded loop that escalates to a human: a worker step, an evaluate step that branches, and a counter in ctx.shared that caps the retries before pausing for review. The shape (steps abbreviated):

import { defineStep, goto, type AgentExecutionContext } from "@sapiom/agent";
// evaluate → ship on success, else → reconsider
const evaluate = defineStep({
name: "evaluate",
next: ["ship", "reconsider"],
async run(input: { passed: boolean }, ctx) {
return input.passed ? goto("ship", {}) : goto("reconsider", {});
},
});
// reconsider → loop back to the worker, or escalate once we hit the cap
const reconsider = defineStep({
name: "reconsider",
next: ["work", "escalate"],
async run(
_input,
ctx: AgentExecutionContext<{ attempt: number; maxAttempts: number }>,
) {
const attempt = ctx.shared.get("attempt") ?? 0;
const max = ctx.shared.get("maxAttempts") ?? 3;
return attempt < max ? goto("work", {}) : goto("escalate", {});
},
});

When you goto back into a step that declares an inputSchema, the payload you pass must still satisfy that schema.

The SDK also gives you finer-grained directives when a step should handle its own failure:

  • retry({ delayMs }) — re-run this step; bound it with ctx.attempts.
  • fail(reason, { output }) — end the agent as failed (the step must set canFail: true).
  • timeoutMs on a step caps how long its run may take.

A step’s run completes in one dispatch and can’t block across processes, so a long-running capability (the coding agent) is launched, and the step pauses until it finishes — then a resume step receives the result as its input.

import {
defineStep,
pauseUntilSignal,
terminate,
type AgentExecutionContext,
} from "@sapiom/agent";
import { CODING_RESULT_SIGNAL } from "@sapiom/tools";
interface Shared extends Record<string, unknown> {
codingRunId: string;
}
const launch = defineStep({
name: "launch",
next: ["collect"],
pause: { signal: CODING_RESULT_SIGNAL, resumeStep: "collect" },
async run(input: { task: string }, ctx: AgentExecutionContext<Shared>) {
const run = await ctx.sapiom.models.coding.launch({ task: input.task });
ctx.shared.set("codingRunId", run.runId);
return pauseUntilSignal(run, { resumeStep: "collect" });
},
});
const collect = defineStep({
name: "collect",
next: [],
terminal: true,
// The resumed step's input IS the coding run's result.
async run(input: { status: string; summary: string | null }, ctx) {
return terminate({ status: input.status, summary: input.summary });
},
});

Key points:

  • Declare pause: { signal, resumeStep } on the launching step and return pauseUntilSignal(handle, { resumeStep }) — the handle carries the signal and correlation id, so you don’t wire them by hand.
  • The resumed step’s input is the run’s result (for a dispatch pause — a launched capability). It crossed a process boundary, so it carries no live handles — stash anything else the resumed step needs in ctx.shared before pausing, and re-attach a sandbox from the result’s executionEnvironment if you need one.
  • For a manual human-gate pause, the production resume payload must be a JSON object and is whatever fires the signal. Under run_local, a manual gate always auto-resumes with {}; the local runner does not accept a manual-signal payload override, so type a locally tested happy path defensively and test payload-dependent branches through the cloud signal path.
  • For a human gate, use the manual form and deliver the signal from Run Inspector, Sapiom MCP, or your server. Matching is tenant-scoped by signal name + correlation ID; the delivery’s execution ID frames ownership rather than choosing the waiter:
return pauseUntilSignal({
signal: "my.approval",
resumeStep: "finalize",
correlationId: ctx.executionId, // makes the awaited signal unique to this run
});

The runner uses the current execution ID when correlationId is omitted, but setting it explicitly makes an external callback contract easier to store and inspect. See Use signals for exact delivery requests and matched semantics.

A step can run another deployed agent and await its result — the same launch-and-pause pattern as the coding agent, via ctx.sapiom.agents.

For a quick child run, run inline:

const result = await ctx.sapiom.agents.run({
definition: "enrich-lead", // the child orchestration's deployed slug
input: { domain: "acme.com" },
});

For a long-running child, launch it and pause until it signals — so the parent step doesn’t time out:

import { defineStep, pauseUntilSignal } from "@sapiom/agent";
import { AGENTS_RESULT_SIGNAL } from "@sapiom/tools";
const launchChild = defineStep({
name: "launchChild",
next: ["useResult"],
pause: { signal: AGENTS_RESULT_SIGNAL, resumeStep: "useResult" },
async run(input: { domain: string }, ctx) {
const handle = await ctx.sapiom.agents.launch({
definition: "enrich-lead",
input: { domain: input.domain },
});
return pauseUntilSignal(handle, { resumeStep: "useResult" });
},
});

The resumed step’s input is the child’s AgentRunResultPayload — discriminated on status, so branch on "completed" vs "failed" rather than catching an exception. Import the type from @sapiom/tools.

A step body runs once on the happy path and re-runs only on retry (after a throw or a retry()). Don’t rely on a value being recomputed identically across a pause/resume or a retry — capture non-deterministic values (timestamps, ids) once and pass them forward via goto input or ctx.shared.

The local loop needs no Sapiom account or capability request, and it creates no Sapiom capability spend. Author-written import and step code still has ordinary local side effects:

  • npm run typecheck — confirms every ctx.sapiom.* call and directive you used actually exists.
  • check — typechecks, bundles and imports index.ts, derives the manifest, and validates the step graph.
  • run_local — runs your real step code with every Sapiom capability stubbed; a dispatched pause auto-resumes with its stub result, while a manual gate auto-resumes with {}.

run_local needs no stubs to start (capabilities return sensible defaults). Add overrides in .sapiom-dev/stubs.json only when a step branches on a specific result:

{
"version": 1,
"steps": { "launch": { "models.coding.run": { "status": "completed" } } },
}

Stub the method your step actually calls — models.coding.launch if you launched it, models.coding.run if you awaited inline. Capability paths use the plural namespace for namespace calls (repositories.list) and the singular handle for handle methods (repository.pushFromSandbox). run_local reports unusedStubs (a key matched nothing — usually a typo or a singular/plural slip) and stubWarnings (a key matched but the value was the wrong shape) — a green run with either non-empty means a stub silently didn’t apply.

See Test locally for precedence, verbatim response values, branch coverage, and a checked failure stub.

Every step receives a pre-authenticated, tenant-scoped capability client at ctx.sapiom. Its finite, versioned shape comes from the installed @sapiom/tools declarations. Use editor autocomplete and npm run typecheck; an absent method is not callable from a step.

Audio and browser automation are present on ctx.sapiom as ctx.sapiom.speech and ctx.sapiom.browserAutomation. Use the Capabilities overview to choose an implemented surface, and treat the installed declarations as authoritative.

For example, file storage requires the byte count before it creates a presigned upload:

const bytes = new TextEncoder().encode(JSON.stringify({ result: "done" }));
const { fileId, uploadUrl, requiredHeaders } =
await ctx.sapiom.fileStorage.upload({
contentType: "application/json",
fileName: "report.json",
visibility: "private",
fileSize: bytes.byteLength,
});
await fetch(uploadUrl, {
method: "PUT",
headers: requiredHeaders,
body: bytes,
});
const { downloadUrl } = await ctx.sapiom.fileStorage.getDownloadUrl(fileId);

The fetch transfer above is ordinary author code. Under Local Run, the ctx.sapiom.fileStorage.* calls are stubbed but that direct network request is not; supply a deliberate test boundary instead of assuming Local Run is a sandbox.

Common typed namespaces include:

NamespacePurpose
sandboxesCreate, attach to, execute in, and destroy ephemeral compute.
repositoriesCreate or attach repositories and push work from a sandbox.
llmOne routed LLM call (run), or a deferred/session call for queued or repeated calls.
modelsAn in-server agentic loop, no filesystem (run/launch).
models.codingRun inline or launch a coding task for pause/resume.
fileStorageCreate uploads and short-lived downloads, list files, and change visibility.
contentGeneration.images / .videoCreate image or video output, optionally into file storage.
speechText-to-speech, sound effects, and voices.
browserAutomationScreenshots and browser-session lifecycle; connect your own CDP client for interaction.
searchWeb search, page scraping, and typed email lookup.
databaseProvision, inspect, list, and delete on-demand Postgres.
agentsRun or launch another deployed agent.

The installed declarations remain authoritative when this summary and a package version differ. The individual capability pages explain which operations are typed, Cloud-MCP-only, or available on both surfaces. See Choose a call surface for when to reach for llm vs. models.run vs. agents.run.

Sapiom MCP runs locally from @sapiom/mcp. Register it as sapiom-project in Claude Code or Codex:

Terminal window
claude mcp add sapiom-project -- npx -y @sapiom/mcp
Terminal window
codex mcp add sapiom-project -- npx -y @sapiom/mcp

These are MCP tool names, not shell commands:

ToolPurposeImportant boundary
sapiom_statusReport the current environment and authentication state.Read-only.
sapiom_authenticateOpen browser sign-in and cache the shared credential.Required before the first cloud action, not before local authoring.
sapiom_logoutClear the cached credential.Does not delete project or Studio state.
sapiom_dev_agents_scaffoldCreate a bundled starter project.Writes an npm-install-ready project; install dependencies separately.
sapiom_dev_agents_cloneMaterialize one gallery template, fork, or deployed definition.Gallery and fork clones are source-only until link/deploy.
sapiom_dev_agents_checkTypecheck, bundle, import, derive the manifest, and validate the graph.Import-time author side effects remain real.
sapiom_dev_agents_run_localRun authored code locally with ctx.sapiom.* stubs.No Sapiom capability request or spend; ordinary local effects remain real.
sapiom_dev_agents_linkResolve or create the hosted definition by name.Requires authentication.
sapiom_dev_agents_deployBundle current local source, including uncommitted source, and start a metered cloud build.Returns the exact build ID; it does not start an agent run.
sapiom_dev_agents_runStart a real execution of the runnable build.Can create metered capability usage.
sapiom_dev_agents_inspectRead a cost-agnostic execution or build audit and optionally wait.Costs are a separate dashboard read.
sapiom_dev_agents_signalDeliver an object payload to a matching manual pause.Dispatched-capability callbacks resume automatically.
sapiom_dev_agents_scheduleCreate a recurring cron or one-off trigger.A schedule is independent from delayed child dispatch.
sapiom_dev_agents_schedule_inspectList an agent’s schedules or inspect one schedule and recent fires.A non-null execution ID identifies a started run.
sapiom_dev_agents_schedule_cancelDisable future unfired occurrences.Does not cancel a run that already started.
sapiom_dev_agents_cron_previewValidate cron and timezone and project UTC occurrences.Authenticated but non-persistent.
sapiom_dev_sandbox_configureWrite a validated sandbox-preview resource to sapiom.json.Local configuration only.
sapiom_dev_sandbox_checkValidate configured sandbox previews.Does not deploy.
sapiom_dev_sandbox_previewUpload, build, start, and expose a web-app preview.A failed result carries build/start logs.

The usual ownership transition is:

scaffold/clone → install → typecheck → check → run_local
authenticate → link → deploy → run → inspect
signal

A bundled scaffold can complete the first line without a Sapiom account. A live gallery clone, link, deploy, production run, inspection, signal, or schedule reads or changes organization-owned cloud state and requires authentication.

The browser pages and generated agent-readable outputs share this site’s canonical navigation. Fetch the compact index at https://docs.sapiom.ai/llms.txt, the combined corpus at https://docs.sapiom.ai/llms-full.txt, or append .md to a canonical page URL:

Terminal window
curl https://docs.sapiom.ai/agents/authoring.md
curl https://docs.sapiom.ai/guides/test-locally.md

Scaffolded projects already include AGENTS.md and the version-matched sapiom-agent-authoring skill. Read those local files first when they disagree with a newer web page, because they match the packages installed in that project.