Custom Workflow Authoring
September 20, 2026 · View on GitHub
Hand-write and maintain workflow TypeScript after you are comfortable with Atomic, or when a generated workflow does not meet your needs. This guide takes one workflow continuously from its module shape through inputs, outputs, stages, artifacts, reload, execution, and validation.
For reusable pieces, prefer importing builtin definitions and composing them with ctx.workflow(...) over copying their implementation.
Or hand-write the TypeScript
Workflow files are plain TypeScript modules. Create .atomic/workflows/explain-file.ts:
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "explain-file",
description: "Explain a file with tracked workflow stages.",
inputs: {
path: Type.String({ description: "File path to explain." }),
},
outputs: {
explanation: Type.String({
description: "Explanation of the file's purpose, risks, and key symbols.",
}),
},
run: async (ctx) => {
const explanation = await ctx.task("explain", {
prompt: `Read ${String(ctx.inputs.path)} and explain purpose, risks, and key symbols.`,
context: "fresh",
});
return { explanation: explanation.text };
},
});
Run /workflow reload or restart Atomic, then list and run it:
/workflow list
/workflow inputs explain-file
/workflow explain-file path="src/index.ts"
See Writing a Workflow for the full workflow({...}) API and WorkflowContext for ctx.task / ctx.chain / ctx.parallel / ctx.stage / ctx.ui.
Writing a Workflow
Automatic stage model selection
When the user delegates the choice of the best stage model, author model: "auto" instead of guessing a concrete model or effort. Keep explicit user model, effort, and constraint requests intact. Omitted model settings retain their existing defaults.
const result = await ctx.task("analyze", {
model: "auto",
prompt: `Analyze ${String(ctx.inputs.path)} against the supplied requirements.`,
modelConstraints: { minContextWindow: 32000, maxInputCost: 5 },
});
ctx.stage("analyze", { model: "auto" }).prompt(text) works too. Set model: "auto" in chain or parallel shared options to route each stage separately. The decision uses the actual supplied prompt after input interpolation and chain context expansion, eligible models and efforts, and compact dated evaluation evidence rather than full guides. It ranks up to three distinct models with an effort for each, trying them in order before remaining configured fallbacks and the current chat model. File references remain references, not guessed file contents. See automatic model selection for ranking and limits.
Long prompts are excerpted for model selection only; the stage still receives the full execution prompt. The excerpt preserves the beginning, end, and <keepContext>...</keepContext> spans. Protect essential selection requirements with those tags, since unprotected middle text may be omitted. If protected text cannot fit, the normal router context-limit and fallback behavior applies. Hard modelConstraints remain enforced independently.
The shared routerModel setting chooses the decision provider. It does not choose which workflow to launch. Jev can make the decision but never executes the stage. See automatic stage operation for failures and resume behavior.
Write workflow code for both Atomic hosts. Standalone binaries run under Bun; npm installs run under Node. A Bun.* global is available only when Atomic itself runs under Bun. Otherwise it fails with Bun is not defined, even if Bun is installed separately.
Use APIs both hosts provide:
node:child_processinstead ofBun.spawn/Bun.spawnSyncnode:fsinstead ofBun.filenode:pathinstead of Bun's path helpers
Every example on this page follows that rule. A snippet that deliberately requires one host has a host-specific: comment naming it.
Workflow files are TypeScript modules that export a workflow definition:
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "my-workflow",
description: "Short description shown in workflow listings.",
inputs: {
prompt: Type.String({ description: "Task or question for the workflow." }),
},
outputs: {
summary: Type.String({ description: "Synthesized findings and recommended next steps." }),
reviewer_count: Type.Number({ description: "Number of parallel reviewers that ran." }),
},
run: async (ctx) => {
const prompt = String(ctx.inputs.prompt);
const scoutPath = ".atomic/workflows/runs/my-workflow/scout.md";
const reviewPaths = {
quality: ".atomic/workflows/runs/my-workflow/quality.md",
runtime: ".atomic/workflows/runs/my-workflow/runtime.md",
} as const;
await ctx.task("scout", {
prompt: `Map the relevant context for: ${prompt}`,
context: "fresh",
output: scoutPath,
outputMode: "file-only",
});
const reviews = await ctx.parallel(
[
{
name: "quality",
prompt: `Scout artifact: ${scoutPath}\nRead the file at ${scoutPath} and inspect only sections needed for this quality review.`,
reads: [scoutPath],
output: reviewPaths.quality,
outputMode: "file-only",
},
{
name: "runtime",
prompt: `Scout artifact: ${scoutPath}\nRead the file at ${scoutPath} and inspect only sections needed for this runtime review.`,
reads: [scoutPath],
output: reviewPaths.runtime,
outputMode: "file-only",
},
],
{ concurrency: 2 },
);
const final = await ctx.task("synthesis", {
prompt: [
`Quality review: ${reviewPaths.quality}`,
`Runtime review: ${reviewPaths.runtime}`,
"Read the files at the paths above incrementally, then synthesize findings and recommend next steps.",
].join("\n"),
reads: Object.values(reviewPaths),
});
return { summary: final.text, reviewer_count: reviews.length };
},
});
Authoring basics:
workflow({ ... })returns the workflow definition directly for discovery; there is no builder terminal step.- Workflow names normalize for lookup: trim, lowercase, convert whitespace/underscore to hyphen, remove other punctuation, and collapse hyphens.
descriptionsets the listing text.autoAttach: trueopens the graph overlay when an interactive top-level named launch through/workflow <name>or the registeredworkflowtool is accepted. Only exacttrueis retained on the compiled definition; omission andfalsedo not opt a definition into auto-attachment. Existing input-form launch behavior is unchanged.heartbeatIntervalMinutesdeclares the workflow's heartbeat cadence in minutes. Omission uses the15-minute default;0disables heartbeats for the workflow. Negative and non-finite values are rejected when the definition is authored. While a run is active, each boundary atstartedAt + n × intervaldelivers a heartbeat card to the main chat as a queued steer that never interrupts an in-flight response. SeeheartbeatIntervalMinutes.inputsdeclares typed user inputs.worktreeFromInputsoptionally maps input names to workflow-wide reusable Git worktree defaults.outputsdeclares typed outputs that parent workflows receive fromctx.workflow(childWorkflow, ...).run: async (ctx) => { ... }defines the workflow body.
prompt and task are aliases for task text inside authored workflow primitives. Prefer prompt because it mirrors lower-level stage.prompt(...); task remains useful in ctx.chain(...) examples.
Author workflows to create at least one tracked execution node by calling ctx.task(), ctx.chain(), ctx.parallel(), ctx.stage(), ctx.workflow(), or ctx.tool() in the run body so each normal run has graph work to inspect and render. Stage nodes remain the attachable, interruptible, resumable chat units; durable tool nodes are non-chat execution. Guard-only workflows may call ctx.exit(...) before creating a node when they intentionally stop early.
Source layout for authored workflows
Keep a small workflow in one readable entry file. Show its graph and control flow at the top level, name each stage for its responsibility, and make inputs, outputs, evidence, and success criteria explicit. A maintainer reading top to bottom should be able to identify branches, gates, artifacts, and stop conditions.
Avoid both monolithic prompt blocks and unnecessary fragmentation. Do not split short one-use prompts, create one file per stage, add wrapper-only modules, or hide the graph across files. Line counts alone are not a module boundary.
When a meaningful source boundary improves clarity, reuse, ownership, or testability, keep the graph and control flow in the top-level workflow entry file and extract cohesive concerns:
- long or reused prompt builders;
- shared TypeBox schemas and workflow-specific types;
- model-policy constants shared by several stages;
- deterministic helpers with their own testable behavior; and
- reusable child workflow definitions.
Put those support modules in a subdirectory below the top-level discovery directory — either one owned by a single workflow or a shared support directory for several workflows. Project and user discovery scans only top-level .ts/.js/.mjs/.cjs files in the workflow directory; the scan is non-recursive, so support modules in subdirectories are not scanned as extra top-level workflow candidates. Every top-level candidate in any of those four extensions is imported and each of its exports is shape-checked, so a support module left at the top level produces definition diagnostics for its non-workflow exports regardless of extension. Use .js import extensions from TypeScript source, following the repository convention.
For example, a custom workflow can keep its support code in a workflow-owned subdirectory:
.atomic/workflows/code-review.ts
.atomic/workflows/code-review/prompts.ts
.atomic/workflows/code-review/schemas.ts
.atomic/workflows/code-review/model-policy.ts
The subdirectory is for cohesive, reusable support code, not a requirement to give every prompt or stage its own file.
Workflow and extension responsibilities
Evaluate Atomic extension hooks when a workflow needs fine-grained, cross-cutting tool or session event control. Workflow TypeScript owns the inspectable DAG, stages, handoffs, durable ctx.tool side effects, and gates. Extension hooks own cross-cutting session and model-tool policy such as tool_call interception, input mutation, or blocking; tool_result transformation; context and provider hooks; lifecycle observation; or reusable custom tools. Use hooks only when cross-stage or cross-workflow event control is materially clearer than embedding the policy in each stage. Do not require a companion extension for ordinary workflow logic. See the authoritative extension event documentation for hook contracts and ordering.
When a workflow depends on a companion extension, make that dependency explicit and package and document the extension with the workflow. If stages use tools allowlists, include any custom tools provided by the extension. Document the hook-driven behavior and keep the graph, stage contracts, artifacts, gates, and stop conditions visible in the workflow entry file so readers can distinguish inspectable workflow orchestration from event policy.
Dynamic topology must remain acyclic
Atomic workflow({ run }) definitions are imperative, dynamic TypeScript. The final graph is materialized only while run(ctx) executes and may depend on runtime inputs, branches, loops, files or network data, model or human output, helpers, and nested workflows. Discovery can report module import and definition-shape diagnostics: it loads the module, checks its exports, schemas, and run function, and rejects failures observable at that point. It does not execute every control-flow path or compile run into a complete graph. TypeScript and discovery cannot prove arbitrary dynamic acyclicity.
Cyclic workflow graphs are unsupported. Workflow authors and coding agents MUST NOT create self-edges or dependency edges from the current frontier to an existing ancestor. Every materialized execution topology must remain a DAG. If a cycle cannot be removed, redesign or stop before launch.
Before launch, sketch the expected node and dependency shape for every branch and loop. Reject any proposed edge from the current frontier to the node itself or an ancestor. Bounded loops must create distinct tracked work for each iteration, with stable per-iteration identity and call order for resume/replay; never reopen an ancestor below its downstream work.
Invalid structural cycle:
Implement → Review → Validate
▲ │
└────── Repair ──────┘
Repair points back to the existing Implement ancestor.
Valid unrolled loop:
Implement
↓
Review 1
↓
Validate 1
↓
Repair 1
↓
Review 2
↓
Validate 2
Each iteration creates new tracked nodes, so the materialized topology stays acyclic.
Retained-session activity without new dependency work is not a loop edge:
Implement ✓
activity: processing follow-up
Record such follow-up as non-topological activity metadata. Do not reopen the original node as a descendant of its own downstream review or validation work.
Discovery cannot prove every dynamic path acyclic. Validate your branches and resumed runs before relying on a definition.
Guiding Principles
- Locally scoped stage prompts - State only the current objective, inputs, expected outputs, and success criteria. Define any vocabulary the stage needs. See Locally Scoped Stage Prompts.
- DAG-only dynamic topology - Treat
run(ctx)as imperative code that materializes graph nodes at runtime. Keep every branch, loop iteration, and nested boundary acyclic; never add a self-edge or a parent edge to an ancestor, and redesign or stop before launch if one remains. - Clear vocabulary - Use clear software engineering terminology in self-described prompts.
- No regex gates - Avoid hard-coded regular expressions that gate reviews or model outputs.
- Schema-backed gates - Prefer schema-backed workflow stages (
ctx.stage(..., { schema }),ctx.chainitems, orctx.parallelitems) for review/gate decisions whenever the workflow must evaluate model output; a schema-enabled item receives the structured-output tool automatically. See Evaluation and Quality Gates. - Stages are model stages - Treat atomic workflow units as language model stages, not deterministic tools.
- Small deterministic-gate stages - When deterministic gates are needed, create small dedicated stages that instruct a model to run a specific tool or perform a specific check. This keeps gates adaptive to the current codebase while preserving explicit workflow structure.
- Checkpoint workflow-owned side effects - Prefer
ctx.tool(name, args, fn)for filesystem writes, network mutations, external API actions, and other side effects orchestrated directly by the workflow definition. Atomic durably caches a completed call's serializable result, so resume returns that result without rerunningfn. Keep pure computation and side-effect-free transformations as ordinary TypeScript. Do not wrap agent-stage internals or every function call indiscriminately. Do not retainctx.toolfor detached work after the workflow executor returns: terminal admission is closed first, and a later call rejects before its callback, retries, graph node, or checkpoint can begin.
Context engineering guidance
Also document the context that stages pass to one another:
- For substantial handoffs, create files or artifacts and tell the next stage to read them instead of putting large text outputs in its prompt or context.
- Prefer forked context for non-reviewer stages so long-running implementation work keeps a coherent, continuous context.
- Prefer a clean context window for reviewer stages so earlier implementation stages do not bias the reviewer. Reviewers should evaluate the supplied artifacts, changed files, tests, and explicit criteria as independently as possible.
See Context Engineering for details.
Protect a stage's role constraints, acceptance criteria, and prohibitions with keepContext so compaction cannot delete them out from under a long-running stage — see Protect the contract from compaction.
Inputs
Inputs are declared with TypeBox Type.* schemas in the inputs object. Import Type from typebox directly in workflow files. Workflow packages still declare typebox as a peer dependency so TypeBox schemas resolve under tsc — see Programmatic usage. Common input schemas map to picker kinds and accepted runtime values:
| TypeBox schema | Picker kind | Accepted runtime value |
|---|---|---|
Type.String({ default? }) | text | string |
Type.Number({ default? }) | number | number |
Type.Integer({ default? }) | integer | integer (whole number) |
Type.Boolean({ default? }) | boolean | boolean |
Type.Union([Type.Literal("a"), Type.Literal("b")], { default? }) | select | one of the literal strings |
A Type.Union([Type.Literal(...)]) of string literals expresses a 'select': the input picker renders those literals as choices, and runtime validation rejects values outside them. Put description and default in the schema options object, e.g. Type.String({ description: "…", default: "…" }). An input is required when its schema is not wrapped in Type.Optional(...) and declares no default; wrap optional inputs in Type.Optional(...). A default does not make an input optional — a defaulted input is always present after defaults are applied.
Prefer explicit descriptions because /workflow inputs <name>, /workflow <name> --help, and the input picker show these descriptions to users. Runtime validation uses TypeBox Value and is strict for both top-level named runs and ctx.workflow(...) child calls: Atomic rejects unknown keys, missing required values, type mismatches, non-JSON-serializable values, and union/literal values outside the declared choices before the workflow body starts. It does not coerce strings like "3" to numbers; pass count=3 or JSON numbers when a schema declares Type.Number().
In TypeScript workflow files, entries in inputs also narrow ctx.inputs for better intellisense: required/defaulted Type.String() inputs are string, Type.Number() is number, Type.Boolean() is boolean, a Type.Union([Type.Literal(...)]) select is the literal string union, and Type.Optional(...) inputs include undefined. Use Static<typeof schema> when you need the inferred TypeScript type of a schema directly.
Outputs
Workflow outputs are runtime contracts for completed workflow runs and for parent workflows that call a child with ctx.workflow(childWorkflow, ...). A workflow normally returns a JSON-serializable object from run, and entries in the outputs object document, validate, and expose keys from that returned object. ctx.exit({ outputs }) can expose a partial subset of the same declared output contract when the run intentionally stops early. Primitives, arrays, null, functions, symbols, undefined properties, NaN, and infinite numbers fail validation.
Return convention: outputs are return-object keys. Atomic never infers child workflow outputs from stage names, stage order, or the final assistant message. If a parent should read child.outputs.foo, the child workflow's run must both declare outputs: { foo: schema } and return { foo: value }. result is not special, and Atomic never adds it: to expose result, declare it in outputs and return { result } exactly like any other output. Returning a key that is not declared in outputs fails the run with atomic-workflows: workflow "<name>" returned undeclared output "<key>"; declare it in outputs or remove it from the run return.
Reserved status output convention and structured failures: if a workflow declares and returns a top-level status output with the string value "failed", Atomic treats the run as failed instead of recording a successful completion. Returned "blocked", "needs_human", "incomplete", "active", and "auth_blocked" statuses are treated as blocked/incomplete terminal states rather than successful completions. The engine's own system-owned budget_exceeded stop is likewise blocked; a workflow-returned budget_exceeded value cannot forge that stop and is treated as a normal completion.
Independently of that convention, Atomic uses structured failure metadata captured from the run's blocking stage (failedStageId) or run-level failure metadata to keep recoverable auth, rate-limit, and provider fallback exhaustion blocked/resumable even when the workflow did not declare a status output. Atomic does not infer failure state by scanning arbitrary output text or by scanning every failed stage in an otherwise completed non-fail-fast branch.
When a workflow returns a reserved status, Atomic uses a non-empty top-level summary string as the run reason shown in lifecycle notices and status surfaces; if no non-empty value is present, Atomic falls back to non-empty top-level remaining_work and then result text. Use the reserved status convention only when the workflow is intentionally reporting its own terminal state (for example, a deterministic release gate that returns { status: "blocked", summary: "required checks are pending" }, or a reviewer-gated workflow that returns { status: "needs_human", remaining_work: "provider credentials are missing" }).
Do not use a top-level status field for unrelated external state such as a deployment/check the workflow only inspected; choose a domain-specific name like deployment_status or gate_status instead.
The outputs object is a schema contract, not an automatic stage selector. To expose values from any stage, capture the stage/task/child result in normal TypeScript and return it from run under the desired key:
export default workflow({
name: "review-with-summary",
description: "Review with returned artifacts.",
inputs: {},
outputs: {
research_artifact: Type.String(),
review: Type.String(),
},
run: async (ctx) => {
const researchPath = ".atomic/workflows/runs/review-with-summary/research.md";
await ctx.task("research", {
prompt: "Research the target.",
output: researchPath,
outputMode: "file-only",
});
const review = await ctx.task("review", {
prompt: `Research artifact: ${researchPath}\nRead the file at ${researchPath} incrementally and summarize risks.`,
reads: [researchPath],
});
return {
research_artifact: researchPath,
review: review.text,
};
},
});
Atomic never adds a result output. A workflow exposes only the keys it declares in outputs and returns from run. To expose result, declare outputs: { result: schema } and return { result }. Returning a key not declared in outputs fails with the returned undeclared output error quoted above. For a child workflow call, <name> is the child's name, and the parent surfaces the failure through the child-failure wrapper described in Workflow Composition.
Declare outputs with TypeBox Type.* schemas. Prefer precise schemas: they give the run return and child.outputs precise Static<> types and validate the actual shape at runtime.
Use Type.Unknown(), Type.Any(), Type.Array(Type.Unknown()), or Type.Object({}, { additionalProperties: true }) only for genuinely dynamic data whose shape you cannot know ahead of time.
| TypeBox schema | Static type | Accepted runtime value |
|---|---|---|
Type.String({ ... }) | string | string |
Type.Number({ ... }) | number | finite number |
Type.Integer({ ... }) | number | integer |
Type.Boolean({ ... }) | boolean | boolean |
Type.Union([Type.Literal("a"), Type.Literal("b")], { ... }) | "a" | "b" | one of the literal strings |
Type.Array(Type.String()) | string[] | array of strings |
Type.Object({ topic: Type.String(), score: Type.Number() }) | { topic: string; score: number } | object matching that shape |
Type.Unsafe<MyInterface>(runtimeSchema) | MyInterface | whatever runtimeSchema accepts (escape hatch) |
Type.Array(Type.Unknown()) | unknown[] | any JSON array (last resort, dynamic only) |
Type.Object({}, { additionalProperties: true }) | Record<string, unknown> | any JSON object (last resort, dynamic only) |
Type.Unknown() / Type.Any() | unknown / any | any JSON-serializable value (last resort) |
Output schemas carry description in their options object. A declared output is required when its schema is not wrapped in Type.Optional(...); wrap outputs that may be absent in Type.Optional(...). A required output means the workflow run return object must contain that output before the run can complete; a missing required output fails with missing output "<key>", and a declared value whose runtime type does not match the schema fails with output "<key>" expected <type>, got <actual>. For child workflow calls, the parent boundary fails before the parent continues.
On completion, Atomic validates declared outputs against their schemas with TypeBox Value and recursively checks every returned or exposed value for JSON serializability. During child output replay, Atomic also performs a structured-clone safety check after JSON validation so continuation can restore completed child workflow boundaries.
Prefer precise schemas
A loose output like Type.Unknown() or Type.Object({}, { additionalProperties: true }) types the run return and child.outputs.x as unknown/Record<string, unknown>, so every consumer must cast or guard before using the value, and runtime validation only checks "is this JSON?" instead of the real shape. Declaring the shape fixes both at once:
// ❌ Loose: child.outputs.report is `unknown`; nothing checks the shape at runtime.
outputs: {
report: Type.Unknown(),
}
// ✅ Precise: child.outputs.report is `{ topic: string; score: number; tags: string[] }`,
// and TypeBox rejects a returned value missing `score` or with a non-number `score`.
outputs: {
report: Type.Object({
topic: Type.String(),
score: Type.Number(),
tags: Type.Array(Type.String()),
}),
}
The same rule applies to inputs: inputs: { counts: Type.Array(Type.Number()) } makes ctx.inputs.counts a number[], while Type.Array(Type.Unknown()) only gives you unknown[].
Type.Unsafe<T>() escape hatch for deeply-nested values
When you already have a precise TypeScript type for a deeply-nested serializable value and don't want to hand-write the equivalent TypeBox schema, wrap a permissive runtime schema with Type.Unsafe<MyType>(...). The static type becomes exactly MyType (so ctx.inputs, the run return, and child.outputs stay precise), while the runtime check stays as lenient as the wrapped schema. Use a type alias rather than an interface for the wrapped type — an interface has no implicit index signature, so it does not satisfy the serializable-output constraint:
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
type ResearchPacket = {
readonly topic: string;
readonly score: number;
readonly sections: readonly { readonly heading: string; readonly body: string }[];
};
export default workflow({
name: "research-packet",
description: "",
inputs: {
topic: Type.String(),
},
outputs: {
packet: Type.Unsafe<ResearchPacket>(Type.Object({}, { additionalProperties: true })),
},
run: async (ctx) => {
const packet: ResearchPacket = {
topic: ctx.inputs.topic,
score: 1,
sections: [{ heading: "overview", body: "…" }],
};
return { packet }; // statically checked against ResearchPacket
},
});
Type.Unsafe<T>() does not deeply validate at runtime. Use it only when the producing code guarantees the shape. Prefer Type.Object(...) and Type.Array(...) when possible; reserve permissive schemas for genuinely dynamic values.
How types flow
ctx.inputs.xisStatic<inputSchema>for the input you declared asinputs: { x: schema }— required and defaulted schemas are always present, andType.Optional(...)adds| undefined.- TypeScript checks the
runreturn against your declared outputs at compile time (a missing required output or wrong value type is a TypeScript error), and TypeBoxValuechecks it at runtime (rejecting undeclared keys and enforcing the declared shape recursively). ctx.workflow(child)returns a discriminated child result. Whenchild.exited === false,child.outputsis the child's full declaredoutputscontract; whenchild.exited === true,child.outputsisPartial<TOutputs>because childctx.exit({ outputs })may intentionally provide only a subset.
Use Static<typeof schema> (both Static and TSchema are re-exported from @bastani/atomic/workflows) when you need the inferred TypeScript type of a schema directly — for example to type a helper that builds an output value.
Stage follow-on user messages
ctx.stage() returns a StageContext with sendUserMessage(content, options?) to inject a normal follow-on user turn into that stage's AgentSession. Use this when workflow code needs to continue an existing stage session after stage.prompt(...) has already resolved, including schema-backed stages where prompt() is intentionally one-shot because the structured-output tool may be called exactly once.
const gate = ctx.stage("review-gate", {
schema: Type.Object({ approved: Type.Boolean() }, { additionalProperties: false }),
});
const decision = await gate.prompt("Review the implementation and call structured_output.");
if (!decision.approved) {
await gate.sendUserMessage("Explain the highest-priority changes needed before approval.");
}
When the stage session is idle, sendUserMessage() starts the next user turn immediately and waits for that turn to finish under the normal workflow stage guard: it observes the stage concurrency limiter, workflow abort/cancellation signals, MCP scoping, readiness gates, and session metadata capture. If sendUserMessage() is the first live call on a ctx.stage(...) handle, Atomic records the stage as a normal running/completed graph node. If it is called after a prior prompt()/complete() has already completed the stage, the follow-on turn still uses internal abort/cancellation and concurrency protection while reusing the completed stage session.
The content argument mirrors the Atomic SDK and accepts either a string or text/image content blocks such as [{ type: "text", text: "Describe this" }, { type: "image", data: "...", mimeType: "image/png" }] when the underlying stage session supports native user-message delivery. Non-native fallback adapters only support string content and reject text/image block arrays instead of stringifying them. Idle non-native fallback delivery sends the follow-on string to the already-selected session directly, so workflow model fallback retries are not re-run for that injected turn. During a controlled pause, the runner gates every stage.sendUserMessage() before selecting either native delivery or the prompt() fallback; therefore an adapter that omits optional sendUserMessage() is not prompted until explicit resume, and the admitted delivery runs once afterward.
When the stage is already streaming, the message is queued as a follow-up by default; pass { deliverAs: "steer" } to steer the active turn instead, or { deliverAs: "followUp" } to be explicit. deliverAs only affects streaming delivery and is a no-op for idle sessions. Follow-on turns preserve the stage's mcp.allow / mcp.deny scope for the injected user turn, just like the original prompt(). The older stage.steer(text) and stage.followUp(text) methods are still available for queueing while a turn is active, but they do not start a new idle turn. If that stage is paused before delivery, Atomic preserves every queued item—type, optional data, duplicate entries, raw content, and order within its steering or follow-up queue—without starting a queued model turn or workflow continuation; late context-bearing traffic joins the hold, and the existing stage resume action releases the queue once.
The two streaming modes have distinct, deterministic timing:
steeris delivered at the next steering boundary: after the current assistant response has finished executing its whole tool batch, and before the next model request. It is not injected between two tool calls emitted by the same assistant response.followUpis delivered only when the agent would otherwise stop — no further tool-driven turns and no steering messages left.
Each queue is FIFO in admission order. There is no global FIFO across the two queues: steering keeps its semantic priority even when a follow-up was submitted earlier. A controlled pause hold delays eligibility but preserves both the queue class and the order within it. An abort, kill, or fatal provider failure ends the turn without consuming what is still queued.
A message you type into an attached stage chat and submit with Enter defaults to steer, matching normal (non-workflow) session steering, so a mid-run correction lands at the next steering boundary rather than at the end of the turn. Ctrl+F queues a follow-up instead. This is a property of the interactive surface, not of the API: an authored stage.sendUserMessage() call that names no deliverAs still defaults to follow-up while the stage is streaming.
Custom AgentSessionAdapter implementations must emit { type: "agent_start" } through subscribe() when a submitted turn starts, then { type: "agent_end", messages } when it ends. This applies to sendUserMessage() and the prompt() fallback. Do not delay start until completion. A synchronous subscription replay is a snapshot; after subscription returns, emit starts only for new turns. If an old replayed turn can end during a newer turn, attach the same stable string or numeric turnId to its start and end so the old event cannot clear the newer turn's ownership.
Native queue pause is an optional StageSessionRuntime optimization for custom adapters:
interface StageSessionRuntime {
readonly queuedMessagesPaused?: boolean;
pauseQueuedMessages?(): void;
resumeQueuedMessages?(): boolean | Promise<boolean>;
}
Existing adapters may omit all three members and continue using the runner's prior fallback pause behavior: the active call is aborted, the workflow objective remains suspended, and public deliveries admitted through the stage handle wait until explicit resume. Adapters that implement the native capability must provide both methods. pauseQueuedMessages() synchronously gates raw queued steer/follow-up work before abort() settles; resumeQueuedMessages() releases that hold without starting a provider turn and returns true only when raw held work was released. Atomic's bundled AgentSession implements this stronger native hold, which preserves already-queued and late native traffic verbatim.
Reporting an already-held queue is a second optional StageSessionRuntime capability:
interface StageSessionRuntime {
getSteeringMessages?(): readonly string[];
getFollowUpMessages?(): readonly string[];
}
Implement these getters to expose messages queued before session attachment, including messages transferred during fallback or retained post-mortem chat. Atomic reads an initial snapshot, then follows ordinary queue_update events. Without the getters, only pre-attachment queue contents are unavailable to the view.
Intercom updates can interrupt current model work; subagent completion notices use the stage's message queues. Work accepted before stage close is handled before the terminal result. Closing cancels remaining stage-owned children and suppresses their late findings. A blocking sibling ask can still open an eligible retained conversation for post-mortem discussion without changing workflow state. Explicit sendUserMessage() remains the API for authored follow-up turns.
Early exit with ctx.exit()
Use ctx.exit(options?) when workflow code intentionally stops the current run from a helper, branch, loop, or precondition guard with a chosen terminal status. ctx.exit() throws an executor-owned control signal and is typed as never, so code after it is unreachable. In async run bodies, prefer return ctx.exit(...) when the exit is the only path so TypeScript can see the non-returning branch.
export default workflow({
name: "guarded-import",
description: "",
inputs: {},
outputs: {
scanned: Type.Number(),
},
run: async (ctx) => {
const files = await findCandidateFiles(ctx.cwd);
if (files.length === 0) {
return ctx.exit({
status: "skipped",
reason: "No matching files",
outputs: { scanned: 0 },
});
}
const review = await ctx.task("review", { prompt: `Review ${files.join(", ")}` });
return { scanned: files.length };
},
});
ctx.exit() accepts status: "completed" | "skipped" | "cancelled" | "blocked" | "failed"; status defaults to "completed". Choose completed when the objective was met and declared outputs are complete and trustworthy; skipped when a precondition made the run a valid no-op; cancelled when the work is no longer wanted, which is a decision rather than a defect; blocked when valid progress needs a changed condition or a later decision; and failed when required work was attempted and definitively could not complete. A bounded reviewer or repair loop that does not converge is blocked, not failed.
reason from a valid author exit is persisted and shown in status surfaces and lifecycle notices, including the default /workflow status list and /workflow status <runId> detail, so do not put secrets in it. An exit rejected during validation is finalized as an ordinary failed run rather than an accepted author exit. outputs may contain a partial subset of declared outputs; provided keys still must be declared in the workflow's outputs object, match their TypeBox schema, and be JSON-serializable. failed exits default to resumable: false; set resumable: true only when a later durable retry is intended. resumable is valid only with status: "failed"; supplying it for another status records a non-resumable authoring failure. A durable retry keeps the failed handle in the resume catalog and re-dispatches the workflow with completed checkpoints replayed. The low-level resumeRun() helper only inspects terminal runs; it reports the durable retry path instead of silently claiming that it resumed. The other exit statuses keep their existing non-resumable author-exit behavior. Public pause and quit, plus internal destructive cancellation, keep their distinct behavior.
An author-initiated failed exit returns to a parent as { exited: true, status: "failed" } with its reason and partial outputs; it does not throw. An unintentional child failure still throws, so check child.exited === true before reading required child outputs and use the discriminator to branch. The lifecycle terminal notice uses the same steer/trigger-turn delivery path and references partial outputs so the launching agent does not need a separate status call.
Exit snapshots outputs before cleanup, so later mutation cannot repair an invalid payload. If reading or copying exit options fails, the run records a non-resumable authoring failure unless external terminal control already won.
After exit is selected, new tracked work and retained stage operations are refused. Queued parallel work does not start; active stages and prompts are skipped. External cancellation can win during cleanup, in which case the canonical result is killed.
On resume of an unfinished tool, a successful exit cannot skip that tool. Restore its matching call before new tracked work; otherwise Atomic reports insufficient_state: replay topology mismatch. Intentional failed, blocked, cancelled, or skipped exits remain available.
Workflow Composition
Use workflow composition when a workflow calls a reusable user-defined workflow from the project or package, or a bundled builtin workflow, and consumes its outputs as a tracked boundary stage. Import the child definition with a normal TypeScript import, then pass it directly to ctx.workflow(workflowDefinition, options). ctx.workflow(...) does not accept registry names, path objects, or string aliases.
Compose nested workflows through these tracked boundaries; do not call a child definition's run function recursively. Each repeated child call must remain a distinct boundary with stable iteration identity and call order so execution, replay, and hydration preserve an acyclic parent/child topology.
For workflows intended to be called by parent workflows, declare every field a parent should rely on in the child workflow's outputs object, including result. No output exists without declaration: a child exposes exactly its declared outputs, and returning an undeclared key fails the child call.
Compose with a user-defined workflow
User-defined workflows are ordinary TypeScript modules. Import the workflow definition with a relative module specifier and call it directly from the parent workflow:
// .atomic/workflows/shared-research.ts
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "shared-research",
description: "",
inputs: {
topic: Type.String(),
},
outputs: {
summary: Type.String({ description: "Research summary markdown." }),
sources: Type.Optional(Type.Array(Type.String(), { description: "Source URLs and file references." })),
},
run: async (ctx) => {
const result = await ctx.task("research", { prompt: `Research ${String(ctx.inputs.topic)}` });
return { summary: result.text, sources: [] };
},
});
// .atomic/workflows/research-and-synthesize.ts
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
import sharedResearch from "./shared-research.js";
export default workflow({
name: "research-and-synthesize",
description: "Run shared research and synthesize it.",
inputs: {
topic: Type.String(),
},
outputs: {
final: Type.String({ description: "Synthesis built from the child research summary." }),
child_run_id: Type.String({ description: "Run id of the nested shared-research child." }),
},
run: async (ctx) => {
const child = await ctx.workflow(sharedResearch, {
inputs: { topic: ctx.inputs.topic },
stageName: "run shared research",
});
if (child.exited === true) {
return ctx.exit({ status: child.status, reason: child.exitReason ?? "shared research stopped early" });
}
const final = await ctx.task("synthesize", {
prompt: `Synthesize:\n\n${String(child.outputs.summary)}`,
});
return { final: final.text, child_run_id: child.runId };
},
});
Compose with builtin workflows
Builtin workflow definitions work like user-defined child definitions. Import several from the barrel:
import {
adversarialVerification,
classifyAndAct,
fanOutAndSynthesize,
generateAndFilter,
goal,
loopUntilDone,
openClaudeDesign,
ralph,
tournament,
} from "@bastani/atomic/workflows/builtin";
Or import one individual module:
import goal from "@bastani/atomic/workflows/builtin/goal";
import ralph from "@bastani/atomic/workflows/builtin/ralph";
Example parent that maps a repository and verifies the synthesis:
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
import { adversarialVerification, fanOutAndSynthesize } from "@bastani/atomic/workflows/builtin";
export default workflow({
name: "research-and-verify",
description: "Map repository slices, synthesize evidence, and verify the report.",
inputs: { topic: Type.String() },
outputs: {
report_path: Type.String(),
approved: Type.Boolean(),
},
run: async (ctx) => {
const research = await ctx.workflow(fanOutAndSynthesize, {
inputs: {
prompt: `Partition repository research for: ${ctx.inputs.topic}. Save cited findings per slice and synthesize conflicts.`,
max_branches: 6,
},
stageName: "repository research",
});
if (research.exited === true) {
return ctx.exit({ status: research.status, reason: research.exitReason ?? "research stopped early" });
}
const verification = await ctx.workflow(adversarialVerification, {
inputs: { task: `Verify the cited report at ${research.outputs.synthesis_path}` },
stageName: "verify research report",
});
if (verification.exited === true) {
return ctx.exit({ status: verification.status, reason: verification.exitReason ?? "verification stopped early" });
}
return {
report_path: research.outputs.synthesis_path,
approved: verification.outputs.approved,
};
},
});
Passing a definition directly to ctx.workflow(...) uses the child definition's normalized name for replay metadata and the default boundary label.
ctx.workflow(workflowDefinition) creates a boundary named workflow:<workflow-name> by default. Valid child graphs appear inside the parent graph, including nested stages and human-input prompts. Attach and control actions target the actual child stage even when names repeat. Child runs are not separate top-level status entries. The returned result has:
| Field | Meaning |
|---|---|
workflow | Normalized child workflow name. |
runId | Nested child run id. |
status | completed for normal completion, or skipped / cancelled / blocked / failed when the child intentionally ended with ctx.exit(...). An unintentional failed child still makes the parent child call throw. |
exited | false for normal child completion; true when the child used ctx.exit(...) (including ctx.exit({ status: "completed" })). |
outputs | Full declared child outputs when exited === false; partial declared child outputs when exited === true. |
exitReason | Optional child ctx.exit({ reason }) text, present only on the exited === true branch. |
ctx.workflow() options:
| Option | Meaning |
|---|---|
inputs | Values validated against the child workflow's inputs schema map before the child starts. |
stageName | Parent boundary stage label. Defaults to workflow:<workflow-name>. |
Output exposure rules:
const child = await ctx.workflow(sharedResearch);
if (child.exited === true) {
child.outputs.summary; // string | undefined: ctx.exit({ outputs }) may be partial
} else {
child.outputs.summary; // string: normal completion returned the full declared contract
child.outputs.sources; // string[] | undefined: optional output declared by sharedResearch
}
A child exposes only outputs declared in outputs and returned from run or supplied to ctx.exit({ outputs }). There are no implicit outputs and no raw return-object passthrough. If run returns a key that was not declared in outputs, the child run fails with atomic-workflows: workflow "<childName>" returned undeclared output "<key>"; declare it in outputs or remove it from the run return, and the parent surfaces that failure through the wrapper atomic-workflows: child workflow "<childName>" (<displayName>) failed with status failed: .... A child with no declared outputs therefore exposes no outputs.
Missing required outputs, schema type mismatches, and non-JSON-serializable returned values fail normal child completion before the parent continues; child ctx.exit({ outputs }) allows missing required outputs but still validates every provided key and sets child.exited === true so parent code must handle the partial shape.
Pass only imported workflow definitions to ctx.workflow(...), not registry names or paths. Missing modules or invalid exports fail discovery. Nested children count against maxDepth, default 4 total workflow levels.
A missing, empty, or invalid child graph retains a boundary summary rather than displaying unrelated nodes. Failed or skipped boundaries also retain summaries. Use stageName for a concise custom boundary label.
If the parent exits while a child runs, the child is cleaned up and becomes cancelled, non-resumable; active child stages and prompts are skipped. No further child work starts after parent exit.
Resume reuses a completed child boundary's recorded outputs without running it again. A child interrupted before completion starts from the beginning. If the parent exits during replay, the boundary is skipped rather than reported as newly completed.