Custom Workflow Authoring
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 withctx.workflow(...) over copying their implementation.
Or hand-write the TypeScript
Workflow files are plain TypeScript modules. Create.atomic/workflows/explain-file.ts:
/workflow reload or restart Atomic, then list and run it:
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, authormodel: "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.
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.
A chat model reads the complete stage prompt to answer the routing questions; a classifier router such as Jev never sees it. Hard modelConstraints remain enforced independently. Set taskNeeds in the stage options when you already know the task’s kind of work, difficulty, mistake cost, or need for images, a very large context or a fast answer; stating all six skips the chat model’s read, and list the contenders in modelConstraints.allowedModels to choose them yourself.
routerModel chooses the routing decision provider. An unset or auto value uses the current chat model; saved classifier credentials do not change that choice. The setting does not choose which workflow to launch. A classifier can make the routing 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
host-specific: comment naming it.
Workflow files are TypeScript modules that export a workflow definition:
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.
.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:
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, durablectx.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
Atomicworkflow({ 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:
Repair points back to the existing Implement ancestor.
Valid unrolled loop:
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.
keepContext so compaction cannot delete them out from under a long-running stage — see Protect the contract from compaction.
Inputs
Inputs are declared with TypeBoxType.* 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:
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 withctx.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:
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.
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 likeType.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:
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:
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.
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.
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.
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:
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:
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.
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.
Desktop verification with Cua Driver in ctx.tool
When a model chooses each desktop action, an authored model stage loads the bundled cua-driver skill and runs one-shot cua-driver call <tool> commands; see Computer use for the face rule, installation, telemetry, and readiness. When your workflow’s TypeScript owns the sequence and the postcondition, run the scenario with the @trycua/cua-driver TypeScript SDK inside ctx.tool(name, args, fn, { timeoutMs }) so the observe → act → re-observe → assert loop is a durable, replayable node rather than a transcript.
Prerequisites and setup, each as one bounded attempt:
node(preferred) orbunon the host. If neither is present, install one in a bounded attempt (Node via fnm withfnm install --ltsor the host’s package manager, Bun viacurl -fsSL https://bun.sh/install | bash) and report what the installer did; if the install is refused or fails, report that as the limitation rather than retrying.- The
cua-driverexecutable (cua-driver --version), installed with upstream’s one-line installer if missing, followed bycua-driver telemetry disableonce. - The SDK installed at the exact driver version into a scratch directory outside the user’s repository, for example
npm install @trycua/cua-driver@0.34.0 --prefix ~/.cache/atomic-cua. The package ships per-platform native optional dependencies. Daemon-backed clients verify contract, tool-schema, capability, and protocol versions before each action and refuse on mismatch, so the pin must matchcua-driver --version.
cua-driver skills install, cua-driver skills update, and clawhub install @cua/driver steps; leave existing user-level skills alone.
The scenario itself is a small script in that scratch directory. It acquires the driver with CuaDriver.connect(undefined) when a daemon is running, so it reuses the daemon’s permission identity (on macOS, CuaDriver.app’s Accessibility and Screen Recording grants) and the persisted telemetry-off preference; upstream describes connect() as a compatibility and app-hosting path, which is exactly the role it plays here. Only when no daemon is reachable does it fall back to CuaDriver.create(undefined), which loads the runtime into the node process. On macOS that in-process fallback attributes permissions to the node host, normally the terminal app, as a separate grant from the app’s; checkPermissions is then read-only, and the host must fully quit and relaunch after granting. Save it as ~/.cache/atomic-cua/verify-counter.mjs:
ctx.tool first, so a missing permission or session ends the run with ctx.exit({ status: "blocked", reason }). A blocked author exit is terminal and not resumable (see Early exit with ctx.exit()), and the preflight’s { ready: false } result is a completed, cached checkpoint, so after the user grants the permission start a new run: its fresh preflight checks again instead of replaying the cached result. The scenario tool spawns node with CUA_DRIVER_RS_TELEMETRY_ENABLED=false in the child environment, forwards signal so a quit or targeted abort kills the child, and maps anything but verified to a nonzero outcome:
verified result is cached durably, so a resumed run does not click again. A thrown refuted, blocked, or unknown result fails the node and is not replayed from cache; a new run executes the preflight and the scenario again from a fresh snapshot, and a durable retry of a resumable failure replays the completed preflight checkpoint and re-runs only the scenario. Keep the artifacts directory outside the repository unless the evidence belongs in the deliverable, and never post the installation UUID from cua-driver telemetry status in a result or reason. Safety is the same on both faces: one controller per desktop, a dedicated session or account where practical, and an explicit stopping point before destructive or publishing actions.
Classifier and image models in ctx.tool
Workflow stages run chat language models. For a schema-backed decision, use the stage’s schema and structured_output tool. It resolves an exact chat or registered classifier ID through the stage model registry and falls back on a classifier runtime failure. The direct @bastani/pi-ai examples below are for cases that need the classifier’s probabilities or image generation from workflow TypeScript inside ctx.tool(name, args, fn, { timeoutMs }). Those calls are durable, replayable nodes. model: "auto" selects only chat language models for stage execution; routerModel may select a classifier for routing, but a classifier never executes a chat stage.
Atomic provides @bastani/atomic/workflows, typebox, and the exact imports @bastani/atomic, @bastani/pi-ai, and @bastani/pi-ai/providers/all to workflow files. The latter three use the running CLI’s module instances and version, including AuthStorage. These examples need no project-local package installation, in npm or standalone-binary installs. A project copy does not override these host-provided imports.
For editor type checking, install the packages as dev dependencies at the version atomic --version prints. Atomic still runs these imports from the CLI, not from the project copy:
node_modules. Install those dependencies next to the workflow. /workflow reload reloads workflow files and their relative imports, but does not replace the shared host modules; restart Atomic to use a different host version.
Pass AuthStorage.create() to builtinModels() so classifier and image requests use credentials saved by Atomic’s /login flows in auth.json; this is the recommended default for workflows running inside Atomic. Ambient provider environment variables such as TYPESAFE_API_KEY and OPENROUTER_API_KEY continue to work. Never read, print, or pass credential values in workflow state, prompts, or artifacts. Both operations resolve instead of throwing: check stopReason === "stop" before using a result, and forward the tool’s signal so a quit or targeted abort cancels the request.
Classify a request before choosing a stage
Use a classifier for a narrow, structured decision such as routing an incoming request. This follows TypeSafe’s intent routing and confidence-gated routing patterns (index: docs.typesafe.ai/llms.txt): one Choice question returns the selected option with a probability per option and a confidence value, and your code decides what to do with that answer. The classifier does not execute a stage; workflow code reads its answer and starts a chat stage or asks for human review. Pick thresholds for your own stakes, as TypeSafe’s pattern pages do; a threshold is not a guarantee of correctness. In.atomic/workflows/triage.ts:
{ choice, confidence } is what a resumed run replays; the classifier is not asked again. For a consequential action, add policy checks or ctx.ui.confirm(...) before the side effect. A schema-backed stage or task (ctx.task(name, { schema, prompt }), as the classify-and-act builtin does) can also make a validated decision. Its structured_output call can name a registered classifier or omit model and use the current chat model. A classifier probability is available only from a direct classify() call like the example above; a chat result does not supply one.
Generate an image artifact in a durable tool step
For an illustrative asset or a design mock, call an image model insidectx.tool() and write the returned base64 image to a file; the cached node returns only the path. Image generation uses generateImages(), never a stage prompt or structured_output. In .atomic/workflows/preview-asset.ts:
openai-images model, for example models.getModelOfType("image", "openai-images", "gpt-image-2.5-flare"). The provider also lists gpt-image-2.5-sunburst, gpt-image-2, gpt-image-1.5, gpt-image-1, and gpt-image-1-mini. It has no login of its own: it uses the key saved for openai-api, then a key saved for openai, then OPENAI_API_KEY. A ChatGPT sign-in cannot call the Images API, so with only that sign-in the call returns stopReason: "error" with Provider is not configured: openai-images. Text blocks become the prompt; add image blocks to input to edit them or use them as references. Pass size, quality, n, or background with the other options when you need them, for example { signal, size: "1024x1024", quality: "low" }; each generated image is a separate image block in result.output.
builtinModels() covers Atomic’s built-in image providers. For another image backend, register its implementation for a new api ID with registerImagesApiProvider({ api, generateImages }) from @bastani/pi-ai/compat, then call generateImages(model, context, { apiKey, signal }) from the same entry point with a model whose api matches. That call dispatches on model.api and does not look up credentials, so take apiKey from your own configuration, such as an environment variable, and never put it in workflow state or outputs. @bastani/pi-ai/compat is not one of the host-provided imports, so install @bastani/pi-ai next to the workflow. To make an image backend available to codemode and extensions through the session’s model registry instead, register a provider with images; see Custom providers.
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 toctx.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:Compose with builtin workflows
Builtin workflow definitions work like user-defined child definitions. Import several from the barrel: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:
ctx.workflow() options:
Output exposure rules:
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.