README.md
September 20, 2026 · View on GitHub
Atomic Workflows
Turn coding agents into reliable engineering workflows.
An open-source Atomic workflow extension: install it, author workflows in TypeScript, run them from chat.
Call workflow route with the actual request, relevant message text/document excerpts, and explicit constraints in state, not file paths in place of content. If it returns none, continue inline. Otherwise use its input contract to prepare inputs, then call workflow run with the registered workflow ID. Ask only for genuinely missing information.
Atomic can author custom TypeScript workflow({...}) definitions inline, import reusable project/package workflows or builtins from @bastani/atomic/workflows/builtin, and nest them with ctx.workflow(...). Imported children may nest further workflows within maxDepth, so compose proven research, implementation, design, verification, and approval graphs rather than copying them. Custom parents can also use runtime classification, dynamic fan-out and synthesis, adversarial verification, candidate tournaments, HIL gates, and bounded convergence.
Workflow stage sessions are created in process and receive a typed stage policy rather than inheriting subagent environment flags. Resource reload never mutates process.env; stage options carry the subagent management/fanout policy directly, so concurrent stage creation is race-free while existing tool allowlists and the one-level delegation rule remain authoritative. Legacy child environment keys are only a compatibility path for older hosts.
Authoring API · Surfaces · Builtins ·
Custom workflow directories
Adding workflow files under .atomic/workflows/ (project scope) or ~/.atomic/agent/workflows/ (user scope) makes them discoverable automatically. To register additional discovery paths, add a workflow extension config file at .atomic/extensions/workflow/config.json for a project or ~/.atomic/agent/extensions/workflow/config.json for your user account:
{
"workflows": {
"team": { "path": "/shared/team/workflows" }
}
}
Temporary-worktree setup can symlink selected main-root directories into each checkout (the default preserves node_modules):
{
"worktree": {
"symlinkDirectories": ["node_modules", ".cache"]
}
}
After Atomic is running, use /workflow reload or the workflow tool's reload action to rescan all workflow sources in process. Additions, edits, renames, deletions, config changes, and package-resource changes become visible immediately to list/get/inputs/help/completion/invocation surfaces. Reload requests are serialized/coalesced and publish a complete replacement registry; an in-flight workflow keeps its original definition while later calls use the new registry. Fatal refresh failures retain the prior registry, and skipped malformed or missing resources are reported with actionable diagnostics while valid siblings remain available.
Workflow lifecycle notifications
Workflow lifecycle notices are enabled by default. They send steer prompts into the main chat/model context when a run completes, fails, or ends blocked, and when a user starts, quits, or resumes one. Awaiting-input prompts are tracked for dedupe/restore, but they do not wake the main chat agent. Configure lifecycle tracking in the same extension config file:
{
"workflowNotifications": {
"enabled": true,
"notifyOn": ["started", "completed", "failed", "blocked", "awaiting_input", "paused", "quit", "resumed"]
}
}
Set enabled to false to disable all lifecycle notices, or narrow notifyOn to a non-empty list of selected events. Completion, failure, and blocked lifecycle notices are emitted for top-level workflow runs, use steer delivery, and wake an idle model so the lifecycle update enters the model context when it happens. When a fulfilled workflow body leaves admitted tool failures, the engine promotes the first admission and persists that exact tool origin for the failed notice. Ordinary body rejections retain their original error and failed graph nodes without claiming a tool origin because transparent native promises do not expose the source promise; this prevents a caught tool rejection from being misattributed when body code later throws the same object or primitive. Nested child workflow completion/failure is reflected inside the expanded parent graph instead of producing separate top-level completion cards. Awaiting-input states are tracked for dedupe/restore, but workflows do not enqueue main-chat /workflow connect cards for them; prompt state remains visible through workflow status/connect surfaces, avoiding stale actionable cards if a prompt resolves while the main chat is streaming.
Treat blocked runs as continuable by default: resume, answer pending prompts, steer, or use a follow-up workflow. Explicit task-scoped inline/no-workflow requests override that default even for complex work. Safely hold/stop the affected run, reconcile completed work and in-flight effects, then continue inline without duplication; completed work is not undone. Keep safety, authorization and validation requirements. A budget_exceeded stop still requires approval before raising the chosen limit. See verification and evidence for execution-mode scope, environment-aware browser/terminal/desktop verification, qlty setup and native GitHub attachments.
Control notices report deliberate actions on a top-level run: /workflow <name> produces a WORKFLOW STARTED card (▶), /workflow quit a WORKFLOW QUIT card (⏹, warning tone, plus a resumable field), and /workflow resume a WORKFLOW RESUMED card (▶). These travel the same steer delivery, capped-backoff retry, and card path as the failure notice. The quit text says the stop was deliberate and user-requested and instructs the model not to resume the run or take the work over unless asked, hinting /workflow resume <run-id>; the resumed text does not, since the run is progressing again.
Only attributed user actions notify. The matching workflow({ action: "run" | "pause" | "quit" | "resume" }) tool calls stay silent, because the tool result already reports them to the agent. /workflow pause does not attribute an actor or raise a main-chat control notice. Engine-internal transitions stay silent too — a notice exists only when a control path named an actor — which keeps answering a human-in-the-loop prompt and the resume-acknowledgement pass from flooding the chat. Workflow activity and lifecycle observation still report pause requests independently of chat notices.
Each notice carries two attributions. Origin is who launched the run and renders on every kind as "which you started" or "which the user started"; it is recorded once at dispatch, persisted through session restore and durable resume, and inherited by a continuation from the run it continues. Actor is who performed this one event ("The user resumed"). A run with no recorded origin omits the clause rather than guessing one.
One attributed request produces one notice. A whole-run resume reports at run scope; a stage-scoped resume reports at stage scope when siblings remain paused. A quit reports the quit alone, never the pause it publishes on the way. Notices are deduplicated by run id together with the occurrence timestamp (quitAt/resumedAt), so repeated snapshot invalidations at one unchanged state notify once. Resuming reports a resume and never a start, whoever asked for it. Supported resume of paused, quit, or recoverable failed/blocked work preserves the same workflow id and durable checkpoints; the notice names that one id. A run already started, paused, or quit when notifications install (restore, replay, /reload, session-preserving reinstall) is seeded as delivered and stays silent, and nested child runs never notify at top level.
When a stage human-in-the-loop prompt is answered from the workflow TUI/stage chat, workflows also emits a separate display-only workflows:hil-answer-notice custom message. It records the answer for user-visible audit, but it does not wake the main agent, enter LLM context, or authorize answering later workflow prompts. Answers sent by the main-chat workflow tool do not emit this notice because the tool result already tells the main agent what happened.
Authoring API
Dynamic topology is DAG-only
Atomic workflow({ run }) definitions are imperative, dynamic TypeScript. Discovery loads modules and validates imports and definition shape, but it does not compile every run path into a complete graph or prove acyclicity. Runtime inputs, branches, loops, external data, model or human output, helpers, and nested workflows determine the materialized topology during execution.
Cyclic workflow graphs are unsupported. Authors and coding agents MUST NOT create a self-edge or a dependency edge from the current frontier to an existing ancestor. Every materialized execution topology must remain a DAG. Redesign or stop before launch if a cycle cannot be removed.
Sketch branches, loops, and nested boundaries before launch. Bounded loops must create distinct tracked work per iteration with stable identity and call order; never reopen an ancestor beneath its downstream work. Compose children through ctx.workflow(...) boundaries rather than recursive run invocation. Keep retained-session follow-up that creates no dependency work as activity, not a back-edge.
Execution, replay, and DBOS hydration are the authoritative topology-validation points. Runtime topology changes should add incremental edge checks and DBOS hydration validation; prompt guidance and TypeScript types cannot replace these checks.
Workflow source layout
Keep a small, readable workflow in one entry file and write it for human maintainers. Keep the graph and control flow visible in the top-level workflow entry file, use stage names that state each stage's responsibility, and make its inputs, outputs, evidence, and success contract explicit. A developer reading the entry file from top to bottom should be able to identify the graph, branches, gates, artifacts, and stop conditions. Avoid both monolithic prompt blobs and gratuitous fragmentation: do not split short one-use prompts, create one file per stage, add wrapper-only modules, hide the graph across files, or use line counts alone as a module boundary.
At a meaningful source boundary that 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; or reusable child workflow definitions.
Put 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. Use .js import extensions from TypeScript source.
The existing .atomic/workflows/release-docs.ts keeps its graph in the entry file and imports deterministic helpers from .atomic/workflows/lib/release-docs.ts, a shared support directory that also holds the separate publish-release helper. For example:
.atomic/workflows/code-review.ts
.atomic/workflows/code-review/prompts.ts
.atomic/workflows/code-review/schemas.ts
.atomic/workflows/code-review/model-policy.ts
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.
Workflow-owned side effects
Prefer ctx.tool(name, args, fn) for workflow-owned TypeScript operations with side effects, including filesystem writes, network mutations, external API actions, and similar deterministic operations orchestrated directly by the workflow definition. Each invocation creates a non-chat, non-attachable durable graph node before fn runs; it may appear before, between, after, or without model stages. Atomic durably caches a completed call's serializable result, so resume returns that result without rerunning fn or repeating the side effect. Tool-only workflows are valid tracked execution, while a normal return with no stage, child, tool, or explicit exit remains invalid. Keep pure computation and side-effect-free transformations as ordinary TypeScript. Do not wrap agent-stage internals or every function call indiscriminately. The executor closes tool admission before publishing any terminal outcome; calling a retained ctx.tool function afterward returns a rejected native promise and creates no callback, retry, graph node, or checkpoint.
Checks that may fail as part of a repair loop can opt into failureMode: "return". That overload returns a typed { ok: true, value, attempts, cached } | { ok: false, error, attempts, cached } outcome after configured retries. Failure details preserve exposed exitCode, stdout, and stderr; persisted text is best-effort secret-redacted and limited to 16 KiB per field. A recoverable failure keeps its tool node failed but lets author code continue. Replay returns the same durable outcome with cached: true and does not rerun the callback. Cancellation and storage faults still throw. Atomic never injects this evidence into a later prompt: pass the needed fields to a repair stage or artifact explicitly, and use distinct serializable arguments for each bounded rerun.
New tool checkpoints retain stable graph identity, invocation order, parents, and timing. A current-format checkpoint created before tool topology was added still replays safely: its cached output remains authoritative and the callback is never rerun. Root inspection derives deterministic fallback identity/order from checkpoint identity and record order; replay inside a child attempts to append topology metadata with the current child/boundary ownership without replacing the original output checkpoint. That additive migration write is best-effort: a storage rejection leaves the live replay cached in its inferred child, does not poison a later durable flush, and is retried by a later replay. Until one retry succeeds, a fresh completed inspection can only show the legacy record through the deterministic root fallback with topology unavailable; historical child ownership cannot be reconstructed from the old record alone. Fresh completed inspection reconstructs available tool topology but does not persist a workflow's declared root output; that existing limitation is separate from live run() output and graph correctness.
Example 1 — Single task
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "summarize-pr",
description: "Summarize a pull request in one task.",
inputs: {
pr_url: Type.String({ description: "URL of the pull request to summarize." }),
},
outputs: {
summary: Type.String({ description: "One-task summary of the pull request." }),
},
run: async (ctx) => {
const summary = await ctx.task("summarize", {
prompt: `Summarize the pull request at ${String(ctx.inputs.pr_url)} clearly and concisely.`,
});
return { summary: summary.text };
},
});
Example 2 — Parallel fan-out with ctx.parallel
Use ctx.parallel for independent specialist work. The aggregator receives the specialist outputs through typed task results instead of manual stage/session plumbing. The runtime snapshots the parent graph frontier when the fan-out starts, so every branch shares the same parents even when limited concurrency queues later branches or an earlier sibling fails with failFast: false.
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "parallel-research",
description: "Scout → three parallel specialists → aggregator.",
inputs: {
topic: Type.String({ description: "Research topic." }),
},
outputs: {
summary: Type.String({ description: "Synthesized summary of the specialist reports." }),
},
run: async (ctx) => {
const topic = ctx.inputs.topic;
const reportPaths = {
auth: ".atomic/workflows/runs/parallel-research/auth.md",
db: ".atomic/workflows/runs/parallel-research/db.md",
api: ".atomic/workflows/runs/parallel-research/api.md",
} as const;
await ctx.parallel([
{ name: "auth-specialist", task: `Research authentication patterns for: ${topic}`, output: reportPaths.auth, outputMode: "file-only" },
{ name: "db-specialist", task: `Research database layer for: ${topic}`, output: reportPaths.db, outputMode: "file-only" },
{ name: "api-specialist", task: `Research API surface for: ${topic}`, output: reportPaths.api, outputMode: "file-only" },
], { concurrency: 2, failFast: false });
const summary = await ctx.task("aggregator", {
prompt: [
"Synthesize the specialist reports.",
`Auth report: ${reportPaths.auth}`,
`Database report: ${reportPaths.db}`,
`API report: ${reportPaths.api}`,
"Read the files at the paths above incrementally and only expand sections needed for the synthesis.",
].join("\n"),
reads: Object.values(reportPaths),
});
return { summary: summary.text };
},
});
Example 3 — Human-in-the-loop (HIL)
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "review-and-merge",
description: "Plan a change, ask for human approval, then execute.",
inputs: {
task: Type.String({ description: "What to implement." }),
},
outputs: {
status: Type.Optional(Type.String({ description: "Set to \"cancelled\" when the human rejects the plan." })),
result: Type.Optional(Type.String({ description: "Implementation result when the plan is approved." })),
},
run: async (ctx) => {
const planPath = ".atomic/workflows/runs/review-and-merge/plan.md";
const plan = await ctx.task("planner", {
prompt: `Create a concise implementation plan for: ${String(ctx.inputs.task)}`,
output: planPath,
});
const approved = await ctx.ui.confirm(`Proceed with this plan?\n\n${plan.text}`);
if (!approved) return { status: "cancelled" };
const result = await ctx.task("implementer", {
prompt: [
`Plan artifact: ${planPath}`,
`Read the file at ${planPath} incrementally, then execute it exactly.`,
].join("\n"),
reads: [planPath],
});
return { result: result.text };
},
});
Human input is runtime-only: call ctx.ui.input, ctx.ui.confirm, ctx.ui.select, ctx.ui.editor, or ctx.ui.custom<T> at the point where the workflow actually needs a decision. No declaration-time HIL marker is required or supported.
ctx.ui.custom<T>(factory, options?) mounts an arbitrary focused TUI component in the attached workflow graph/stage UI and resolves with the value passed to done(value). The factory uses the same real TUI/theme/keybinding/component types as Atomic extension ctx.ui.custom. Use options.label for a safe display-only graph/status label and options.replayIdentity (do not include secrets) when the widget's semantics can change without the callsite changing; label text is not part of replay identity. Custom widget prompts require an interactive workflow graph; they are not answerable through the non-TUI workflow answer action. Both inline rendering and overlay: true mount: overlay is a placement hint, so the request lands on the attached stage chat's custom-UI slot — which keeps the stage transcript visible behind it — rather than opening a nested overlay above the graph chrome. An in-stage ask_user_question, which always asks for an overlay, therefore mounts and answers there; overlayOptions and onHandle are not consumed on that path.
Example 4 — Compose workflows
Prefer regular TypeScript module imports for reusable child workflows: import the workflow definition returned by workflow({...}), then pass it directly to ctx.workflow(workflowDefinition, options).
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 cited evidence, then 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,
},
});
if (research.exited) 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}` },
});
if (verification.exited) return ctx.exit({ status: verification.status, reason: verification.exitReason ?? "verification stopped early" });
return {
report_path: research.outputs.synthesis_path,
approved: verification.outputs.approved,
};
},
});
The child executes as a nested workflow behind a parent boundary stage named workflow:<workflow-name> by default, but user-facing status and graph views recursively replace that boundary with a valid, non-empty child graph whose run reciprocally identifies the parent run and boundary stage. Every boundary parent connects to every child root, and every child terminal connects to each downstream dependent. Repeated or sibling children keep distinct virtual node ids and exact { runId, stageId } control targets even when local stage ids or names collide, so attach, send, pause, resume, stage selection, and post-mortem chat reach the true owning run and stage. Implementation-owned child runs stay out of top-level /workflow status lists.
For durable runs, Atomic writes and awaits a versioned boundary-start identity before child code can run. That record fixes the boundary id, child run id, owning root/parent, source order and parents, replay scope, alias, workflow, and a deterministic fingerprint of the child definition plus exact validated inputs across pause, process restart, cached replay, and completed inspection. Distinct-input parallel calls therefore keep their own cache even when restart reverses dispatch order; repeated identical calls share the fingerprint but retain a per-invocation ordinal. A completed child boundary and its ctx.tool side effects replay exactly once from the root's scoped checkpoints; only incomplete work continues.
If no valid child graph can stand in for the boundary—including a failed or skipped boundary, a missing or empty child graph, stale or mismatched ownership metadata, or a recursive link that cannot produce a valid expansion—the expanded graph keeps the boundary summary instead of flattening an unrelated or invalid child. Exact expanded ids resolve first; a local id or stage name is accepted only when it identifies one visible stage, and identifiers always match whole—a stage id is never truncated, so a prefix never selects a stage. Inputs are strictly validated against the child workflow before it starts: unknown keys, missing required values, type mismatches, and invalid select choices fail before the child body runs. The parent receives the child's declared outputs on child.outputs after those outputs pass their declared runtime type checks.
For workflows intended to be called as children, declare an outputs entry for every non-default field a parent should rely on. outputs is only the schema/contract: use normal TypeScript in run() to gather values from any stage/task/child workflow and return those keys.
Return convention: child 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.summary, the child workflow's outputs map must declare summary and run() must return { summary }. result is not special and is never added for you: to expose result, declare outputs: { result: schema } and return { result } 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 (the child-call variant reports ... child "<alias>" returned undeclared output "<key>" from "<childName>").
A reusable child module can simply default-export a workflow definition:
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "shared-research",
description: "Reusable research helper.",
inputs: {
topic: Type.String(),
},
outputs: {
summary: Type.String(),
},
run: async (ctx) => {
const report = await ctx.task("research", {
prompt: `Research: ${String(ctx.inputs.topic)}`,
});
return { summary: report.text };
},
});
Builtin workflows are also callable as modules for reuse:
import {
adversarialVerification,
classifyAndAct,
fanOutAndSynthesize,
generateAndFilter,
goal,
loopUntilDone,
openClaudeDesign,
ralph,
tournament,
} from "@bastani/atomic/workflows/builtin";
import fanOutAndSynthesizeWorkflow from "@bastani/atomic/workflows/builtin/fan-out-and-synthesize";
import goalWorkflow from "@bastani/atomic/workflows/builtin/goal";
import ralphWorkflow from "@bastani/atomic/workflows/builtin/ralph";
import openClaudeDesignWorkflow from "@bastani/atomic/workflows/builtin/open-claude-design";
Only workflow({...}) definitions can be passed to ctx.workflow(...); registry names, strings, and path objects are intentionally not supported for child workflow calls. Missing or invalid module imports fail when the workflow file itself is loaded. A parent receives the child's declared outputs from the child run() return object. Missing required outputs, schema type mismatches, returning an undeclared output, and non-JSON-serializable returned child values fail the child call before the parent continues.
Early exit with ctx.exit()
Use ctx.exit() when the workflow intentionally ends before its normal run() return. It accepts completed | skipped | cancelled | blocked | failed, an optional reason, partial declared outputs, and resumable for an author-initiated failed outcome.
if (allRejected) {
return ctx.exit({
status: "failed",
reason: "The upstream API rejected every candidate",
outputs: { attempted: candidates.length },
resumable: true, // failed exits default to false
});
}
Choose the status by the outcome:
completedmeans the objective was met and declared outputs are complete and trustworthy.skippedmeans a precondition made the run a valid no-op; no work was needed.cancelledmeans the work is no longer wanted; it is a decision, not a defect.blockedmeans valid progress needs a changed condition or a later decision. A bounded reviewer or repair loop that does not converge is blocked, not failed.failedmeans required work was attempted and definitively could not complete. Do not use it for a non-converged reviewer loop. Failed exits are non-resumable by default; setresumable: truewhen a later durable retry is intended.resumableis valid only withfailed; another status records a non-resumable authoring failure.
reason from a valid author exit is persisted and shown in status and lifecycle notices. An exit rejected during validation is finalized as an ordinary failed run rather than an accepted author exit. outputs may be a partial subset of the declared outputs contract, but every provided key must be declared, schema-valid, and JSON-serializable. Missing required keys are allowed only on the exit path. A durable retry re-dispatches the workflow with completed checkpoints replayed. The low-level resumeRun() helper only inspects terminal runs and reports the durable retry path; it does not silently claim that it resumed one.
When a child uses ctx.exit(), ctx.workflow(child) returns a discriminated result instead of throwing. Check child.exited before reading a required output:
const child = await ctx.workflow(researchWorkflow);
if (child.exited === true) {
// child.status includes failed; outputs may be partial.
return ctx.exit({ status: child.status, reason: child.exitReason ?? "research stopped early" });
}
return { report: child.outputs.report };
An author-initiated failed exit returns { exited: true, status: "failed" } to its parent with its reason and partial outputs. An unintentional child failure still throws, so exited remains an intent discriminator.
Reusable Git worktrees
Use gitWorktreeDir when a workflow should run in a reusable Git worktree instead of the invoking checkout. The executor creates the worktree if it is missing, reuses it when it already exists as a same-repository worktree root, defaults workflow ctx.cwd to the matching path inside that worktree for worktreeFromInputs, and defaults stage/task cwd to that worktree path.
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "safe-implementation",
description: "Run implementation stages in a reusable worktree.",
inputs: {
task: Type.String(),
worktree: Type.String({ default: "" }),
base_branch: Type.String({ default: "origin/main" }),
},
worktreeFromInputs: {
gitWorktreeDir: "worktree",
baseBranch: "base_branch",
},
outputs: {
result: Type.String({ description: "Implementation result text." }),
},
run: async (ctx) => {
const result = await ctx.task("implement", {
task: String(ctx.inputs.task),
// No cwd needed: when `worktree` is non-empty, this task runs from the
// corresponding cwd inside that reusable Git worktree.
});
return { result: result.text };
},
});
You can also pass worktree options per stage/task or as shared chain/parallel defaults:
await ctx.stage("review", {
gitWorktreeDir: "../review-worktree",
baseBranch: "origin/main",
}).prompt("Review the current changes.");
await ctx.parallel([
{ name: "security", task: "Security review" },
{ name: "runtime", task: "Runtime review" },
], {
gitWorktreeDir: "../review-worktree",
baseBranch: "origin/main",
failFast: false,
});
Worktree semantics:
gitWorktreeDirmust be used from inside a Git repository. Relative paths resolve from the logical invoking repository root; absolute paths are used as-is.- If the requested path exists, it must be an actual Git worktree/checkout root belonging to the invoking repository. The invoking checkout itself, paths nested beneath it, foreign repositories, and existing subdirectories are rejected so writes do not silently land in the main checkout.
- If the path is missing, the parent directory is created and Git runs
git worktree add --detach <path> <baseBranch>from the canonical main repository root.baseBranchdefaults toHEADwhen omitted. Missing targets whose existing parent resolves through a symlink beneath the invoking checkout are rejected. - The default execution cwd preserves the caller's repo-relative cwd inside the worktree. For example, invoking a workflow from
repo/packages/apiwithgitWorktreeDir=../repo-wtuses../repo-wt/packages/apifor workflowctx.cwdand stage/task execution. - Symlinked repo/worktree paths preserve their logical spelling in the default cwd, matching Codex-style worktree behavior.
- An explicit absolute
cwdinside the invoking checkout is remapped to the corresponding worktree path; an absolutecwdalready inside the selected worktree is preserved. Relative values resolve from the worktree default cwd and cannot escape it. Foreign paths, lexical traversal, and symlink escapes fail before a session starts. - Relative stage/task outputs follow the effective worktree cwd and cannot traverse or follow symlinks outside the selected worktree. Explicit absolute outputs remain caller-selected.
worktree: true on an authored ctx.task(...) is different: it creates a branch-backed temporary checkout at <main-root>/.atomic/worktrees/<flattened-name> using branch worktree-<flattened-name> and cleans up both checkout and branch afterward, including failures before the task callback starts. / in generated names is flattened to +, and creation stays anchored at the canonical main root even when Atomic is launched from a linked worktree. The base ref is an explicit baseBranch, then origin/<default-branch> (fetched when needed), then HEAD. Post-creation setup copies .atomic/settings.local.json plus untracked .atomic/settings.json, shares the main repository's Husky or populated .git/hooks directory through core.hooksPath, symlinks configured worktree.symlinkDirectories, and copies gitignored files matched by .worktreeinclude; tracked checkout content is never overwritten. When no task cwd is set, temporary isolation starts from the runner invocation cwd; relative task cwd values resolve from that same invocation cwd. Relative task outputs are persisted under distinct per-task runner-owned temporary artifact directories before cleanup; returned output artifact paths therefore remain readable, including with outputMode: "file-only". Those relative paths cannot traverse or follow symlinks outside their runner-owned output root, and a pre-existing symlink or junction at the trusted artifact root is rejected. It is mutually exclusive with gitWorktreeDir, which is intended for named/reusable worktrees that remain available across retries and /workflow resume. Durable resume records the original invocation cwd and resolved reusable-worktree metadata, then replays from that original repository context rather than whichever cwd the resumed interactive session currently has. Reusable worktree setup is cached by canonical repository and target identity within a workflow run, independent of equivalent path spelling or baseBranch, and the selected checkout identity is revalidated before reuse. Read-only Git repository probes retry a transient timeout once, and slow Git subprocess failures include the exact command, cwd, timeout, elapsed time, exit status/signal, and spawn error details.
Worktrees provide checkout and cwd isolation, not an operating-system security sandbox. A process with permission to mutate arbitrary sibling paths can still race filesystem checks; use a container, VM, or another OS-enforced boundary for untrusted code.
For advanced integrations, the SDK also exports setupGitWorktree(options), which returns { worktreeRoot, cwd, repositoryRoot, created } and uses the same validation/path behavior as the executor.
Structured stage results
structured_output is opt-in for workflow items. Add schema to ctx.stage, ctx.task, ctx.chain items, or ctx.parallel items when the stage must finish with machine-readable JSON:
const Decision = Type.Object({
approved: Type.Boolean(),
findings: Type.Array(Type.String()),
}, { additionalProperties: false });
const decision = await ctx.stage("review-gate", { schema: Decision }).prompt(
"Review the artifact and return the decision.",
);
// decision.approved is typed from the schema.
Atomic registers the canonical structured_output tool only for schema-enabled items and automatically adds it to explicit tools allowlists. The schema is used directly as the tool argument contract. A schema-backed StageContext supports one prompt() call because the final-answer tool is a single result contract; create another ctx.stage(..., { schema }) for another structured prompt. If a turn completes without calling structured_output, or the tool call fails schema validation, Atomic sends up to three corrective follow-up prompts that include the exact contract/validation error before failing the item. ctx.task/ctx.chain/ctx.parallel results expose the captured value as result.structured and keep result.text as formatted JSON for handoffs.
When an item configures both schema and output, its successful structured_output turn has two separate channels: all ordinary assistant text blocks from that exact message, in order, form the human-readable artifact written to output, while the successful tool arguments remain the typed machine-readable workflow result. Atomic never serializes those arguments into the artifact. The runner snapshots the result together with the exact successful tool-call id, so corrective attempts, another call with the same tool name, and later admitted turns cannot replace either side of the pairing. When the successful message has no ordinary text — including after a model-fallback session recreation that leaves the live session without that message — Atomic falls back to the most recent earlier assistant text that made no structured_output call; only when no such text exists is the artifact written empty, with the standard empty-artifact warning in the receipt. Items with schema but no output keep their existing result-text behavior.
subagent is available as a default workflow-stage tool on the same terms as main chat: a stage is a top-level session, so it can delegate once, and the children it launches cannot delegate further. Delegation is one level deep and nothing configures it. tools allowlists apply to bundled extension tools as well as built-ins; if a stage sets tools, list every non-mandatory tool it should see. Ordinary intercom remains registered and active in every workflow model stage even under noTools: "all", restrictive allowlists, or exclusions. Restrictions on subagent, web_search, fetch_content, and every other tool are unchanged. Bundled @bastani/subagents agent definitions are available to the subagent tool in workflow stages, including workflows launched from a subagent child process.
Send material updates through Intercom to every affected workflow stage, including stages that have not started. Join workflow:<rootRunId> and use intercom list to see live, pending, and possible future targets, then send to workflow:<rootRunId>/<segment>[/<segment>...]; segments may be stage names, run ids, or globs (* matches one segment, ** any depth). Name and pattern sends remain sticky for every future match until root termination. When shared scope or acceptance criteria change, broadcast one authoritative update to workflow:<rootRunId>/** (or a narrower pattern) rather than enumerating stages; live matches receive it immediately and future matches receive it before their first model turn. A valid target outside the known set queues with a notInKnownSet warning and settles undeliverable at terminal only if never delivered. Use ask only once the target is live and can reply. Workflow answer handles pending human-input prompts, and workflow resume handles paused run control.
Stages are also peers of one another. Every stage in an invocation shares the workflow:<rootRunId> group with its siblings and with the subagents they delegate, so a live stage can intercom list the others and send/ask them directly to debate a finding with evidence, hand off paths and reproductions, claim shared files or an expensive step, or learn what a sibling already verified. A send to a known pending sibling queues until it starts; an ask to a completed sibling that retains a valid conversation reopens it for a post-mortem turn. Named subgroups isolate their members from sibling subgroups. Stages do not coordinate spontaneously: when parallel stages should exchange evidence, the prompt names the peers or how to discover them, bounds the exchange, and requires each stage to return its own result. See peer coordination between stages.
Model fallbacks
Stages and high-level task helpers can retry transient provider/model failures with an ordered fallbackModels list. The primary model is tried first, then each fallback, and finally the current Atomic-selected model when available. Fallbacks are only used for retryable model/provider failures such as rate limits, quota/usage-limit exhaustion (provider messages such as The usage limit has been reached and codes such as usage_limit_reached/insufficient_quota classify as retryable rate-limit failures so the chain advances to a candidate with remaining headroom), auth/provider outages, unavailable models, network timeouts, context-window overflows that Atomic's auto-compaction cannot resolve on the current model, and 5xx errors — ordinary tool, shell, validation, cancellation, and workflow-code failures are not retried.
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "fallback-review",
description: "Review with a model fallback chain.",
inputs: {
topic: Type.String(),
},
outputs: {
review: Type.String({ description: "Reviewer output text." }),
model: Type.Optional(Type.String({ description: "Model that produced the review." })),
attemptedModels: Type.Optional(Type.Array(Type.String(), { description: "Models tried, in fallback order." })),
modelAttempts: Type.Optional(Type.Array(Type.Unknown(), { description: "Per-attempt model fallback details." })),
},
run: async (ctx) => {
const review = await ctx.task("reviewer", {
prompt: `Review this topic: ${String(ctx.inputs.topic)}`,
model: "anthropic/claude-sonnet-4",
fallbackModels: ["openai/gpt-5-mini", "github-copilot/gpt-5-mini"],
});
return {
review: review.text,
model: review.model,
attemptedModels: review.attemptedModels ? [...review.attemptedModels] : undefined,
modelAttempts: review.modelAttempts ? [...review.modelAttempts] : undefined,
};
},
});
Set model and fallbackModels on the authored stage/task/chain/parallel item that needs them.
When the user delegates the best-stage-model choice, use model: "auto" on stages or shared chain/parallel options. Routing selects from the actual prompt after input interpolation, supplied context, available model/effort pairs, and shipped evaluation guidance. Preserve explicit model, effort, and modelConstraints requests. Automatic model/effort assignments remain pending until routing; the following manual assignment guidance applies to deliberately concrete stages.
For authored workflows, choose thinking effort by stage role and failure cost rather than copying a benchmark's level onto every stage. Pick the model for each stage from factual measured evidence in packages/coding-agent/docs/models/evals.md and the general role guidance in packages/coding-agent/docs/models/model-selection.md. Before launch, record each model stage's role, failure cost, primary model, thinking level, and fallback policy, then print a compact Stage | Model | Thinking | Role assignment with a short rationale. Reserve max for high-cost-of-error roles or an explicit user request; use high for demanding analysis, planning, and repair; use medium for user-impact review and reporting; and keep deterministic checks as tool nodes with no model call. Apply the same role policy independently to every fallback, not a mechanical max inheritance. Use only levels listed in the configured catalog's availableThinkingLevels; if a level is unsupported, choose another catalog model or leave the stage unpinned rather than inventing a suffix.
When pi exposes its model registry, workflow runs validate user-specified model / fallbackModels before starting model-backed work and report all unavailable or ambiguous IDs together. Bare model IDs are accepted only when they resolve uniquely or match the current provider; otherwise use provider/model. Fallback attempts may send the same prompt/context to a different provider, so choose fallbacks that fit your cost, privacy, and data-handling requirements.
createRegistry — grouping workflows
import { createRegistry, workflow } from "@bastani/atomic/workflows";
const alpha = workflow({ name: "alpha", description: "", outputs: {}, run: async () => ({}) });
const beta = workflow({ name: "beta", description: "", outputs: {}, run: async () => ({}) });
const gamma = workflow({ name: "gamma", description: "", outputs: {}, run: async () => ({}) });
const registry = createRegistry()
.register(alpha)
.register(beta)
.merge(createRegistry().register(gamma));
registry.names(); // ["alpha", "beta", "gamma"]
registry.all(); // workflow definitions
registry.get("alpha"); // workflow definition | undefined
Declaring inputs and outputs with TypeBox
Inputs and outputs are declared with TypeBox schemas. Import workflow from @bastani/atomic/workflows, import Type from typebox, and put schemas in the inputs and outputs maps. workflow({...}) infers precise static types for ctx.inputs, the run() return, and child.outputs from those schemas, and the runtime validates against them with TypeBox Value.
Prefer precise schemas. A precise schema (Type.Object({ topic: Type.String(), score: Type.Number() }), Type.Array(Type.String())) gives consumers a precise Static<> type and makes runtime validation enforce the real shape. Reserve Type.Unknown(), Type.Any(), Type.Array(Type.Unknown()), and Type.Object({}, { additionalProperties: true }) for genuinely dynamic data whose shape you cannot know ahead of time.
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
workflow({
name: "example",
description: "",
inputs: {
prompt: Type.String({ description: "Required free-text input." }), // required key -> ctx.inputs.prompt: string
ref: Type.Optional(Type.String()), // optional key -> string | undefined
count: Type.Number({ default: 2 }), // defaulted -> required key, ctx.inputs.count: number
flavor: Type.Union([Type.Literal("a"), Type.Literal("b")], { default: "a" }), // select
},
outputs: {
packet: Type.Object({ topic: Type.String(), score: Type.Number() }), // required object output
note: Type.Optional(Type.String()), // optional output
},
run: async (ctx) => ({ packet: { topic: ctx.inputs.prompt, score: ctx.inputs.count } }),
});
Static and TSchema are also re-exported from @bastani/atomic/workflows for advanced typing.
Input schema reference
| Schema | Picker kind | Notes |
|---|---|---|
Type.String({ default?, description? }) | text | Free-form string |
Type.Number({ default?, description? }) | number | Finite number |
Type.Integer({ default?, description? }) | integer | Integer |
Type.Boolean({ default?, description? }) | boolean | True/false toggle |
Type.Union([Type.Literal("a"), Type.Literal("b")], { default? }) | select | Enumerated string choices |
Type.Optional(schema) | — | Makes the key optional (T | undefined) |
A required input is any schema that is neither Type.Optional(...) nor carries a default (a defaulted input is a required key at the type level but optional for the caller to provide). Input validation is strict for named workflow runs and ctx.workflow(...) child calls: Atomic rejects unknown keys, missing required values, values whose runtime type does not match the declared schema, and select values outside the declared literals. It does not coerce strings like "3" into numbers; pass JSON numbers (count=3) for Type.Number(). The inputs map narrows ctx.inputs for intellisense: required/defaulted strings are string, numbers are number, booleans are boolean, selects are the literal union, and Type.Optional(...) inputs include undefined.
Output types
Declare outputs in outputs when a workflow result should be part of its runtime contract, especially when another workflow will call it as a child. Lead with the most precise schema you can express — the loose rows at the bottom are last resorts for genuinely dynamic data.
| Schema | Runtime value accepted |
|---|---|
Type.String() | string |
Type.Number() | finite number (rejects NaN) |
Type.Integer() | integer |
Type.Boolean() | boolean |
Type.Union([Type.Literal(...)]) | one of the declared literal strings |
Type.Array(Type.String()) | array of the declared element type (use the real type) |
Type.Object({ topic: Type.String(), ... }) | object matching the declared shape |
Type.Unsafe<T>(runtimeSchema) | precise static T, lenient runtime (escape hatch) |
Type.Array(Type.Unknown()) | any JSON array (last resort, dynamic only) |
Type.Object({}, { additionalProperties: true }) | any JSON object (last resort, dynamic only) |
Type.Unknown() / Type.Any() | any JSON-serializable value (last resort) |
Wrap an output schema in Type.Optional(...) to make the key optional; an un-wrapped output schema is required. run() must return a JSON-serializable object. Functions, symbols, undefined properties, NaN, infinite numbers, and non-plain objects (e.g. Date) fail validation. Declared outputs are validated before a workflow is marked completed. A required output that is missing fails with missing output "<key>", and a type mismatch fails with output "<key>" expected <kind>, got <actual>. A workflow exposes exactly the outputs it declares in outputs: there is no automatic result output, and returning a key that was not declared fails the run with atomic-workflows: workflow "<name>" returned undeclared output "<key>"; declare it in outputs or remove it from the run() return. To expose result, declare outputs: { result: schema } and return { result }. Child output replay still performs a structured-clone safety check after JSON validation so completed child boundaries can be replayed.
Why precise schemas
A loose schema types the value as unknown/Record<string, unknown> everywhere it is read and only checks "is this JSON?" at runtime. A precise schema types it exactly and validates the real shape:
// ❌ Loose: child.outputs.report is `unknown`; runtime only checks "is JSON".
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()),
}),
}
Type.Unsafe<T>() escape hatch
When you already have a precise TypeScript type for a deeply-nested serializable value and don't want to hand-write the full 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 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: "Return a typed research packet.",
inputs: {
topic: Type.String(),
},
outputs: {
// Static type = ResearchPacket; runtime only checks "is a JSON object".
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 };
},
});
Tradeoff: Type.Unsafe<T>() does not deeply validate at runtime — it trusts the produced value matches T. Use it when the producing code already guarantees the shape; when you can express the shape directly, prefer a real Type.Object(...)/Type.Array(...) so runtime validation also catches drift. Keep bare Type.Unknown() and loose additionalProperties objects for genuinely dynamic data.
How types flow
ctx.inputs.xisStatic<inputSchema>— required/defaulted inputs are present,Type.Optional(...)adds| undefined.- The
run()return is checked against declared outputs at compile time (missing-required, wrong-type, and undeclared-output keys are TypeScript errors for object-formworkflow({...})) and at runtime via TypeBoxValue(undeclared keys rejected, declared shape enforced recursively). ctx.workflow(child).outputsis typed from the child's declaredoutputscontract, so a parent reads precisely-typed child outputs without casting.
Static and TSchema are re-exported from @bastani/atomic/workflows; use Static<typeof schema> when you need a schema's inferred TypeScript type directly.
Surfaces
Slash commands
| Command | Description |
|---|---|
/workflow <name> [key=value ...] | Start a named workflow, passing optional input overrides |
/workflow <name> --help | Print the workflow's input schema |
/workflow list | List all registered workflows with descriptions |
/workflow status [run-id] | Show active plus retained terminal/current-session runs, or details for one run |
/workflow connect [run-id] | Open a workflow run graph |
/workflow attach [run-id] [stage] | Open live stage chat or explicit terminal post-mortem chat |
/workflow pause [run-id|--all] | Pause active/named/all active runs so they can resume |
/workflow quit [run-id|--all] | Gracefully pause live workflow runs so they can resume later |
/workflow resume <run-id> | Resume paused work or re-open a run snapshot |
/workflow reload | Reload discovered workflow resources and package-manifest entries in-process |
/workflow inputs <name> | Print the input schema for a workflow |
Input overrides are bare key=value tokens (no leading --). Values are JSON-parsed when possible, so numbers, booleans, and quoted strings work as expected (e.g. count=3, flag=true, prompt="multi word value"). A whole-object override can be passed as a single JSON token (e.g. {"prompt":"...","count":3}). Runtime validation is strict: unknown input keys, missing required values, type mismatches, and invalid select choices fail before a named workflow run starts.
Named workflow launches always run as background tasks in interactive sessions. Run /workflow connect <run> to see agents working and chat with and steer each stage. Foreground launches are reserved for explicit user requests or technical requirements, with notice before launch. Press F2 to open the same live graph viewer; HIL prompts (ctx.ui.input/confirm/select/editor/custom) appear as awaiting-input graph nodes. Press Enter on a focused node, click a visible graph node, or choose one from the switcher to open a ctx.tool node's one read-only agent-chat-style tool message block, never a modal dialog or editable transcript. Tool nodes remain non-attachable: their graph card body is always the constant durable tool, and the message block is collapsed by default with the tool name/call and a compact bounded result or error preview. The configured app.tools.expand action (ctrl+o by default) toggles full bounded args, result or error, callback source, timing, and cached/replayed markers. ↑/↓, PageUp/PageDown, Home/End, the wheel, and the scrollbar scroll the block; Escape or ctrl+x returns to the graph. It is read-only and offers no attach, steer, pause, or resume action. ctrl+x is the workflow hierarchy chord: attached stage chats show ctrl+x return to graph, while graph surfaces show ctrl+x leave graph · return to main chat. Workflow surfaces consume it before configurable editor/tool actions. Composer and prompt drafts survive leaving a stage, and pending custom questions remain pending for reattachment. ctrl+d and q are not workflow navigation controls; ordinary editor/prompt Ctrl+D behavior and printable prompt q remain available. esc, ctrl+c, and graph h close/hide behavior are unchanged. While the graph pane is active, vertical wheel/trackpad gestures pan vertically and horizontal gestures pan wide graphs left and right when the terminal reports them, without falling through to the main chat or terminal scrollback. Focused graph and stage-chat overlays receive those gestures through the fullscreen application route. Fullscreen pi-tui owns application selection, so drag and multi-click selection also work over workflow overlays; copied text uses OSC 52, and terminals that refuse OSC 52 writes rely on the modifier-drag bypass (Shift/Option, as provided by the terminal). ctrl+t is not a workflow control: focused workflow overlays leave it to the host app.thinking.toggle action, while inline tree selectors keep app.tree.filter.noTools. Human input is detected when those runtime ctx.ui.* calls execute; workflows no longer have a declaration-time HIL flag.
Typing into an attached stage chat and pressing Enter steers: the message is consumed after the current assistant response finishes its tool batch and before the next model request, matching normal session steering. Ctrl+F queues a follow-up, consumed only when the agent would otherwise stop. Queued entries belong to the stage session rather than the pane, so leaving the stage and reattaching restores the pending Steering: / Follow-up: rows, and a detached stage node carries a ✉ N queued badge in the graph.
At normal widths, the persistent below-editor BACKGROUND panel and interactive /workflow status list/detail name materialized pending stages by display name and canonical ID. They show the canonical workflow:<rootRunId>/<segment>[/<segment>...] path only when pre-start Intercom delivery is available on a nonterminal owning run and label unavailable delivery otherwise; retained pending stages on ended runs, including ended nested child runs projected into a live root, never advertise a target. Interactive status cards and run detail wrap a full target onto continuation rows when needed, and narrow status cards preserve the full canonical stage ID rather than ellipsizing it. The single-line panel uses only pending-stage forms that fit its remaining metadata budget; if none fit, it omits the pending label entirely so mode, progress, live-tool, and elapsed/status metadata remains intact. The narrow widget remains aggregate-only. See Workflow Operations for the per-root pending-input preview on waiting BACKGROUND cards.
Named launches return only after startup admission, while the admitted workflow body and stages remain background work. Pre-body setup failures (including invalid input-bound reusable worktrees) are returned immediately from the original tool call as structured failed results with the concrete error and allocated run id; Atomic does not first claim that the workflow started, and it removes the unadmitted run so corrected inputs can be retried immediately. Failures after admission continue to use normal background status and lifecycle notices.
Graceful quit is idempotent for already-paused runs and preserves unresolved ctx.ui prompts in DBOS. Pausing a stage also holds every queued steering and follow-up item in place: no queued turn, late context-bearing delivery, or workflow continuation starts while the stage is paused, and the existing resume action releases the items once in their existing per-queue order without queue release itself starting a provider turn. Stable author-callsite-and-composed-nested-scope reservations are created before prompting and released by exact current-format token generation after answer checkpoint, rejection, or abort. Answering while quit/paused cannot advance workflow code until explicit resume.
Workflow durability uses DBOS/Postgres as its only persistent backend. Atomic initializes it lazily on the first workflow action, reuses that process-wide instance, and awaits readiness before durable execution or control. DBOS_SYSTEM_DATABASE_URL selects an existing database when supplied. Otherwise Atomic first provisions its embedded Postgres (npm-distributed binaries, directly spawned under ~/.atomic/postgres on port 5439, shared across sessions) and tries DBOS's reusable dbos-db Docker container if embedded provisioning fails without leaving retained-process cleanup pending. Linux root execution clears inherited supplementary groups before dropping Postgres to an unprivileged primary GID/UID; when that account cannot traverse the installed binaries, Atomic publishes one exact package-content runtime generation under a root-owned cache, where it stays readable/executable but not writable by Postgres. Reuse and publication re-snapshot the current source, validate the deterministic path after rename, and fail closed on source mutation, corruption, or setup-lease displacement without accumulating unbounded repair copies. If DBOS still cannot become ready, Atomic shows interactive and RPC actions a display-only warning notification that is not added to agent/model context, then runs against one process-local in-memory backend. Print and other headless actions without a usable UI write the same actionable warning to the console instead. Workflow execution continues, but its state does not survive the process and /workflow resume after exit has nothing to restore. Once DBOS is ready, query or write failures fail the workflow action and do not switch backends. Abrupt Atomic exit releases the retained native process lease without killing Postgres. Orderly shutdown sends fast shutdown and waits on that exact retained process; sessions attached to an already-running cluster never own or stop it. Concurrent Atomic sessions safely share one database: unique per-process executor ids, owner/heartbeat metadata on running workflows, and first-writer-wins claims on contended status transitions prevent double dispatch. Running workflows never appear as resume targets in any session; stale-heartbeat (crashed) ones surface as red crashed rows, paused rows render yellow, failed/blocked red, completed green, and the open picker live-updates on local changes plus a bounded cross-session poll.
DBOS is the only durable catalog for resume, completed inspection, deletion, and targeted lookup. Session JSONL files remain chat transcripts only. Atomic reads one current durable format and does not convert prior local state or pre-current DBOS records. A completed current-format child checkpoint created before boundary-start or invocation-fingerprint identity existed is shown only when its child checkpoints reciprocally prove the same root, parent run, boundary, child owner, and scope. Active checkpoints without a provable invocation fingerprint, and malformed, duplicate, stale, nonreciprocal, mixed, aliased, cyclic, orphaned, or unsupported topology, fail closed before cache exposure or child/control dispatch: Atomic does not invent a child link or execute child code to repair it.
Nested ctx.workflow(...) calls are displayed as an expanded graph within the top-level run. /workflow status and run pickers list only top-level user-launched workflows, not implementation-owned child runs. After a fresh process starts, active and completed graphs retain source stage ids, order, parent edges, lifecycle status, boundary ownership, and exact { runId, stageId } control targets. The workflow tool's stages, stage, transcript, answer, pause, and resume actions route a uniquely identified visible child stage to that nested owner, and ambiguous local ids or names are rejected. Run-level quit still targets the selected top-level run or all live top-level runs. Completed graph inspection is read-only even when no stage transcript remains. /workflow attach <root-run> <nested-stage> remains the explicit user-driven path for retained post-mortem chat and routes to the true child owner without resuming or mutating execution. (stages, stage, transcript, and answer are workflow tool actions, not /workflow slash subcommands; the slash command exposes connect, attach, pause, list, status, quit, resume, reload, and inputs.)
Raw stage-chat prompt answer replay is live-memory only. StageSnapshot.promptAnswerState reports whether continuation can replay a raw stage-chat answer (available), must ask again because the private ledger entry is gone (unavailable), or must ask again because multiple matching prompt nodes are ambiguous (ambiguous). Raw answers stay in a private PromptAnswerRecord ledger, are never serialized to snapshots or persistence, and remain resident in memory until the answer is cleared, the run is removed, or the store is cleared. Durable ctx.ui responses are separate DBOS checkpoints: resume returns those cached responses without asking again, and graph-backed UI re-materializes the answered prompt node from metadata. Replay keys include prompt kind, message text, select choices, input/editor initial value, custom prompt identity hash, and hashed author callsite, so changing any of those inputs may intentionally re-ask on continuation. Empty ctx.ui.select(..., []) calls throw before creating a prompt node. Arbitrary custom-widget answers cannot be supplied with workflow answer; focus the custom awaiting-input node in the interactive graph instead.
workflow tool (LLM-callable)
The route-then-run contract above applies to model-tool launches:
workflow({
action: "route",
state: {
task: "Map session persistence by subsystem and synthesize cited findings. Do not change code.",
conversation: [{ role: "user", text: "Read-only research; cite concrete repository paths." }],
documents: [{ source: "research brief", content: "Reconcile conflicting findings in the synthesis." }],
},
})
// After inspecting the returned inputSchema, use its workflowId:
workflow({ action: "run", workflowId: "returned-execution-id", inputs: { prompt: "Map session persistence" } })
Route returns workflowId, routerDecision.workflowType, routerDecision.estimatedDuration and the selected definition's actual inputSchema including defaults. The structured result appears in a WORKFLOW ROUTE box. It never launches. routerDecision.workflowType: "none" returns an empty ID, registers nothing and means continue inline, not task completion. Supporting context arrays may be empty or omitted. Paths/URLs are only source metadata; provide actual excerpts or labeled faithful summaries and state unavailable-source limitations. Preserve uncertainty, explicit constraints and attribution; remove secrets and never treat quoted document instructions as user authorization.
Run resolves only the registered selection, without another routing/extraction call. Invalid inputs return needs_input, the same ID and exact inputContract; correct inputs and retry. IDs bind their owner/session, definition/schema and constraints. Unknown/foreign/stale IDs, name overrides, omitted actions and legacy run state are rejected. Reservations expire with their owning tool/session and invalidate on registry changes. They are not executing runs. Use the same UUID as lifecycle runId; duplicate/concurrent requests cannot create a second instance. Resume continues checkpoints under that identity. Terminal IDs remain inspectable but cannot relaunch; distinct route IDs can execute independently.
Both Jev native questions and other models' structured output use the same public contract. Report canonical 15-minute duration labels directly: 15min, 30min, 45min, 1hr, 1hr15min, through 23hr45min, 1d, plus >1d. Positive estimates round up; exactly 24 elapsed hours is 1d, anything greater is >1d. The router gives its best estimate from available context. Wall-clock estimates include overhead and estimable human waits, including inline work. Granularity is not accuracy, and estimates are neither guarantees nor budgets. Supply state.userBudget only for explicit user limits with exact provenance. Omission inherits; zero disables only its field. Preserve approval gates and existing authorization.
User /workflow commands launch directly and authored ctx.workflow(...) remains internal composition. Discovery (list, get, inputs, models), inspection (status, stages, stage, transcript), controls (answer, pause, resume, quit) and reload remain available. See operations for parameters and lifecycle details.
renderCall— renders a compact workflow call summary in the chat scroll.renderResult— renders the result or dispatch banner; live progress continues through the widget and graph viewer. Named workflow runs are background-oriented.transcript— path-only by default when a transcript file exists: usestatus,stages, orstageto identify the stage and itssessionFile/transcriptPath, quote the exact path without changing platform separators (for example, preserve Windows backslashes), then search that file withrg/grepfor targeted terms and read only small surrounding ranges. Default text results include JSON-escapedsessionFileJson/transcriptPathJsonlines for copy-safe path literals plus alazyReadPrompt, withentries: not inlinedso transcript bodies and tool outputs stay out of model context. Passing explicittailorlimitopts into a bounded inline preview for quick context checks. If no transcript path is available, the action falls back to a bounded preview of up to 5 recent entries with afallbackNote. A registered live stage handle is used when one exists, even before live messages arrive; otherwise the action falls back to stored stage snapshots. Snapshot entries are ordered chronologically beforetail/limitis applied, with terminal result/error entries kept after tool entries when timestamps are missing or tied.includeToolOutputapplies only to inlined snapshot previews or no-path fallback previews; live session transcripts may not expose tool output.answer— answers one pending primitive or structured workflow prompt while the authoritative root is nonterminal. It acceptspromptIdplusresponse,text, ormessage, but never sends stage chat, steers work, resumes a stage, or starts a model turn.
Free-form workflow-stage communication uses ordinary Intercom, not the workflow tool. Send to workflow:<rootRunId>/<segment>[/<segment>...]; * matches one segment and ** any depth. workflow:<rootRunId>/** reaches live stages immediately and remains sticky for every future stage until root termination; narrower name and pattern targets likewise deliver to every future match. Use intercom list inside the invocation group to see live, pending, and possible future targets. Valid paths outside the known set queue with a notInKnownSet warning and settle undeliverable at terminal only if never delivered; use ask only on live targets.
The concise model-facing status listing enumerates pending stages with their display name, canonical ID, literal pending lifecycle, pendingStageDeliveryAvailable value, and an exact Intercom target only when that target is usable on a nonterminal owning run. Retained pending stages on ended root or nested child runs report unavailable delivery and no target, including when a child stage is projected through a live root. Duplicate names remain distinct through canonical IDs. Live send delivery is immediate, a known pending-stage send queues before the first model turn, and ask requires a live reply-capable stage.
reload— refreshes workflow resources directly in-process instead of queuing a literal/workflow reloadchat follow-up.models— returns safe model-catalog metadata from the configured registry. Each entry containsprovider(e.g.openai),id(e.g.gpt-4),fullId(e.g.openai/gpt-4),isCurrent(whether this is the active model), andavailableThinkingLevels, canonically derived from the registry model'sreasoningandthinkingLevelMapmetadata. The result is a configured-auth snapshot: it shows which models are present in the registry with configured authentication, not proof of credentials, entitlements, OAuth freshness, or live provider access. No secrets, tokens, or authentication details are returned.
F2 keyboard shortcut
Press F2 while a workflow is running to open the DAG overlay for the active run.
Execution model
@bastani/atomic/workflows follows Atomic's package/extension model: Atomic loads src/extension/index.ts from the package atomic.extensions manifest, with legacy pi.extensions still supported, then the extension registers the workflow tool, /workflow slash command, renderers, widget, and lifecycle hooks in-process.
For interactive use, run workflows through /workflow <name> [key=value ...] or let the LLM call the workflow tool. In non-interactive (-p / --print / --mode json) sessions, /workflow <name> key=value and LLM calls to the workflow tool remain available for deterministic workflows. The input picker and graph picker are disabled, top-level ctx.ui.* is unavailable, and stage child sessions exclude ask_user_question. Named workflow dispatch waits for the terminal run snapshot before returning.
Because human input is runtime-only and workflows no longer carry a declaration-time HIL marker, headless dispatch does not reject a workflow just because its source contains ctx.ui.*. If you copy the HIL example above into a non-interactive session, it can pass dispatch and then fail when execution reaches the prompt with an error such as atomic-workflows: interactive ctx.ui.confirm is unavailable in headless (non-interactive) mode; run the workflow in interactive mode or remove the interactive prompt from this stage (the primitive name varies, including ctx.ui.custom). Run those workflows interactively, or guard/remove runtime ctx.ui.* calls before using headless mode.
For library or package authoring, import workflow from @bastani/atomic/workflows, import Type from typebox, and export the definition returned by workflow({...}). List @bastani/atomic and typebox as peer dependencies. TypeScript resolves the published workflow SDK and its declarations through @bastani/atomic/workflows. Atomic maps that specifier to its in-memory SDK when it loads the workflow at runtime. Programmatic callers can import run and call run(definition, inputs), or group definitions with createRegistry() and execute the selected definition.
Set heartbeatIntervalMinutes to declare the workflow's heartbeat cadence in minutes. Omitting it uses the 15-minute default; 0 explicitly disables heartbeats. Negative and non-finite values are rejected when the workflow definition is authored. The scheduler consumes this validated setting directly: while a top-level run is active it raises one heartbeat per startedAt + n × interval boundary, computed from the run's persisted start time rather than the previous delivery, so retries and restarts cannot shift the cadence. The example below heartbeats every 30 minutes, so a run started at 09:00 raises boundaries at 09:30, 10:00, 10:30, and so on.
Each heartbeat reaches the parent chat as a workflows:workflow-heartbeat card through the same queued-steer delivery lifecycle notices use (triggerTurn, deliverAs: "steer", persistWhenStreaming), so a busy parent is never interrupted — the steer lands at its next protocol-safe boundary. At most one heartbeat per run is outstanding at a time: it holds its slot until its card is actually consumed into the conversation, not merely until the parent's turn ends, so a boundary that falls before that is skipped rather than stacked behind it and the cadence then resumes at the first future boundary. Parent pickup follows the typed heartbeat entry and releases the slot only for the exact runId + scheduledAt identity; another custom message cannot release it by copying the rendered text. Paused runs emit nothing and are never backfilled, a resumed or restarted run takes the first future boundary rather than bursting missed ones, simultaneous heartbeats process in scheduledAt order with the run id as the tie-break, and a run that reaches a terminal state is suppressed before every delivery attempt.
When a run reaches any terminal status — completed, failed, blocked, skipped, cancelled, or killed — one idempotent cleanup pass drops its wake-up timer, its next boundary, its outstanding slot, any heartbeat of its own still queued inside the scheduler with the retry timer that belonged to it, and its cadence and durable-anchor memos. Repeating the pass creates nothing and resurrects nothing. Because it reads observed state rather than transition events, it is also the recovery pass: a later process discards a stale durable anchor or leftover queued record for an already-terminal run instead of replaying it, and never replays a missed boundary for an active one. A heartbeat the host already accepted into the parent's queue is past that pass, since nothing withdraws a queued message, so it is invalidated when the parent reads it instead: its exact runId + scheduledAt must still be pending for a current nonterminal run. A same-ID durable resume therefore cannot revive an old card, and a heartbeat whose run is terminal or absent from this process is excluded from the model's context. That covers a card parked through a long turn and a card recovered from a previous process alike. The rendered card stays in the transcript as a record of what was raised; only the model-facing steer is invalidated (#1975).
A recoverable provider or rate-limit block is not the terminal blocked status: the run remains stored as running and resumable. It raises no new heartbeat while blocked, but keeps its cadence state and any card already waiting with the parent; cleanup runs only once the run's own status becomes terminal.
import { workflow } from "@bastani/atomic/workflows";
import { Type } from "typebox";
export default workflow({
name: "audit-auth",
description: "Audit the authentication module.",
heartbeatIntervalMinutes: 30,
inputs: {
prompt: Type.String({ default: "Investigate the auth module" }),
},
outputs: {
summary: Type.String(),
},
run: async (ctx) => {
const result = await ctx.task("audit", { prompt: ctx.inputs.prompt });
return { summary: result.text };
},
});
The workflow tool accepts routing (route with state), registered execution (run with workflowId and inputs), discovery, inspection, pending-prompt answers, run control, and reload. Author stage graphs with ctx.task, ctx.chain, and ctx.parallel inside workflow definitions.
For large handoffs, prefer artifact paths over prompt injection: write stage output to output, set outputMode: "file-only" when the parent only needs the path, pass paths with reads, and instruct downstream agents explicitly with wording like Read the file at <path>.... Reserve previous/{previous} for compact summaries; avoid passing full session histories, all prior stage outputs, or every review round directly into the next model prompt. In review loops, save JSON review artifacts and pass only the latest review-round artifact, with a ledger or index file linking older rounds when needed.
Workflow stage sessions follow Atomic SDK directory defaults: DefaultResourceLoader is initialized with the project cwd and the Atomic default ~/.atomic/agent directory, while legacy .pi paths remain readable where the SDK supports multiple config directories. A stage-supplied agentDir is treated as an explicit user override; a stage-supplied resourceLoader owns discovery, with cwd/agentDir left for session naming and tool path resolution.
Custom AgentSessionAdapter runtimes may optionally expose queuedMessagesPaused, pauseQueuedMessages(), and resumeQueuedMessages() on public StageSessionRuntime. Existing adapters that omit them retain the prior fallback pause behavior: the active call is aborted and runner-admitted public deliveries stay deferred until explicit resume. When both methods are present, Atomic uses the stronger native hold so raw queued steer/follow-up work is gated before abort settles and released without starting a provider turn. Atomic's official runtime implements this optional capability.
To inspect a workflow's input schema inside pi, use /workflow inputs <name> or /workflow <name> --help.
Builtin workflows
Six composable pattern workflows
The six common patterns ship as full builtins and are discoverable/runnable by name:
| Workflow | Graph and use | Inputs (defaults) | Parent-consumable outputs |
|---|---|---|---|
classify-and-act | structured classifier → deterministic action; HIL fallback for low confidence | prompt; categories (3 defaults), confidence_threshold=0.75 | result, category/confidence, classification/action paths |
fan-out-and-synthesize | partition → bounded artifact fan-out → evidence synthesis barrier | prompt; max_branches=4, max_concurrency=4 | result, partitions, branch/synthesis/manifest paths |
adversarial-verification | worker → per-criterion fresh rubric verifiers → deterministic mean+veto reducer → findings consolidation / bounded repair; decompose rubrics into named criteria and aggregate by mean plus explicit veto, never a unanimity AND; criteria.md uses ## Criteria with non-empty ### Name {#id} bodies and the shared verification-criteria doors; consolidator cannot approve | task; criteria (record or criteria.md markdown, defaults to task_fit/evidence/completeness), verifier_count=3 (1–5), max_repairs=2 (0–5), accept_mean=14, reask_limit=1; invalid reports are re-asked within the bound and never become fail votes; normal calls per round: criteria.length × verifier_count | approved, mean_score, score_table_path (verification-summary-<round>.json with scores, mean, invalidCount, decision, and usage), repairs_completed, candidate_path, review_report_path, remaining_work |
generate-and-filter | candidate fan-out → dedupe/filter → optional judge → shortlist; use graded per-criterion scores, Bradley–Terry preference from score gaps, and slot-swap repeats when ranking candidates; custom judges can reuse the shared verification-criteria module and criteria.md rubric format | prompt; num_candidates=8, shortlist_size=3, use_judge=true, max_concurrency=4 | result, shortlist and candidate/filter/judge/final/manifest paths |
tournament | independent attempts → seeded ring and pivot-round soft scoring with order-balanced pairwise judges → full ranking/comparisons reducer; derive a Bradley–Terry preference from graded score gaps and repeat with A/B slot swaps; comparisons.json preserves per-job score or invalid rows, pair aggregates, ranking, and planned/executed budget records | prompt; num_attempts=4, max_concurrency=4, n_evaluations=2, pivots=1, seed=0, optional criteria (markdown rubric, record, string list, or CriterionInput list)/models | result, winner, attempt_artifact_paths, judge_artifact_paths, comparisons_path, ranking, seed |
loop-until-done | durable ledger → iteration/evaluator loop → complete or inspectable exhaustion; each scored iteration records progress (score, perRepeat, trend, window) and the ledger emits progress_curve, final_trend, and progress_disclaimer; trend is advisory and never a kill switch | prompt; max_iterations=5, progress_scoring=true, progress_repeats=1 | result/status, ledger, iteration/evaluation paths, remaining work, progress_curve, final_trend, progress_disclaimer |
All six are exported from @bastani/atomic/workflows/builtin as definitions (classifyAndAct, fanOutAndSynthesize, adversarialVerification, generateAndFilter, tournament, and loopUntilDone). Import and nest them through ctx.workflow(definition, { inputs, stageName }); nested calls respect maxDepth. Prefer composition over copying their prompts or graphs: children contribute their stages, dedicated prompts, gates, artifacts, HIL nodes, and declared outputs to the expanded parent graph.
A migration parent can nest all three definitions: fan out the fix pass, independently verify the produced patch set, then run a bounded evidence loop until the repository tests pass:
import { adversarialVerification, fanOutAndSynthesize, loopUntilDone } from "@bastani/atomic/workflows/builtin";
const fixes = await ctx.workflow(fanOutAndSynthesize, {
inputs: { prompt: "Fix every migration call site", max_branches: 6 },
stageName: "migration fixes",
});
const verification = await ctx.workflow(adversarialVerification, {
inputs: { task: `Verify every patch listed by ${fixes.outputs.manifest_path}` },
stageName: "verify migration patches",
});
const convergence = await ctx.workflow(loopUntilDone, {
inputs: {
prompt: `Run the migration test suite and repair remaining failures. Start from ${fixes.outputs.manifest_path}; respect the verification decision at ${verification.outputs.review_report_path}.`,
max_iterations: 5,
},
stageName: "loop while migration tests fail",
});
The parent can consume fixes.outputs, verification.outputs, and convergence.outputs directly, and can repeat the verification child per patch when its own typed input lists individual patch artifacts.
Repository-wide research
For broad repository uncertainty, compose fan-out-and-synthesize with a partition prompt that produces distinct subsystem slices, writes each branch to an artifact, and requires the synthesis barrier to cite concrete paths and resolve conflicting findings. Use /skill:research-codebase when one focused subsystem or question is enough.
/workflow fan-out-and-synthesize prompt="Partition session persistence by subsystem, save cited findings per branch, and synthesize the end-to-end flow"
Task-specific implementation and review
Domain-specific implementation should use a custom worker → fresh verifier → reducer graph when no installed pattern covers the complete contract. Keep literal acceptance criteria visible to each reviewer, execute deterministic checks through workflow-owned tools, consolidate evidence-backed findings into bounded repair rounds, and stop on explicit approval, blocked evidence, or iteration exhaustion. Keep PR/MR creation, release, deployment, and publication as separately authorized post-approval actions.
goal
Goal runs a bounded autonomous implementation loop with a durable objective ledger, immutable acceptance criteria, sub-agent orchestration receipts, parallel reviewers, and a deterministic reducer. It returns complete, blocked, or needs_human; set create_pr=true only to authorize the final PR/MR/review stage after approval.
/workflow goal objective="Update CLI docs, add one example, and validate the docs build"
Inputs: required objective; optional acceptance_criteria, max_turns=10, base_branch=origin/main, git_worktree_dir="", and create_pr=false.
ralph
Ralph runs prompt refinement, codebase research, delegated implementation, and independent multi-model review in a bounded loop. It can start from a task, issue, or spec path; set create_pr=true only to authorize the post-approval final stage.
/workflow ralph prompt="Implement specs/rate-limit.md and validate burst traffic" max_loops=3
Inputs: required prompt; optional acceptance_criteria, max_loops=10, base_branch=origin/main, git_worktree_dir="", and create_pr=false.
For either workflow, keep PR/MR creation out of the task text and pass the original task as acceptance_criteria on follow-up runs to prevent contract drift.
open-claude-design
Combined discovery/init → design-system/reference research → curated reference discovery with user preference check → one generation → one live review session → export/handoff pipeline. The discovery stage asks for output type and references, then runs impeccable init in the same stage so PRODUCT.md/DESIGN.md are detected, created, or reconciled. ds-* stages handle user-provided URL/file reference extraction directly, then reference-discovery uses that context and asks which curated direction you prefer (or asks for a reference image/path/URL if none fit). Export is only exporter plus final-display.
The live session is unbounded: accepted variants and steered edits are written into preview.html in place as you go. A deterministic run-level gate offers Start live review or Skip remaining review rounds and export as-is; the latter exports without opening a session. The workflow-owned poll loop ends on the helper's exit event, and the exporter receives the preview exactly as it stands. There is no second opinion, decision stage, or later review session.
The session starts at user-feedback-N-start; durable live-poll-N-M and live-reply-N-M tool nodes own polling and acknowledgements, while live-generate-*, live-steer-*, live-manual_edit_apply-*, and live-variant_mount_failed-* mint model stages. Replies pass the event id before the status. Successful variant_mounted events are journal-only; accept, discard, and prefetch mint no model stage, and timeout is absorbed in the poll node. Nonzero helper exits fail the run rather than looking like timeouts. No <artifact_dir>/feedback/ directory or per-session record is written.
The poll and reply nodes run the bundled Impeccable 4.3.1 launcher (skills/impeccable/scripts/impeccable, or impeccable.cmd on Windows) with the live-poll verb; the launcher runs the engine binary beside it or downloads and checksum-verifies the pinned engine release on first use, so a machine without network access needs IMPECCABLE_BIN pointed at a preinstalled engine. Live sessions persist the selected app root for every helper and support SvelteKit, Nuxt, TanStack Start, Astro, Next.js, Vite, and static HTML injection.
Research context moves between stages as artifact files, not inline prompt payloads: the project design context is written to <artifact_dir>/design-context.md and the curated references brief to <artifact_dir>/references.md; reference-discovery, generate-1, and exporter read the required files via reads.
/workflow open-claude-design prompt="Design a kanban board component"
| Input | Type | Required | Default | Description |
|---|---|---|---|---|
prompt | text | ✓ | — | Design brief or description. |
discover_references | boolean | — | true | Discover current gallery references with browser tooling; set false to skip. |
Child workflow outputs: output_type, design_system, artifact, handoff, import_context, run_id, artifact_dir, preview_path, preview_file_url, spec_path, spec_file_url, and agent_browser_status. open-claude-design has no result output; it exposes only the declared fields listed here.
Custom workflow discovery
@bastani/atomic/workflows discovers workflow files from project-local paths, user-global paths, configured workflow paths, installed Atomic package resources, and bundled workflows:
| Location | Scope | Example path |
|---|---|---|
.atomic/workflows/*.ts | Project | .atomic/workflows/my-workflow.ts |
~/.atomic/agent/workflows/*.ts | User | ~/.atomic/agent/workflows/my-workflow.ts |
workflows.<name>.path in config | Configured | see config example below |
| Installed Atomic package workflows | Package | atomic.workflows, legacy pi.workflows, or workflows/ / workflow/ directories |
| Bundled workflows | Built-in | shipped with @bastani/atomic/workflows |
Config-based discovery (~/.atomic/agent/extensions/workflow/config.json or .atomic/extensions/workflow/config.json):
{
"workflows": {
"my-team-workflows": { "path": "/shared/team/workflows" }
},
"workflowNotifications": {
"enabled": true,
"notifyOn": ["started", "completed", "failed", "blocked", "awaiting_input", "paused", "quit", "resumed"]
}
}
License
MIT — see LICENSE.
Development: see DEV_SETUP.md for setup, testing, layout, and the local-extension dev loop.
Model reasoning levels
Workflow stage model and fallbackModels strings support suffix-first reasoning levels using the model_name:thinking_effort syntax: append :off, :minimal, :low, :medium, :high, or :xhigh to the model id (for example openai/gpt-5:high or anthropic/claude-haiku-4-5:off). A suffix on a fallback candidate controls only that retry attempt, so fallback chains can mix reasoning levels.
The older thinkingLevel stage option remains accepted as a deprecated default for candidates without a suffix. If both are present, the model suffix wins. Migrate legacy thinkingLevel stages by folding the effort into the model strings:
- model: "openai/gpt-5.5",
- fallbackModels: ["anthropic/claude-opus-4-8"],
- thinkingLevel: "high",
+ model: "openai/gpt-5.5:high",
+ fallbackModels: ["anthropic/claude-opus-4-8:high"],
fallbackThinkingLevels is an optional compatibility helper aligned by index to fallbackModels; it is used only for fallback entries that do not already include a suffix.