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.

Using the local loop through Agent Studio or Sapiom MCP requires Sapiom authentication. The check and run still make no real Sapiom capability request and create no Sapiom capability spend. Author-written import and step code 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 each typed agent-run operation and its Local Run stub contract. 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

Sapiom MCP exposes 20 public tools across account state, agent projects, schedules and signals, web-app previews, and feedback. The Sapiom MCP overview maps outcomes to exact tools. The generated tool reference gives released inputs plus reviewed authentication, side effects, returns, and failures for every tool, including sapiom_send_feedback.

The usual project transition is:

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

Authentication is required before any project-authoring tool, including local tools. Scaffold, preparation, check, and Local Run stay on your machine after sign-in and make no Sapiom capability request. A live gallery clone, link, deploy, production run, inspection, signal, or schedule additionally reads or changes organization-owned hosted state.

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.