Skip to content

Authoring Workflows

This guide explains how to create workflows that orchestrate multiple commands into automated pipelines. Read Authoring Commands first — workflows are built from commands.

A workflow is a YAML file that defines a directed acyclic graph (DAG) of commands to execute. Workflows enable:

  • Multi-step automation: Chain multiple AI agents together
  • Parallel execution: Independent nodes run concurrently
  • Conditional branching: Route to different paths based on node output
  • Artifact passing: Output from one node becomes input for downstream nodes
  • Iterative loops: Loop nodes repeat until a declared completion condition is met
name: fix-github-issue
description: Investigate and fix a GitHub issue end-to-end
nodes:
- id: investigate
command: investigate-issue
- id: implement
command: implement-issue
depends_on: [investigate]
context: fresh

Bundled workflows as templates: Archon bundles the sdlc pack from .archon/workflows/sdlc/ in its repository (compiled into the binary; source builds also load it from disk). Browse it for real-world examples, then copy and modify. Copy the whole pack folder: its workflows include each other and share modules in sdlc/.shared/.

Terminal window
cp -R /path/to/Archon/.archon/workflows/sdlc .archon/workflows/

Same-named workflows in .archon/workflows/ override the bundled ones. Bundled commands live inside each workflow’s own commands/ folder; there is no shared bundled command directory.


Workflows live in .archon/workflows/ relative to the working directory:

.archon/
├── workflows/
│ └── my-pack/ # Author-chosen pack name
│ └── release/ # Author-chosen workflow folder
│ ├── release.yaml
│ ├── commands/
│ │ └── prepare.md
│ └── scripts/
│ └── publish.ts

The two directories form a fixed package boundary: .archon/workflows/<pack>/<workflow>/. A packaged workflow contains exactly one YAML definition; bare command: and named script: references resolve only from its own commands/ and scripts/ directories, with no shared or cross-scope fallback. Included workflows retain their own resource folder, so two workflows may reuse names such as review.md without collisions.

Scripts from different workflows in one pack can import modules from <pack>/.shared/. That directory is reserved for modules and .md documents, not workflow definitions or script: targets. Bun and Python use the same pack-relative layout in source checkouts and binary builds. See Share code within a pack for import examples.

The same tree works under ~/.archon/workflows/ for home-scoped workflows. Existing flat .archon/workflows/foo.yaml, one-level grouped YAML, shared .archon/commands/, and shared .archon/scripts/ remain supported for compatibility.

Global workflows: For workflows that apply to every project, place them in ~/.archon/workflows/. Global workflows are overridden by same-named repo workflows. See Global Workflows.

CLI vs Server: The CLI reads workflow files from wherever you run it (sees uncommitted changes). The server reads from the workspace clone at ~/.archon/workspaces/owner/repo/, which only syncs from the remote before worktree creation. If you edit a workflow locally but don’t push, the server won’t see it.


Workflows use DAG-based execution with nodes:. Each node runs a command or inline prompt, declares dependencies, and supports conditional branching:

name: classify-and-fix
description: Classify issue type, then run the appropriate fix path
nodes:
- id: classify
command: classify-issue
output_format:
type: object
properties:
type:
type: string
enum: [BUG, FEATURE]
required: [type]
- id: investigate
command: investigate-bug
depends_on: [classify]
when: "$classify.output.type == 'BUG'"
- id: plan
command: plan-feature
depends_on: [classify]
when: "$classify.output.type == 'FEATURE'"
- id: implement
command: implement-changes
depends_on: [investigate, plan]
trigger_rule: none_failed_min_one_success

Nodes without depends_on run immediately. Nodes in the same topological layer run concurrently via Promise.allSettled. Skipped nodes (failed when: condition or trigger_rule) propagate their skipped state to dependants.

Note: The steps: (sequential) format has been removed. All workflows use nodes: (DAG) format exclusively.


# Required
name: workflow-name
description: |
What this workflow does.
# Optional workflow-level configuration
provider: claude
model: sonnet
effort: medium # Reasoning depth on any provider that has one
webSearchMode: live # Codex only, workflow level only (no per-node form)
requires: [github] # Optional: hard-block invocation unless the triggering
# user has connected their GitHub identity. Enforced only
# when per-user GitHub is enabled (App mode + TOKEN_ENCRYPTION_KEY);
# a no-op for solo PAT / bot-only installs. The block fires
# BEFORE any worktree/clone/AI cost. Currently the only
# supported value is `github`; unknown values are rejected
# at load time.
worktree: # Optional: pin isolation behavior regardless of caller
enabled: false # false = always run in the live checkout (CLI --no-worktree
# and web both honor it). Use for read-only workflows
# like triage/reporting. true = must use a worktree;
# CLI --no-worktree hard-errors. Omit to let the
# caller decide (current default = worktree).
mutates_checkout: false # Optional: assert this workflow does not write to its checkout,
# so the engine skips the path-exclusive lock and N runs of it
# can share one working directory. Defaults to true (take the
# lock, serialize runs on the same path). See
# [Running sub-runs side by side](#running-sub-runs-side-by-side).
budget: # Optional: ceiling on the run's TOTAL work and spend (not
max_work_units: 20 # concurrency). The engine refuses to start new work once a
max_spend_usd: 5 # ceiling is reached. Declare either or both. See
# [Run budgets](#run-budgets).
tags: [GitLab, Review] # Optional: explicit Web UI filter tags. Overrides the
# keyword-based tag inference. An empty list (`tags: []`)
# suppresses inference and shows no tags. Omit to fall
# back to inferred tags (the default).
inputs: # Optional: declared signature — what this block takes.
diff: { required: true } # A caller supplies values via `with:`; the block reads
style: { default: strict } # them as `$INPUTS.<name>`. See "Workflow Signature".
returns: synthesize # Optional: the node id whose output IS this block's result.
# outcome_field: ready # Optional: a required boolean property on `returns:` whose
# exact value is persisted as the authored run outcome.
# deprecated: # Optional: marks the workflow deprecated (#2781). Every run
# message: Use <pack> instead. # start announces removal in an upcoming release with this
# message plus the copy-to-project/global escape hatch.
# Metadata-only — execution is unchanged. Bundled defaults
# carry it during a deprecation window; author your own copy
# (project or global `.archon/workflows/`, same filename) without
# it to silence the notice.
# Required for DAG-based
nodes:
- id: classify # Unique node ID (used for dependency refs and $id.output)
command: classify-issue # Package-local in packaged workflows; shared lookup otherwise
output_format: # Optional: structured JSON output. SDK-enforced on Claude/Codex/OpenCode; best-effort (prompt + JSON extraction + repair) on Pi/Copilot. Parsed output is validated against the schema; a node that declares output_format but returns no schema-valid output FAILS.
type: object
properties:
type:
type: string
enum: [BUG, FEATURE]
required: [type]
- id: investigate
command: investigate-bug
depends_on: [classify] # Wait for classify to complete
when: "$classify.output.type == 'BUG'" # Skip if condition is false
- id: plan
command: plan-feature
depends_on: [classify]
when: "$classify.output.type == 'FEATURE'"
- id: implement
command: implement-changes
depends_on: [investigate, plan]
trigger_rule: none_failed_min_one_success # Join successful and condition-skipped branches
- id: inline-node
prompt: "Summarize the changes made in $implement.output" # Inline prompt (no command file)
depends_on: [implement]
context: fresh # Force fresh session for this node
provider: claude # Per-node provider override
model: haiku # Per-node model override
# hooks: # Optional: per-node SDK hook callbacks (Claude only) — see hooks guide
# mcp: .archon/mcp/servers.json # Optional: per-node MCP servers (Claude/Codex/Copilot)
# skills: [remotion-best-practices] # Optional: per-node skills (Claude/Pi/Copilot); Codex uses explicit $skill-name

Node types — exactly one required per node (mutually exclusive):

FieldTypeDescription
commandstringCommand name. Packaged workflows resolve it only from their own commands/; legacy workflows use shared repo → home → bundled lookup. Optional with: binds upstream values by name into the file’s $INPUTS.<name> surface — see Binding values into command and script nodes
promptstringInline prompt string
bashstringShell script (no AI). Stdout captured as $nodeId.output; successful stdout is also stored in node_completed.data.node_output as an audit preview capped at 32 KiB (UTF-8 bytes). Optional timeout (ms; without one the node runs until it exits); on_timeout: skip makes a timeout skipped instead of failed
scriptstringTypeScript/JavaScript (via bun) or Python (via uv) — inline code or named reference. Packaged workflows resolve named scripts only from their own scripts/; legacy workflows use shared script directories. Stdout captured as $nodeId.output. Requires runtime: bun or runtime: uv. Optional deps (uv only) and timeout (ms; without one the node runs until it exits); on_timeout: skip makes a timeout skipped instead of failed; optional with: binds upstream values by name into INPUTS_<UPPER_SNAKE> env vars — see Binding values into command and script nodes. See Script Nodes
loopobjectIterative AI prompt until a declared completion condition is met. See Loop Nodes
loop_groupobjectMulti-node sub-DAG body repeated per iteration until a declared completion condition is met. See Cross-Node Loops
approvalobjectDeprecated. Use wait: { attention: <message>, form: approval } instead. See Migrating from approval gates
waitobjectDurably pauses the run until a time, bounded external event, or an answer to an attention wait. See Durable waits
cancelstringTerminates the workflow run with a reason string. Uses existing cancellation plumbing — in-flight parallel nodes are stopped
includestringName of another workflow whose nodes are inlined into this DAG at load time as a namespaced sub-DAG. Optional with: (named inputs → the block’s $INPUTS.<name>) and, to fan the composed body out over a runtime list inside this run, fan_out — see Composing Another Workflow and Fanning out a composed block
workflowstringName of another workflow to run as a governed child sub-run at execution time — its own run record, gates, artifacts, and cost. Optional input (untyped data string → child’s $ARGUMENTS) or with: (named inputs → child’s $INPUTS.<name>; mutually exclusive with input), isolation ('inherit' | 'worktree'), and fan_out (one child per item of a runtime list; optional as: names the per-item $INPUTS channel). See Launching a Separate Governed Run and Workflow Signature

Common fields — apply to all node types:

FieldTypeDefaultDescription
idstringrequiredUnique node identifier. Used in depends_on, when:, and $id.output substitution
depends_onstring[][]Node IDs that must complete before this node runs
whenstring—Condition expression. Node is skipped if false. See Condition Syntax
trigger_rulestringall_successJoin semantics when multiple upstreams exist. Distinct from a fan-out node’s fan_out.join, which reduces one node’s N children and defaults to all_done
context'fresh' | 'shared' | { resume: node-id }—fresh = new session; shared = inherit the ambient prior session in a sequential layer; resume = fork the exact completed upstream node’s session. Parallel layers require named resume or fresh context
idle_timeoutnumber—Kill node after this many milliseconds without a provider chunk. Any chunk counts as progress, thinking included. Reported live background work suspends the timer
retryobject—Per-node retry configuration. See Retry Configuration
mutates_checkoutboolean—false asserts this node leaves the git checkout untouched. The engine compares git status before and after the node and fails it, naming the changed paths, if anything outside the run’s artifacts, state, and log directories changed. Enforced on command, prompt, bash, and script nodes; the check is skipped outside a git repository. A layer whose parallel nodes all declare it still runs in parallel, and a violation there also names the guarded siblings that ran in the same layer, since the change may not be the failing node’s alone. Since one snapshot covers the whole checkout, every guarded node running when the write lands fails, not only the writer. A layer that mixes guarded nodes with any node that isn’t checkout-guarded (a node of another kind, or one without mutates_checkout: false) runs one node at a time, so a sibling’s write is never blamed on a guarded node. Distinct from the workflow-level mutates_checkout, which controls the path lock
always_runbooleanfalseNot a scheduling field: it does not change whether the node runs in a normal pass (use when: and trigger_rule for that). It opts the node out of the resume cache, so on resume it is evaluated again instead of replaying a prior completion, and still runs only if its when: and trigger_rule allow it. See Opting Out of Resume Caching
output_typestring—Semantic label for this node’s output (e.g. 'plan', 'findings', 'code'). When set, the executor writes a typed output + metadata pair after the node completes (best-effort). Top-level nodes use $ARTIFACTS_DIR/nodes/<id>.md + <id>.meta.json; loop-body executions use iteration-specific paths.

AI node options — apply to command and prompt nodes:

FieldTypeDefaultDescription
providerstringinheritedPer-node provider override (any registered provider, e.g. 'claude', 'codex')
modelstringinheritedPer-node model override
output_formatobject—JSON Schema for structured output. SDK-enforced on Claude/Codex/OpenCode; best-effort on Pi/Copilot (schema appended to prompt, JSON extracted + repaired). The parsed output is validated against the schema (every provider); a node that declares output_format but returns no schema-valid output fails rather than degrading silently. Also valid on bash:/script: nodes, where the node’s own stdout is the payload being certified — see Result contracts.
allowed_toolsstring[]—Whitelist of built-in tools. [] = no tools. All providers except Codex
denied_toolsstring[]—Tools to remove. Applied after allowed_tools. All providers except Codex
hooksobject—Per-node SDK hook callbacks. Claude only. See Hooks
mcpstring | { path, optional }—Path to MCP server config JSON file: checkout first, then the declaring home workflow YAML’s directory (its captured copy for captured runs). Other scopes use only the checkout. Claude/Codex/Copilot. Any other provider fails the run before it starts, unless the declaration is { path: ..., optional: true }, which runs the node without the servers when the provider lacks MCP or the file is missing. See MCP Servers
skillsstring[]—Exact Claude-native skill selection (omission/[] selects none); skill declarations for Pi/Copilot. Codex workflow commands/prompts invoke installed skills explicitly with $skill-name instead; a non-empty list on Codex or OpenCode fails the run before it starts. See Skills
pluginsstring[]—Exact plugin ids (name@marketplace) the node loads; omission/[] loads no user-installed plugin. Claude and Codex; any other provider fails the run before it starts. See Plugins
agentsobject—Inline sub-agent definitions keyed by kebab-case ID. Claude only. See Inline sub-agents
effort'minimal'|'low'|'medium'|'high'|'xhigh'|'max'|'ultra'|'persistent'—Reasoning depth. Every provider with a request-level reasoning control — Claude/Codex/Pi/Copilot. Codex accepts all eight; the others clamp unsupported rungs down to the nearest weaker value. OpenCode configures reasoning in opencode.json. Also settable at workflow level
maxBudgetUsdnumber—USD cost cap; node fails if exceeded. Claude only. Per-node only. For a ceiling across the whole run, see Run budgets
systemPromptstring—Set system instructions for this node (Claude/Codex/Pi/Copilot/OpenCode). Replaces the default claude_code prompt on Claude. Per-node only
fallbackModelstring—Model to use if primary model fails. Claude only. Also settable at workflow level
betasstring[]—SDK beta feature flags (e.g., 'context-1m-2025-08-07'). Claude only. Also settable at workflow level
sandboxobject—OS-level filesystem/network restrictions for the Claude subprocess. Claude only. Also settable at workflow level
settingSources('project'|'user')[]inheritedWhich filesystem setting sources a capable provider discovers (for Claude: CLAUDE.md, skills, commands, agents). Workflow skills: remains the exact active skill set. Overrides the assistant-level default; unset everywhere = ['project', 'user']. [] loads none. Requires the provider’s settingSources capability. Per-node only

When a bash: or script: node on the host reaches its timeout, Archon signals the node’s whole process tree before it reports the timeout, so the real work behind a wrapper such as bun run or uv run stops too. On Windows it runs taskkill /T /F on the node’s process. On macOS and Linux archon serve and a foreground workflow run, workflow resume or trigger execute start the node in its own process group and send SIGTERM to that group. A node of a detached run shares its owner’s process group, so on macOS and Linux its timeout still ends only the node’s own process, and work it started in the background keeps running. until_bash checks end only their own process. A container node’s timeout ends only the local docker exec client, so the command inside the container may keep running.

Archon does not confirm that the tree is gone, so some work outlives a reported timeout:

  • On macOS and Linux, a process that ignores or handles SIGTERM without exiting. Archon sends no SIGKILL after it. When that process is the node’s own process, the node does not report the timeout until the process exits.
  • On macOS and Linux, a process that left the node’s group, for example with setsid.
  • On every OS, background work still running when the node’s own process had already exited before the deadline. Archon does not signal a tree whose root has exited.

If the tree cannot be ended, Archon kills the node’s own process (SIGKILL on macOS and Linux) and the node fails with the termination error instead of timing out. It does not take on_timeout: skip, and dependents follow their trigger_rule as for any failed node.

Cancelling a run that archon serve executes ends the process tree of each of that run’s running host bash: and script: nodes and until_bash checks within about a second (SIGKILL to the node’s process group on macOS and Linux, taskkill /T /F on Windows), and records the node failed as cancelled. Other runs’ processes are not touched. A process that left the node’s group with setsid escapes. On Windows, background work whose node process already exited is not reached. A container node’s command is not reached. Cancelling a detached run ends its owner’s process group, nodes included.

Dependency edges determine execution and $node.output carries data. When a later command or prompt must continue one particular AI conversation instead of the latest ambient session, name that completed upstream node:

provider: claude
nodes:
- id: scope
prompt: Scope the work
- id: lens-a
prompt: Review one aspect of $scope.output
context: fresh
depends_on: [scope]
- id: lens-b
prompt: Review another aspect of $scope.output
context: fresh
depends_on: [scope]
- id: synthesize
prompt: Synthesize $lens-a.output and $lens-b.output
context:
resume: scope
depends_on: [lens-a, lens-b]

synthesize forks the exact provider session produced by scope; the parallel lenses do not change that ancestry. The source must be a transitively upstream command, prompt, or plain loop: node, and the consumer must be a command or prompt node. Inside a loop_group body, the source must complete before entry into the group: name an enclosing command, prompt, or plain loop: ancestor, never a same-body node. The first owner turn forks that source; later iterations continue the private branch with the same session ID. Fresh reviewers and fresh_context: true leave that branch intact. Terminal body attention waits and other durable waits, and the deprecated interactive gates and terminal body approvals, restore its provider-tagged cursor. A missing cursor snapshot, lost session, or changed session ID fails rather than silently starting over. Failure resume still restarts the group at iteration 1. See Named owner sessions for an example.

Scalar context: shared is only for ambient session threading through a sequential layer. It is rejected on a node in a structurally parallel layer because there is no single unambiguous ambient lineage there. Use named resume as above, or add dependencies to serialize the nodes. This remains a structural rule when sibling when: conditions appear mutually exclusive: Archon constructs the parallel layer before evaluating those conditions.

This is an exact, immutable fork contract:

  • Source and consumer must resolve to the same provider.
  • Claude, Codex and Pi support immutable forks. A provider that omits the fork capability does not.
  • A missing source handle, unavailable prior context, missing branch handle, or provider that reuses the source session fails the node. Named resume never falls back to a fresh session.
  • Two parallel consumers may name the same source; each receives its own branch while the source remains unchanged.
  • Run resume restores these private handles for completed nodes, so a pause or process restart does not lose declared ancestry. Each node’s full session ID is recorded on its node record; transcripts and logs carry at most an eight-character preview.

This is separate from persist_session: { resume: source } selects ancestry within one governed run, while persist_session continues the same node across separate workflow invocations. If both apply to a consumer, the named source wins for the current invocation and the resulting branch is still saved for its next invocation.

Most of these fields map directly to Claude Agent SDK options. maxBudgetUsd, fallbackModel, betas, and sandbox are Claude-only — Codex and other providers emit a warning and ignore them. settingSources applies to providers declaring that capability, including Claude and capable plugins. systemPrompt sets per-node system instructions on Claude, Codex, Pi, Copilot and OpenCode; Claude replaces its claude_code preset, Codex includes the instructions in the user prompt, and Copilot appends them to its foundation prompt. effort also applies across providers: it is the one reasoning-depth spelling and applies on every provider that has a request-level reasoning control (Claude, Codex, Pi, Copilot), each translating it to its own SDK control. OpenCode has no request-level control — it configures reasoning in opencode.json — so effort: there warns and is ignored. These settings can be set per-node or at the workflow level as defaults (per-node takes precedence). maxBudgetUsd, systemPrompt, and settingSources are per-node only (settingSources also has an assistant-level default in .archon/config.yaml).

effort — reasoning depth:

- id: thorough-review
command: review
effort: high # minimal | low | medium | high | xhigh | max | ultra | persistent

The ladder is the union of every provider’s vocabulary. Codex accepts all eight rungs. Other providers clamp an unsupported rung to the nearest weaker value; only when no weaker value exists do they use the shallowest stronger value. For example, persistent and ultra become max on Claude, Pi, and Copilot, while minimal becomes low on Claude and Copilot.

thinking: has been removed. A workflow, node, tier, or alias that still uses it fails validation with an error directing the author to effort:.

maxBudgetUsd — per-node USD cost cap (node fails with error if exceeded):

- id: expensive-step
command: generate
maxBudgetUsd: 2.50

systemPrompt — override the default claude_code system prompt:

- id: security-review
prompt: "Review this code for vulnerabilities"
systemPrompt: "You are a security expert specializing in TypeScript. Focus only on security issues."

fallbackModel — use a different model if the primary fails:

- id: implement
command: implement
model: claude-opus-4-5
fallbackModel: claude-sonnet-4-6

betas — SDK beta feature flags:

- id: long-context-node
command: summarize
betas: ['context-1m-2025-08-07']

sandbox — OS-level filesystem/network restrictions (layers on top of worktree isolation):

- id: untrusted-code-analysis
command: analyze-external
sandbox:
enabled: true
network:
allowedDomains: []
allowManagedDomainsOnly: true
filesystem:
denyWrite: ['/etc', '/usr']

settingSources — control which filesystem setting sources a capable provider discovers. For Claude, these include project CLAUDE.md/.claude/ skills, commands, agents vs the user-level ~/.claude/. For workflow skills, this controls eligibility rather than activation: skills: selects the exact active set, and a declaration must exist under an enabled source. Loading fewer sources gives a leaner context and a faster node start — a lean reviewer node can skip project context entirely while a writer node in the same workflow keeps it:

- id: lean-review
command: review
settingSources: [] # no setting sources; skills must be omitted or []
- id: implement
command: implement
settingSources: ['project'] # project sources only, skip ~/.claude/

Omitting the field inherits the assistant-level assistants.<provider>.settingSources from .archon/config.yaml; if that is also unset, the default is ['project', 'user']. Providers without the settingSources capability warn and ignore the field. Validation checks host skill paths; a skill found only in a host user directory may be unavailable in container execution. Provider preflight checks the run’s actual scope.

A Claude workflow node loads none of the Claude Code plugins installed on the machine: no plugin subagents, hooks, LSP servers, commands, skills or MCP servers. Your own settings, hooks, permissions and CLAUDE.md still load, and so do the plugins built into Claude Code (@builtin). A node that needs a plugin names it by its exact id, as claude plugin list prints it:

- id: triage
prompt: Find the noisiest error groups from the last day.
plugins: [posthog@claude-plugins-official]
skills: [posthog:investigating-error-issue] # a plugin's skills are still selected one by one
mcp: .archon/mcp/posthog.json # and its MCP servers are declared like any other

A named plugin brings its agents, hooks, LSP servers and commands. Its skills reach the node only when skills: names them, and its MCP servers only through mcp: (see MCP Servers).

The node fails before its first model turn when a named plugin is not installed, or when Claude Code reports a loaded plugin the node did not name, for example one synced from claude.ai or loaded by another route.

A Codex workflow node works the same way. It loads none of the user’s Codex plugins and no ChatGPT apps, while the user’s config.toml settings, hooks and AGENTS.md still load. A node names a plugin by the id codex plugin list prints, such as github@openai-curated. A named Codex plugin brings its skills, which the node body invokes as $<plugin>:<skill>, and its hooks. Its MCP servers reach the node only through mcp: (see MCP Servers), and its apps stay off. A named plugin is turned on for the node even if you turned plugins off globally in Codex. The node fails before its turn starts when a named plugin is not installed in the Codex home in use.

plugins: on a provider that cannot load exactly the named plugins fails the run before any node starts, and archon validate workflows reports it as an error. A required mcp: and a non-empty skills: follow the same rule on a provider without that capability. An mcp: declared optional: true runs the node without its servers instead and records why (see Optional Servers).

Workflow-level defaults (inherited by all Claude nodes unless overridden per-node):

name: my-workflow
effort: high # All Claude nodes use high effort by default
fallbackModel: claude-haiku-4-5-20251001
betas: ['context-1m-2025-08-07']
sandbox:
enabled: true
nodes:
- id: step1
command: step1
# Inherits workflow-level effort, fallbackModel, betas, sandbox
- id: step2
command: step2
effort: low # Per-node override — ignores workflow-level effort
ValueBehavior
all_successRun only if all upstream deps completed successfully (default)
one_successRun if at least one upstream dep completed successfully
none_failed_min_one_successRun if at least one dependency succeeded and none failed or skipped because of an upstream failure (upstream_failed)
all_doneRun when all deps are in a terminal state (completed, failed, or skipped)

none_failed_min_one_success blocks failure-cascade skips by default, including across dependency chains and includes. The skipped join retains the original failed node in its upstream_failed cause. Condition skips and optional timeout skips (on_timeout: skip) remain admissible when another dependency succeeds.

A dependency skipped by its own when: provides neither a success nor a failure. When a none_failed_min_one_success node has a single dependency and that dependency has a when:, the node skips every time the condition is false. The loader warns about this shape, naming the node; both archon validate workflows and run loading report the warning. Joins over several conditional dependencies are not flagged, since they are usually branches where one always runs.

To run after an optional gate, also depend on an unconditional node that runs before it. That node’s success satisfies the success requirement when the gate is condition-skipped, while a failed gate still blocks the join:

name: optional-gate
description: Run the next step whether the optional gate ran or skipped
nodes:
- id: review
bash: echo ready
- id: gate
depends_on: [review]
when: "$review.output != 'ready'"
wait:
attention: "Review found issues. Approve to continue."
form: approval
- id: stop
depends_on: [gate]
when: "$gate.output.decision == 'reject'"
cancel: "Review issues were not approved"
- id: next
depends_on: [review, gate, stop]
trigger_rule: none_failed_min_one_success
bash: echo continue

A rejection is an answer, so gate completes either way. next also depends on stop, which cancels the run on a rejection before next can start.

Use all_done instead only when the next step should run even after the gate fails.

all_success, one_success, and all_done keep their existing behavior. if_skipped supplies a value for a skipped output binding; it does not make a blocked node eligible to run or permit binding a failed output.

Conditions gate whether a node runs based on upstream node outputs.

String operators (value compared as string):

when: "$nodeId.output == 'VALUE'"
when: "$nodeId.output != 'VALUE'"
when: "$nodeId.output.field == 'VALUE'" # JSON dot notation for output_format nodes

Numeric operators (the right-hand side must be a number; a non-numeric output at run time skips the node):

when: "$nodeId.output > '80'"
when: "$nodeId.output >= '0.9'"
when: "$nodeId.output < '100'"
when: "$nodeId.output <= '5'"
when: "$nodeId.output.score >= '0.9'" # dot notation + numeric comparison

Compound expressions (&& binds tighter than ||):

when: "$a.output == 'X' && $b.output != 'Y'"
when: "$a.output == 'X' || $b.output == 'Y'"
when: "$score.output > '80' && $flag.output == 'true'"
# Precedence: (A && B) || C
when: "$a.output == 'X' && $b.output == 'Y' || $c.output == 'Z'"

To express A && (B || C), expand it to A && B || A && C:

when: "$a.output == 'x' && $b.output == 'y' || $a.output == 'x' && $c.output == 'z'"

Declared inputs (see Workflow Signature):

when: "$INPUTS.mode == 'fast'" # branch on a caller's `with:` value
when: "$INPUTS.mode == 'fast' && $check.output.ok == 'true'"

Previous loop iteration — only in a loop_group body:

when: "$LOOP_PREV.run-tests.output.verdict == 'red'"

$LOOP_PREV.<nodeId>.output[.field] reads that body node’s typed output from the previous iteration; it is a condition reference, not text substitution. On iteration 1, there is no prior output, so it resolves to '' and the non-empty equality above is false.

  • $nodeId.output references the full output string of a completed node
  • $nodeId.output.field accesses a JSON field (for output_format nodes)
  • $nodeId.output.a.b accesses nested object fields, checked against every declared schema segment. Paths stop at arrays; indexing, wildcards, and expressions are unsupported.
  • A field used in a scalar condition must resolve to a string, number, boolean, or null. A present object or array fails the gated node; expose a scalar decision field or inspect structured data in a script node.
  • $INPUTS.<name> references a declared input supplied by a caller’s with: (or a direct run’s --input). A name this run does not carry fails the node — it never quietly becomes an empty string. INPUTS is a reserved scope: a node cannot be given that id (the loader rejects it), so $INPUTS.x always means an input.
  • Invalid or unsupported expression syntax is rejected when workflows load and by archon validate workflows.
  • A numeric operator (<, >, <=, >=) needs a number on the right-hand side, quoted or bare; any other literal is rejected at load. If the left-hand value is not a number at run time, the node is skipped (fail-closed).
  • Grouping parentheses are not supported. Use AND/OR precedence or compute a decision in a script: node and gate on its scalar output.
  • Skipped nodes propagate their skipped state to dependants
  • A failed producer’s output is never evaluated, fielded or whole-text — a when: joined past a failure via trigger_rule: all_done must branch on a different node’s output; referencing the failed producer’s own output fails the condition’s node loudly (see Reading across a skipped branch)

In node prompts and commands, reference the output of any upstream node:

nodes:
- id: classify
command: classify-issue
- id: fix
command: implement-fix
depends_on: [classify]
# The command file can use $classify.output or $classify.output.field

Standard variables ($WORKFLOW_ID, $USER_MESSAGE, $ARTIFACTS_DIR, etc.) and node output references resolve in one pass over the template. A substituted value is inserted as written and never scanned again, so a message or an upstream output that quotes $nodeId.output reaches the node unchanged. See Substitution Order.

Output references can follow nested object fields. For example, this wait asks Proposed: add when review emits {"proposal":{"action":"add"}}:

nodes:
- id: review
prompt: Propose an action
output_format:
type: object
properties:
proposal:
type: object
properties:
action: { type: string }
required: [action]
required: [proposal]
- id: gate
depends_on: [review]
wait:
attention: "Proposed: $review.output.proposal.action"
when: "$review.output.proposal.action == 'add'"

A whole-value binding such as with: { proposal: "$review.output.proposal" } preserves its object value. $LOOP_PREV.review.output.proposal.action uses the same path rules for a prior loop iteration (empty on the first iteration). Paths contain object field names only: no array indexing, wildcards, or expressions. when: still requires a scalar, and $node.field shorthand cannot nest.

A reference to a failed producer — fielded or whole-text — fails the node doing the substitution instead of splicing in the failed producer’s leftover output; a bash:/ prompt:/command: body must not assume a dependency succeeded just because it was allowed to run (trigger_rule: all_done).

Use output_format to enforce JSON output from an AI node. For Claude, the schema is passed via the SDK’s outputFormat option and structured_output is used directly. For Codex, the schema is sent as the turn’s outputSchema and the agent’s final JSON message is used. Both ensure clean JSON for when: conditions and $nodeId.output substitution:

archon validate workflows warns when an enforced object schema declares properties but has neither a non-empty required array nor additionalProperties: false. Such a schema can accept missing fields or a schema-echo response, leaving downstream field references empty and skipping gated work. Require the fields downstream nodes need; use additionalProperties: false to reject undeclared fields, and string enum values to constrain decisions. The warning leaves runtime semantics unchanged: intentionally optional fields and open-record maps remain supported. Codex strict-mode errors take precedence over this warning.

Codex strict-mode normalization. OpenAI’s Structured Outputs validator rejects any object schema that doesn’t set additionalProperties: false. Archon normalizes Codex schemas before sending them, injecting additionalProperties: false on every object node automatically — so write portable schemas and you won’t notice. One caveat: an open-record additionalProperties: { type: 'string' } (or additionalProperties: true) is replaced with false, closing the object. OpenAI would reject the open form regardless, but the rewrite is logged (codex.output_format_open_record_closed) so it isn’t silent. Open-record maps aren’t supported for Codex structured output.

Codex strict-mode object schemas. OpenAI’s Structured Outputs validator rejects an object schema that omits properties, or where a key declared in properties is absent from required. Unlike additionalProperties, Archon does NOT normalize these cases for you: it cannot infer a bare object’s intended shape, and filling required would silently change an optional field into a required one. Instead, the launch preflight and archon validate workflows report the node and exact schema path before any run starts. Declare every object’s properties and include every property key in required. To express an optional value, give its type an absent-value form: a ["string","null"] or ["object","null"] union, or an enum with a sentinel like "none" — the field is then always “present” in the output, carrying the absent value when not applicable.

nodes:
- id: classify
command: classify-issue
output_format:
type: object
properties:
type:
type: string
enum: [BUG, FEATURE]
severity:
type: string
enum: [low, medium, high]
required: [type, severity]
  • The output is captured as a JSON string and available via $classify.output (full JSON) or $classify.output.type (field access)
  • Use output_format when downstream nodes need to branch on specific values via when:
  • Validated + reask + fail-fast. The parsed output is validated against your schema for every provider (a net for refusals / max_tokens truncation that bypass even SDK enforcement). On a miss, best-effort providers (Pi/Copilot) re-ask up to 3× with the schema errors appended; enforced providers fail immediately. A node that declares output_format but still has no schema-valid output fails — it no longer completes-with-prose and silently feeds '' downstream.
  • Field access is strict at every depth. Each segment in $classify.output.details.type must be an explicit properties entry. An undeclared segment, at any depth, fails workflow validation when the local schema is known, and fails the consumer at runtime otherwise. Arrays, additionalProperties, $ref, and schema combinators do not authorize nested properties. Declared missing/null fields or parents resolve to '', after the entire path is checked. A schemaless bash:/script: producer must emit every nested key and every intermediate value must be an object. Child workflow results carry the selected producer’s declared paths through completion and resume; a child result recorded before nested paths carries only its top-level fields, so a nested read from it fails as undeclared until the producer reruns. See node output references.

output_format is not AI-only, and it is not only a branching aid: it is how any producing node declares the shape of the value it hands downstream, including a workflow’s own result. Result contracts is the one description of that ownership — who declares a schema, what an include: alias and a workflow: sub-run each guarantee, and how a small result points at a large file.

allowed_tools and denied_tools for Tool Restrictions

Section titled “allowed_tools and denied_tools for Tool Restrictions”

Restrict which built-in tools a node can use without relying on prompt instructions. Restrictions are enforced at the Claude SDK level.

nodes:
- id: review
command: code-review
allowed_tools: [Read, Grep, Glob] # whitelist — only these tools available
- id: implement
command: implement-feature
denied_tools: [WebSearch, WebFetch] # blacklist — remove these tools
- id: mcp-only
command: mcp-command
allowed_tools: [] # empty list = disable all built-in tools
  • allowed_tools: [] disables all built-in tools (useful for MCP-only nodes). Use the mcp field on a node to attach per-node MCP servers — see Node Fields
  • If both are set, denied_tools is applied after allowed_tools
  • undefined (field absent) and [] have different semantics — absent means use default tool set, [] means no tools
  • Supported on all providers except Codex — Codex nodes/steps emit a warning and continue (Codex doesn’t support per-call tool restrictions)

Define Claude sub-agents directly in the workflow YAML, without authoring .claude/agents/*.md files. The main agent can spawn them in parallel via the Task tool — useful for map-reduce patterns where a cheap model (e.g. Haiku) briefs items and a stronger model reduces.

nodes:
- id: triage
prompt: |
Fetch open issues via `gh issue list ...`. For each issue, spawn the
brief-gen sub-agent in parallel (one message, multiple Task tool calls)
to produce a 2-3 sentence brief. Then cluster briefs for duplicates.
model: sonnet
allowed_tools: [Bash, Read, Write, Task]
agents:
brief-gen:
description: Summarises a single GitHub issue in 2-3 sentences
prompt: |
You are concise. Read the issue provided in the caller's prompt.
Return JSON { summary, primarySymptom, affectedArea }.
model: haiku
tools: [Bash, Read]

Keys:

  • Agent IDs must be kebab-case (^[a-z0-9]+(-[a-z0-9]+)*$)
  • Each definition requires description and prompt; model, tools, disallowedTools, skills, and maxTurns are optional
  • Map is merged with any SDK-level agents and composes independently with native Claude skills: selection
  • Claude only. Codex and community providers that don’t support inline agents emit a warning and ignore the field

When to use agents: vs .claude/agents/*.md files:

  • agents: (inline) — use when the sub-agent is specific to ONE workflow’s needs. Keeps the workflow self-contained in a single YAML file; travels cleanly in PRs and forks.
  • .claude/agents/*.md (on-disk) — use when the sub-agent is shared across multiple workflows OR the whole project (for example, a triage-agent used by several maintenance workflows). On-disk agents live outside workflow YAMLs and are picked up automatically by the Claude Agent SDK.

Both sources coexist — inline agents and on-disk agents are both available to Task(subagent_type=...) at runtime.


A wait: node records its condition in the workflow run, changes the run to paused, and returns the worker slot. Time and event waits carry an absolute deadline and resume through the ordinary DAG resume path when it arrives, enforced by the process that owns the run (see below). An attention wait has no deadline: it needs attention until someone answers it, and the answered run continues on its own. Restarting Archon preserves every kind.

Declare exactly one condition:

nodes:
- id: cool-down
wait:
duration_ms: 3600000
- id: maintenance-window
depends_on: [cool-down]
wait:
until: "2026-08-25T22:00:00Z"
- id: checks
depends_on: [maintenance-window]
wait:
event: checks.complete
deadline_ms: 86400000
- id: rerun-checks
depends_on: [checks]
wait:
attention: >-
Re-run the failed external check, then answer with what you found.
  • duration_ms starts once, then persists the resulting absolute time. An early manual resume pauses again against the same time; it does not restart the duration.
  • until accepts an ISO-8601 timestamp after $node.output substitution.
  • event requires deadline_ms. If no matching signal arrives by the deadline, the node completes with status: expired; event waits cannot remain open forever.
  • attention is a non-empty question, with runtime references, that the run needs answered. It takes an optional form and, for some forms, options; see Attention waits.
  • duration_ms and deadline_ms are capped at 1000 years so their persisted RFC3339 timestamps always remain executable.

A satisfied time or event wait produces the fixed structured output { status, waited_ms, event?, payload? }. status is satisfied or expired. An attention wait’s output is its answer, in the shape its form fixes. Downstream when: or until_bash wiring can branch without parsing prose. output_format, retry, and always_run cannot be set on a wait; the engine owns its output and continuation lifecycle.

An attention wait pauses until someone answers it, in the form it declares. Archon does not route the question or care who answers: a person in the console, an agent through the CLI, or a handler workflow all use the same answer channel.

- id: ask
depends_on: [plan]
wait:
attention: "$plan.output.question"
form: select # message (default) | approval | select | multi_select
options: # select and multi_select only
- { id: use-main, label: "Base on main" }
- { id: use-dev }
labels: # optional; values may use runtime references
about: "$plan.output.topic"
FormAnswer acceptedThe next node reads
messagefree text, which may be empty{ text }
approvalapprove or reject, plus optional text{ decision, text }
selectexactly one declared option id, plus optional text{ decision, text }
multi_selectone or more distinct declared option ids, plus optional text{ decisions, text }
  • options is required for select and multi_select and a load error on the other forms. Option ids are unique and kebab-case; label is display text only.
  • options may instead be one whole reference to an upstream output, options: "$plan.output.options". It is read when the wait pauses and must pass the same rules: an array of { id, label? } with unique kebab-case ids and at least one entry. If it does not, the wait node fails with an error naming the producer; it never pauses with options nobody can answer. When the producer declares output_format, the loader also checks that the field is an array of { id, label? }.
  • form is always a literal, because it fixes the node’s output contract, so a strict $ask.output.decision reference is checked at load time.
  • An undeclared option is refused on every surface. In the approval form reject is an answer like any other and never cancels the run; wire cancel: with when: "$ask.output.decision == 'reject'" if a rejection should end it.
  • labels is an optional flat map of strings, resolved when the wait pauses. The engine gives labels no meaning; your own handler workflows select items in the attention queue by them.

Answer with archon workflow answer, POST /api/workflows/runs/<run-id>/answer, or the console. approve, reject and respond <option> work as shorthand on every surface: approve [text] answers a message wait with its text, and otherwise each verb chooses the option of the same id. An answer that names its occurrence is refused if it targets an earlier pause of the same run. For HTTP /approve, /reject and /respond, include the optional expectedGate using the gate identity instructions in the API reference; omitting it answers the current pause. The answer is recorded with the surface it came through and the time (answeredVia, answeredAt on the wait, and a wait_answered event). The run stays paused until a continuation host (archon serve, or archon workflow wake) continues it; answering through the CLI without --json continues it in that process.

A wait paused before attention waits took answers has no pause id. It reads as a message wait, takes no answer, and continues with archon workflow resume <run-id>. A bare resume also completes any message wait, with empty text; the other forms refuse it until they are answered.

Before forms existed, an attention wait produced { status, waited_ms }. It now produces only its answer, so a workflow that reads status or waited_ms from an attention wait fails to load; read text instead. A run paused at such a wait usually resumes from the workflow source it captured, which still reads the old fields, so it cannot resume: abandon it and start a fresh run of the edited workflow.

Signal one exact run through the authenticated API:

Terminal window
curl -X POST http://localhost:3090/api/workflows/runs/<run-id>/signal \
-H 'Content-Type: application/json' \
-H 'X-Archon-User: <trusted-user-id>' \
-d '{"event":"checks.complete","occurrence":"<metadata.wait.pauseId>","payload":{"conclusion":"success"}}'

Use a Better Auth session cookie instead of X-Archon-User when browser authentication is enabled. The header is only for a trusted reverse proxy or loopback client; an auth-disabled local install can omit it. The event name must match the run’s open wait. The signal and its audit event are committed together; duplicate or wrong-run signals do nothing.

The process that owns the run enforces a duration_ms/until/event deadline itself: a foreground archon workflow run and the child started by --detach stay alive through the wait and re-execute the run when its deadline arrives, so a CLI-only install needs no server. If the owner has exited, archon workflow wake resumes due waits without a server; a running archon serve performs the same scan automatically. The same scan continues a gate or attention wait that was answered without being resumed, for example through --json. A run can always be advanced by hand with archon workflow resume <run-id>. The /signal endpoint above requires the server; archon workflow signal signals the same exact event occurrence from the CLI.

Read metadata.wait.pauseId from the run and pass it as occurrence (--occurrence in the CLI). A stale occurrence cannot signal a later wait, even when event and deadline repeat. resumeAt (--resume-at) remains accepted for compatibility; without an occurrence it cannot distinguish waits with the same event and deadline. When both selectors are supplied, both must match. Persisted waits without a pauseId can still be signaled by deadline and remain resumable. Slot release and automatic continuation also check pauseId, a fresh UUID for each new deadline wait.

A wait may be the sole terminal sink in a loop_group body. Archon then escalates the persisted cursor to the group and rechecks the group’s completion condition after the wait completes. max_iterations still bounds uninterrupted autonomous iterations, but continuing past a terminal attention wait (an answer, or a resume of a wait from before answers) permits one fresh iteration even after that bound; each further probe needs another answer. The next iteration reads the answer as $LOOP_PREV.<wait-id>.output. A non-terminal body wait, or a non-terminal deprecated approval: gate, is rejected because resuming a partial iteration would otherwise require replaying already-completed sibling work. A terminal attention wait is read past for the group’s output, so $group.output is the work the wait depends on; see Pausing to ask inside the body. Waits below more than one nested loop_group boundary are not supported.

Durable waits are not supported in container-isolated workflows. The server cannot reconstruct the CLI-owned container context needed for an automatic continuation, so Archon rejects this combination before creating the run. Automatic quota-window continuation is likewise skipped for container runs.

YAML declares the engine-visible coordination condition; computation stays in a bash:, script:, or prompt: node whose structured output can feed the wait.


AI nodes (command:, prompt:) and each loop: iteration automatically retry on transient errors (failures the provider reports as transient, rate-limited or overloaded, and timeouts) using a default configuration: 2 retries (3 total attempts), 3 s base delay with exponential backoff. You will see a platform notification before each retry attempt.

Deterministic nodes (bash:, script:) do not auto-retry — they run exactly once unless you add an explicit retry: block. This keeps side-effectful scripts (deploys, gh mutations, external CLIs) from being silently re-run on a transient-looking failure; opt in per node when re-running is safe. loop_group: does not accept retry:; configure retries on its body nodes.

A loop: policy retries the failed iteration, without advancing max_iterations or replaying earlier iterations. Its retry budget resets after each successful iteration. Without a declaration, it keeps the same defaults as other AI nodes. Loops accept only omitted on_error or transient; on_error: all is rejected, so unknown and fatal failures and output-contract failures never gain retries.

To enable or customise retry, add a retry: block:

nodes:
- id: flaky-node
command: flaky-command
retry:
max_attempts: 3 # 3 retries = 4 total attempts
delay_ms: 5000
on_error: transient
- id: aggressive-retry
prompt: "Summarise the output"
retry:
max_attempts: 4 # 4 retries = 5 total attempts
on_error: all # Retry even non-transient errors (use with caution)
- id: iterate
retry:
max_attempts: 1
delay_ms: 0 # No wait for ordinary transient failures
loop:
prompt: "Finish the task and output <promise>DONE</promise>"
until: DONE
max_iterations: 5
- id: deploy # bash/script only retry when retry: is set
bash: "./deploy.sh"
retry:
max_attempts: 3
delay_ms: 5000
on_error: all

retry: is required to enable retry on bash:/script: nodes; on command:/prompt: nodes and loop: iterations it customises the defaults below. Whenever retry: is declared, max_attempts is required.

FieldTypeDefault (AI nodes)ConstraintsDescription
max_attemptsnumber21–5Number of retry attempts (not including the initial attempt). 1 = one retry (2 total attempts). No default on bash:/script: — omitting retry: means a single attempt
delay_msnumber30000–60000 on loops; 1000–60000 otherwiseBase delay in ms before the first retry. Doubles each attempt (exponential backoff)
on_error'transient' | 'all''transient'—Which errors trigger a retry. Loops accept only 'transient' or omission. 'transient' = transient, rate-limited and capacity failures and timeouts only. 'all' = also unknown failures, including any non-zero bash:/script: exit (FATAL failures such as auth, config errors and cancellation are never retried regardless)

Archon sorts a failed attempt into one of three buckets before deciding whether to retry. The bucket comes from the kind of failure Archon recorded, never from the error message. For a provider error, the provider picks the bucket through the typed failure class it reports on its result, taken from its SDK’s structured signals (error codes, HTTP status fields, typed exceptions), never from the error text:

BucketProvider failure classesRetried by default?
FATALauth, quota_exhausted, budget_exceeded, misconfiguredNever (even with on_error: all)
TRANSIENTtransient, rate_limited, overloaded (rate limits and capacity get longer retry budgets)Yes
UNKNOWNunknownNo (unless on_error: all)

A quota_exhausted failure is never retried in the same window, but when the provider reports when the limit resets, the run waits until that reset instead of failing.

misconfigured means the setup must change before the node can succeed: a bad proxy URL, a missing or too-old CLI, an unknown model, an unreadable checkout or provider MCP config file. Fix the configuration and run again. A host-local read failure in captured home MCP config pauses the existing run for continuation on a host that can read it; see MCP servers.

Capacity failures (overloaded) get at least five retries: six provider attempts with delay centers of 45, 90, 180, 300 and 300 seconds. Each delay has ±50% jitter; the 300-second cap applies before jitter, so a wait can reach 450 seconds. The nominal total wait is 15 minutes 15 seconds, excluding provider execution time. Node delay_ms does not change this schedule, including a loop policy with zero delay. The larger budget applies only while the current failure is capacity; ordinary transient failures keep their own budget. Rate limits keep their existing five-retry minimum and flat 45-second delay with ±50% jitter; once a rate limit occurs, that larger budget remains for the rest of the iteration or node attempt sequence. A rate limit that still refuses the node after those retries does not fail the run: the run pauses on the provider-limit wait (attention queue kind usage_limit) and continues from that node by itself.

Each retry wait is logged and recorded as node_retry_scheduled, with the node path, class, one-based retry attempt, effective budget and delay; loop retries also record the iteration. Exhaustion preserves the provider failure and message. These are retry delays, not a deadline that ends a run.

Claude’s typed overloaded code and Codex’s serverOverloaded map to overloaded. Generic server errors and HTTP 503 do not establish capacity. Pi and OpenCode expose no dedicated capacity discriminator on their current failure paths; capacity-looking prose remains unknown.

Every built-in provider reports a typed class:

  • Claude reports every class, from its SDK’s error codes, HTTP status, process-exit fields and the reason Claude Code gives when it refuses to start. A sign-in the organization rejects is auth. A configuration problem is misconfigured: an invalid proxy URL, a CLI below the minimum version, invalid managed settings, a provider the managed settings disallow, an unusable temp or working directory, a missing shell tool, bypass permissions as root, an unknown model, a Claude Code executable that is missing or cannot launch, an unreadable MCP config file, a declared skill Claude cannot reach, a named plugin that is not installed or that Claude Code cannot list, or a session whose loaded plugins do not match the node’s plugins:.
  • Codex reports a failed turn from Codex’s own error code: auth for missing or rejected credentials, quota_exhausted for a used-up usage limit (with resetAt when Codex reports a full window), rate_limited, overloaded for capacity exhaustion, transient for a dropped connection or a Codex process that exits mid-turn, and budget_exceeded for Codex’s session budget. It reports misconfigured when its binary cannot be found or run (a bad CODEX_BIN_PATH or codexBinaryPath, no binary in a compiled install, a file that is not executable or built for another architecture), when the Codex process exits before answering anything (a binary without app-server, or one that rejects a flag; its stderr is the evidence), or when its MCP config file cannot be read. A workflow node is also misconfigured when a named plugin is not installed, when its mcp: file reuses a server name from the Codex config, or when Codex reports a live MCP server the node did not declare. Every other failure is unknown, including a thread that can no longer be resumed.
  • Pi reports misconfigured when a node has no model, the model ref is malformed, or the model is not in Pi’s catalog, and auth when it has no credentials for the model’s provider. A failed Pi turn reaches Archon only as a stop reason and message text, so those failures are unknown. That includes a used-up usage limit: a Pi run that hits one fails instead of waiting for the reset.
  • OpenCode reports auth (the SDK’s ProviderAuthError, or HTTP 401/403) and rate_limited (HTTP 429). Every other OpenCode failure is unknown.
  • Copilot reports misconfigured when its MCP config file cannot be read. Its SDK exposes every other failure only as a message string, so those are unknown.

Set on_error: all on a node that should retry unknown failures.

Archon puts a failure it detects itself in a bucket by its kind:

FailureBucketRetried by default?
A timeout: an AI node’s idle_timeout, or a bash:/script: node’s timeoutTRANSIENTYes. Each retry of a hung AI node can wait another full idle_timeout
An AI provider stream that ends without any outputTRANSIENTYes
A bash:/script: node that exits non-zero or cannot startUNKNOWNNo (unless on_error: all). What the script printed does not matter
An AI node’s structured output that fails its output_format after reasksUNKNOWNNo (unless on_error: all)
A bash:/script: node’s stdout that fails its output_format—Never
A config error: a missing command file, a bad input reference, an output_format that cannot compileFATALNever
A cancelled nodeFATALNever
A provider error without a typed classUNKNOWNNo (unless on_error: all)

A failure recorded before Archon stored failure kinds counts as UNKNOWN.

Pi retries a failed model call itself before Archon sees the failure. Archon reads Pi’s settings from ~/.pi/agent/settings.json and the project’s .pi/settings.json, and does not change them:

Pi settingDefaultEffect
retry.enabledtruePi retries a turn that failed with an error it judges transient (overload, rate limit, server error, network failure, timeout)
retry.maxRetries3Retries per turn
retry.baseDelayMs2000First delay, doubling each retry (2s, 4s, 8s)
retry.provider.maxRetries0A separate HTTP-level retry, in the Anthropic, OpenAI, Azure and Google adapters, that reads the status code and honors Retry-After. Off by default

See Pi’s settings reference for the full list. A failure that outlasts Pi’s retries reaches Archon as unknown, so by default Archon does not retry it again. To add node-level retries on top, set retry: { on_error: all } on the node. The attempts then multiply: with Pi’s defaults and max_attempts: 2, the failing model call can be sent up to 12 times (3 node attempts × 4 Pi attempts each), and each node retry also repeats the model calls the node made before it failed. To leave retrying to Archon alone, set "retry": { "enabled": false } in Pi’s settings.

Before each retry the platform receives a message like:

Node `node-id` failed with transient error (attempt 1/3). Retrying in 3s...

The node retry is the only retry Archon adds. Every provider makes one attempt per call and reports a typed failure; the engine decides whether to try again:

Node retry (dag-executor) — AI nodes: default 2 retries, 3 s base backoff;
bash/script: only when retry: is set
↓ only if all node retries exhausted
Workflow fails → user opts in to resume on next invocation

The vendors’ own clients may still retry an individual API request inside one attempt (Claude Code’s API retries, Pi’s retry setting); that is the provider’s native behaviour and its configuration.

DAG resume: For nodes: (DAG) workflows, resume is opt-in — pass --resume to archon workflow run, run archon workflow resume <id>, or use the web UI resume button. Plain archon workflow run <name> always starts a fresh run. See DAG Resume on Failure below.


budget: caps how much work and money one run may consume in total. It is not a concurrency limit: fan_out.max_parallel controls how many things run at once, while a budget controls how much runs overall. There is no default; a workflow without budget: runs exactly as before.

budget:
max_work_units: 20 # positive integer
max_spend_usd: 5 # positive number, USD

Declare either ceiling or both. A malformed block, including a misspelt key, is a load error, so a typo never silently means “no ceiling”.

What counts. One work unit is one started attempt of a prompt:/command: node or a bash:/script: node. Each retry is another attempt, and so is each approval on_reject rework. Approval gates, waits and cancel: nodes do no work and consume nothing. Spend is the cost the provider reports for each AI attempt; bash: and script: nodes cost nothing.

Refusal. Before an attempt starts, the engine checks the ceilings. Once a ceiling is reached, it does not start the attempt: the run fails with exit reason budget_refused, a run_budget_refused event recording the reason, the refused node, the limits and what was consumed, and a message naming the node that was not started.

Bounded overshoot. The check happens before an attempt, so an attempt already running finishes. Work units are exact, even across a parallel layer. Spend can exceed max_spend_usd by the cost of the attempts that were already running when the ceiling was crossed.

Unknown cost is not zero. With max_spend_usd, every AI attempt must report a complete cost. The run fails as soon as one does not, before later work starts:

  • providers that do not report cost (their costReporting capability is false);
  • Claude after resuming a session that an earlier process created, which reports no cost for that query;
  • an attempt where only some provider calls reported cost (for example a structured-output retry), recorded on the node as spend.costUsd.partial: true;
  • an attempt that started and never finished, such as one interrupted by a crash or Ctrl-C;
  • an attempt that failed with a provider error that reports no cost, such as a Claude rate limit, overload or startup timeout. The failed attempt may already have made billed calls, so the run fails instead of retrying the node.

Use max_work_units alone for providers that do not report cost, or when a failed attempt should be retried rather than end the run. Unknown cost is still recorded, but it does not refuse the run.

Resume. The totals are rebuilt from the run’s own durable records each time the run resumes, so a resumed run continues from what it already consumed. A run refused for exhaustion refuses again on resume: the ceiling comes from the run’s captured workflow source, not from the YAML on disk. Start a new run with a higher ceiling to continue.

Supported shapes. A budgeted workflow may not contain loop:, loop_group:, workflow: (with or without fan_out:), or include: with fan_out:, including any reached through include:. The budget does not count their work yet, so the workflow fails to load instead. An included workflow’s own budget: is dropped with the run-owned-field warning; the composing run’s budget governs every node.

Node-level maxBudgetUsd is unchanged and applies alongside a run budget: it caps one Claude node, while budget: caps the whole run.


When a nodes: (DAG) workflow fails, the prior run stays in the database as a candidate for resume. Resume is explicit: you opt in by flag or button.

How to resume:

  • CLI: archon workflow run <name> --resume resumes the most recent failed run for (workflow_name, cwd). Or archon workflow resume <run-id> to target a specific run.
  • Chat: Approving or rejecting a paused workflow continues it from where it left off (the platform already knows the run id). For a prior failed (or stale running) run, /workflow run <name> does not silently resume — it shows a prompt offering three choices: resume it, abandon it and run fresh, or start fresh anyway. Pass --force to skip the prompt: /workflow run <name> --force <args> always starts a fresh run.
  • Web UI: Open the failed run in the console and use Resume in the run detail action bar.

What happens on resume:

  1. The CLI / orchestrator looks up the resumable run, loads its node_completed events and private addressable-session handles, then transitions the row back to running.
  2. Completed nodes are skipped; only failed and not-yet-run nodes are executed.
  3. You receive a platform message like: Resuming workflow — skipping 3 already-completed node(s).

Why opt-in? Earlier versions silently auto-resumed on plain archon workflow run, which caused state from prior failed runs (e.g. cached node outputs with stale inputs) to bleed into new invocations of the same workflow at the same path. See #1392 for the bug; now resume is always a user-driven decision.

Crashed servers / orphaned runs: Archon does not auto-fail running rows on server startup — that would kill workflows actively executing in another process (CLI, adapter). If a server crash leaves a row stuck as running, it remains visible in the console run list. Transition it to a terminal status explicitly:

  • Web UI: open the run and use Cancel in the run detail action bar. When no owner answers, Cancel refuses, shows what the run recorded, and offers Abandon; use it after verifying the owner is gone.
  • CLI orphan cleanup: after verifying the owner is gone, use archon workflow abandon <run-id>.
  • Live detached CLI run: use archon workflow cancel <run-id> (or Cancel on any surface) to terminate the exact run’s host process tree before marking it cancelled.

Once the row reaches a terminal status, you can resume it explicitly via the paths above. Plain archon workflow run never resumes implicitly.

Not to be confused with archon workflow cleanup [days], which deletes old terminal runs (completed/failed/cancelled) from the database for disk hygiene. It does not transition running rows.

Session context on resume: Handles required by an explicit context: { resume: node-id } selector are restored for completed nodes. The ambient sequential cursor is not reconstructed; a downstream node that relies on implicit inherited context should use an explicit selector or re-read durable artifacts.

Fresh start: If zero nodes completed in the prior run, Archon starts fresh (no nodes to skip).

By default, resume skips any node that completed successfully in the prior run and feeds its cached output to downstream consumers. That’s the right behavior when a node’s exit code captures the validity of its output (e.g. AI prompts, scripts that produce structured stdout).

It’s the wrong behavior when a node’s success status doesn’t capture output validity — typically a producer whose exit code reports the side effect (a file written, a service called) but whose downstream consumer parses the side effect’s contents on every run. If the producer succeeded but wrote garbage, resume will replay the cached “success” forever without ever re-executing the producer.

Set always_run: true on the node to force re-execution on resume, even when the prior run marked it completed:

nodes:
- id: fetch-data
bash: ./scripts/download.sh > $ARTIFACTS_DIR/data.json
always_run: true # Re-fetch on resume; download.sh exit code doesn't validate the JSON
- id: process-data
prompt: "Summarize $ARTIFACTS_DIR/data.json"
depends_on: [fetch-data]

On resume, fetch-data re-runs regardless of prior success, so process-data reads a freshly produced file. Normal cached nodes in the same run are still skipped — always_run is per-node.


Different from resuming a failed/paused run or selecting an upstream node with context.resume: when you invoke the same workflow again with a follow-up prompt, every AI node normally starts fresh and pays to re-establish context. Set persist_session: true on a node to make its provider session ID stick across runs, so subsequent invocations continue the prior conversation for that role. Only a provider that can fork a session continues it; with any other provider the node starts fresh on every run (see Concurrent runs).

name: feature-dev
description: plan → implement → review with cross-run memory
provider: claude
nodes:
- id: planner
prompt: "Plan the implementation for: $ARGUMENTS"
persist_session: true
- id: implementer
depends_on: [planner]
prompt: "Implement: $planner.output"
persist_session: true
- id: reviewer
depends_on: [implementer]
prompt: "Review the implementation against the plan."
persist_session: true

Run it once with "add OAuth login", again with "now add MFA" — each role continues its prior conversation. The reviewer remembers what it already flagged; the planner remembers it chose Google OAuth.

Sessions are keyed by (workflow_name, node_id, scope_key, provider). The scope is the conversation that launched the run, so each chat thread has its own per-node memory. A run the Web UI or REST API starts in the background still belongs to the chat it was launched from, so runs from one chat continue each other’s sessions.

A run started without a conversation (for example, by code that calls the workflow engine or store with no origin) has no scope. Its persist_session nodes start fresh, save nothing for later runs, and get no scope artifacts. Resuming that run is unaffected: it continues from its own completed nodes like any other run.

The CLI is different: archon workflow run starts a run without a conversation, so its persist_session nodes start fresh on every invocation, and --conversation-id does not change that. Resuming a run that a chat launched keeps that chat’s scope.

Two runs of the same workflow in the same scope can run at the same time. Neither waits for the other, and they never write into the same provider conversation:

  • When a run starts, it reads the scope’s persisted sessions once. Each persist_session node continues from that copy, not from a session another run saved after this run started.
  • The node continues the saved session only when its provider can fork it (declares sessionFork: true). The fork is a new session with the saved history, so the saved one stays unchanged. Claude, Codex and Pi fork.
  • A provider that cannot fork would resume the saved session in place, so two runs could append to one conversation. Instead, the node does not continue it. The run records a node_session_not_continued workflow event naming the session it skipped (an 8-character preview) and posts a notice in the conversation. archon validate workflows also warns before any run, but only for a node whose provider is written in the workflow file itself; a node from an included block, or one whose provider comes from config defaults or a model: alias, is checked only when it runs. OpenCode and Copilot are in this group today, so their persist_session nodes do not carry context between runs; pass state between runs through artifacts instead.
  • When a node finishes with a session, that session becomes the saved one. With overlapping runs, the run whose node finished last wins. A node that finishes without a session id leaves the saved session as it was.
persist_sessions: true # All AI nodes default to persist_session: true
nodes:
- id: validator
persist_session: false # Opt this node back out

The resolved provider must declare sessionResume: true in its capabilities. The loader rejects workflows that set persist_session: true against a non-resume-capable provider at the explicit-provider level; the executor catches the implicit-default-provider case at runtime. Continuing a session across runs also needs sessionFork: true; see Concurrent runs.

persist_session applies to command: and prompt: nodes only. Other node types skip it:

  • bash: / script: — never invoke a provider, so the field is meaningless. Setting it produces a parse warning (shown by archon validate workflows) and is ignored.
  • wait: / cancel: / deprecated approval: — same: no AI call, no session to persist.
  • loop: / loop_group: — have their own per-iteration session threading. Cross-run persistence isn’t wired for them in this release; the field is warn-and-dropped on loop and loop_group nodes. Use a prompt: node if you need cross-run memory.

When a workflow-level persist_sessions: true is combined with any of these node types, the capability check and persistence logic both skip the non-applicable nodes — no false validation errors, no silent runtime mistakes.

To continue a node’s conversation outside Archon, read its session id from archon workflow get <run-id> --verbose and pass it to the provider’s own resume command, such as claude --resume <id>. Claude Code finds a session by the directory it ran in, so run that command from the node’s working directory: the run’s worktree when the run used one.

A node with context: fresh skips persistence (and in-run threading). The explicit “always fresh” intent wins over persist_session.

context: { resume: source } also wins over the consumer’s saved cross-run session for that invocation. After the exact named fork succeeds, its new branch is saved normally when persist_session applies.

SurfaceCommand
Chat/workflow reset-sessions <workflow-name> [<node-id>] (scoped to current conversation)
CLIarchon workflow reset-sessions <workflow-name> [--scope <key>] [--node <id>] [--yes]
RESTDELETE /api/workflows/{name}/node-sessions?scope=<key>&node=<id>

Cross-scope resets are guarded so a dropped scope can’t silently wipe every conversation’s memory: the CLI requires --yes when --scope is omitted, and REST requires ?confirm=all-scopes. When conversations are private to their owner (web auth enabled, or ARCHON_WEB_AUTH_HEADER set), REST refuses the cross-scope reset with 403, and scope must name an existing conversation: a web or CLI conversation must be one you own, while a chat or forge conversation (Slack, Discord, Telegram, GitHub, GitLab, Gitea) keeps its platform’s own access model and needs no ownership. Chat always scopes automatically to the current conversation.

Persistent sessions on Pi replay the full rollout on each turn, so token cost grows with iteration depth. Claude auto-compacts. If a workflow’s persistent sessions get expensive, reset them and start fresh.

If the stored session is gone (for example, a Pi JSONL file was moved), the provider can’t resume it. Rather than silently pretending nothing was lost, the provider starts a fresh session for that node and the executor surfaces a visible warning:

⚠️ Node planner: could not resume the prior session — continued with a fresh session, so the earlier context was not restored.

The node still completes on that fresh session, and its new session id is persisted so the next run continues from it. The node is not re-run — the fresh session is already a clean start, so re-running would only repeat it. Expect this only for persist_session nodes whose prior session became unavailable; warm resumes and first-time runs are unaffected.

Codex does not fall back: a thread it cannot resume or fork fails the node as unknown, with Codex’s own error as evidence (for example no rollout found for thread id …). Codex reports a missing thread with the same JSON-RPC code as any other invalid request, so Archon cannot tell it apart safely enough to start over on its own. A persist_session node on Codex forks the thread an earlier run saved, so once that thread is gone (its rollout under the Codex home was deleted, or the run uses another CODEX_HOME) the node fails on every run in that scope. Clear the saved session with archon workflow reset-sessions to start the node fresh.

A lost session doesn’t have to mean lost context. Workflows that use persist_session in a run launched from a conversation also get a stable cross-invocation artifact scope at scopes/<workflow>/<scope>/ (a sibling of the per-run runs/<id>/ directory, under the same artifacts root; the scope is the launching conversation’s UUID — the same key sessions use). Whenever a persistence-participating node also declares an output_type, the engine mirrors its typed output sidecar (nodes/<id>.md + nodes/<id>.meta.json) into that scope directory in addition to the run directory.

On a cold resume, the warning then goes further: if the scope directory holds typed artifacts from an earlier invocation, the message lists them by reference (file paths — never pasted content), so the recovered context can be read on demand:

Artifacts from the previous invocation are available for recovery (read on demand):

  • plan: ~/.archon/workspaces/acme/widget/artifacts/scopes/feature-dev/<conversation>/nodes/planner.md

To opt in to recovery, give your persist_session nodes an output_type:

nodes:
- id: planner
prompt: "Plan the implementation for: $ARGUMENTS"
persist_session: true
output_type: plan # mirrored to the durable scope → recoverable after a cold resume

Notes:

  • Opt-in only. Workflows without persist_session get no scope directory, no mirroring, and no pointer — default behavior is unchanged. Persist nodes without output_type keep session continuity but leave nothing behind for recovery.
  • Last writer wins. Concurrent runs of the same workflow in the same scope write per-node files into the shared scope directory; the most recent run’s output for a given node is what a later cold resume sees.
  • CLI caveat. archon workflow run starts a run without a conversation, so it gets no scope directory. This is the same caveat as session persistence itself.

Binding Values into Command and Script Nodes

Section titled “Binding Values into Command and Script Nodes”

A command: file and a named script: file are opaque to inline substitution — the engine never rewrites their bodies, so $producer.output.green written inside one stays literal text. Before node-local bindings, the only way to hand such a node a VALUE was an artifact file: the producer wrote $ARTIFACTS_DIR/verdict.green, the consumer re-read and re-parsed it, and a one-bit boolean traveled through the filesystem.

with: on a command: or script: node replaces that bridge. It binds upstream values by name into the channels those bodies already read — $INPUTS.<name> in a command file, INPUTS_<UPPER_SNAKE> env vars in a script:

nodes:
- id: implement
command: implement-issue
output_format:
type: object
properties:
green: { type: boolean }
required: [green]
- id: record-verdict
script: record-verdict # reads process.env.INPUTS_GREEN — no .green file
runtime: bun
depends_on: [implement]
with:
green: $implement.output.green

Values keep their logical type: a whole $node.output[.field] (or $INPUTS.<name>) reference passes the value itself — a boolean stays a boolean, an object stays an object — and non-string literals (retries: 3, flag: true) pass as written. Text delivery uses one deterministic rule everywhere: strings raw, everything else canonical JSON text, so a bound object arrives in the env var single-encoded ({"a":1}) and is parsed once. Any other string is a template, substituted exactly like input:.

When mutually exclusive branches join with trigger_rule: all_done, a consumer may need a value from a producer that was skipped. Declare that case per binding with the { from, if_skipped } directive instead of a file latch:

- id: join
script: decide-ready
runtime: bun
depends_on: [initial-ready, iteration-ready]
trigger_rule: all_done
with:
initial: { from: $initial-ready.output.ready, if_skipped: false }
iteration: { from: $iteration-ready.output.ready, if_skipped: false }

if_skipped is data — the value the binding takes when its producer did not run. The coalescing decision (“ready if either branch said so”) stays in the consuming script, which is the YAML-coordinates / code-computes split. A skipped producer with no if_skipped fails the node with the binding, producer, and fix named — a binding never silently resolves to ''. if_skipped is literal data, so reference-looking text there is neither substituted nor validated as an output reference.

if_skipped covers a producer that completed as skipped, including an exec node that ran until an opted-in timeout. A producer that failed always fails the binding too, whether or not if_skipped is declared — a loop_group’s failure paths in particular can leave real, non-empty output behind (its last completed iteration’s text), so this is an explicit check, not an accident of empty output. There is no way to opt a binding out of this: declaring if_skipped never papers over a real failure, and neither when: nor an inline $node.output reference in a prompt/bash/command body can substitute for it — every one of them fails the same way against a failed producer, whether the reference is fielded or whole-text.

Two guarantees the loader enforces: every bound producer must be reachable through depends_on (a binding can never race its producer), and two binding names may not fold to the same INPUTS_* env key. At runtime, node-local bindings are the nearest input layer — they win over a composed block’s inputs, which win over the run’s $INPUTS.

Bindings carry values. For documents — a plan, a findings report, a diff — keep using the artifact chain below; a path in a binding plus a file on disk is still the right shape for anything big enough to have structure of its own.

Reading where a node started: $node.execution.checkoutStart

Section titled “Reading where a node started: $node.execution.checkoutStart”

The engine observes the checkout when each invocation of a prompt, command, bash, script, or loop node starts, and records it on that node’s execution record. A binding can read the observation of an upstream node’s invocation:

- id: assert-changed
script: assert-changed
runtime: bun
depends_on: [implement]
with:
baseline: "$implement.execution.checkoutStart" # arrives as INPUTS_BASELINE (JSON)

The value is the checkout observation from the start of implement’s invocation: the commit, that commit’s tree, and whether the worktree was clean. A dirty start also names a manifest under $ARTIFACTS_DIR listing each path that differed from the commit with Git’s blob id of its content, its mode, and its type. Retries and the turns of a loop: node keep their invocation’s first observation; the next invocation (for example, the next iteration of an enclosing loop_group) records a new one. A resumed run reads the same recorded value. Observation never changes the checkout: no stash, no index write, no git add. The recorded commit is the one git status reported comparing the worktree against; if HEAD keeps moving while the checkout is read, the observation is unavailable rather than a guess.

The reference is valid only as the whole value of a command: or script: binding, and only for a producer that executes against the checkout. Anything else is a load error. Every bash: and script: node, and the agent’s shell in an AI node (see Durable state documents for the provider exception), also receives its own execution identity, including this attempt’s observation, as JSON in the ARCHON_NODE_EXECUTION environment variable. The implement workflow compares these two observations to decide whether an invocation changed anything since it started.


Workflows work because artifacts pass data between nodes:

┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Node 1 │ │ Node 2 │ │ Node 3 │
│ investigate │ │ implement │ │ create-pr │
│ │ │ │ │ │
│ Reads: input │ │ Reads: artifact │ │ Reads: git diff │
│ Writes: artifact│────▶│ Writes: code │────▶│ Writes: PR │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
$ARTIFACTS_DIR/ src/feature.ts
issues/issue-123.md src/feature.test.ts

When creating a workflow, plan the artifact chain:

NodeReadsWrites
investigate-issueGitHub issue via gh$ARTIFACTS_DIR/issues/issue-{n}.md
implement-issueArtifact from investigate-issueCode files, tests
create-prGit diffGitHub PR

Each command must know:

  • Where to find its input
  • Where to write its output
  • What format to use

The chain above relies on each node knowing the exact filename its upstream wrote. To locate an output by type instead of by guessed filename, declare output_type on a node:

nodes:
- id: planner
command: plan-feature
output_type: plan # tag this node's output

When a node sets output_type, the executor writes a typed sidecar after the node completes:

  • $ARTIFACTS_DIR/nodes/<id>.md — the node’s output text
  • $ARTIFACTS_DIR/nodes/<id>.meta.json — metadata (outputType, runId, producedAt, size)

That exact layout remains the contract for top-level nodes. A typed node inside a loop_group writes one pair per successful body execution instead: nodes/loop.<owner-digest>__<body>.md and the matching .meta.json. The stable digest is derived from the complete original body ID and outermost-to-innermost loop lineage, so delimiter-shaped IDs and repeated inner iteration numbers cannot alias another execution. Metadata keeps the readable provenance as loopGroupPath: [{ groupId, iteration }, ...]. A body node expanded from an include: retains its load-time <include>__<node> ID in metadata and as the sanitized body suffix.

This works on every node type, bash/script included. The write is best-effort — if it fails, the node still succeeds and a warning is logged; the typed sidecar may simply be absent. output_type is an open set of labels (plan, findings, code, summary, …) — pick a convention and keep casing consistent, since lookup is case-sensitive.

Every executable invocation receives a typed-artifact listing at $TYPED_ARTIFACTS_FILE: a JSON file inside the run’s artifact directory, recreated before the node runs. It has the shape { "runId", "artifactsByType": { "<outputType>": [ …metadata ] }, "errors": [ … ] }, so a script or agent selects a type without knowing nodes/, sidecar names, or loop filename rules. Each entry is the same metadata the sidecar holds (nodeId, outputType, path, runId, producedAt, size, and optional loopGroupPath), and path is relative to $ARTIFACTS_DIR.

// A script: read the listing the same way in host or container runs.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const listing = JSON.parse(readFileSync(process.env.TYPED_ARTIFACTS_FILE!, 'utf8'));
if (listing.runId !== process.env.WORKFLOW_ID) throw new Error('listing is for another run');
for (const error of listing.errors) console.error(`unreadable: ${error.path} (${error.kind})`);
for (const gate of listing.artifactsByType['green-gate'] ?? []) {
const body = readFileSync(join(process.env.ARTIFACTS_DIR!, gate.path), 'utf8');
console.log(gate.nodeId, body);
}

In an agent prompt, point at the listing rather than the layout: “Read $TYPED_ARTIFACTS_FILE, select artifactsByType["plan"], and open each entry’s path under $ARTIFACTS_DIR.”

Properties worth knowing:

  • Observation, not a ledger. Entries of a type are ordered by producedAt ascending, with ties broken by content path. The listing freezes which artifacts existed when the invocation started (each attempt, loop iteration, and until_bash check gets its own) and their metadata; it is not an execution history or a completion-order log, and re-running the same node owner overwrites its sidecar. An invocation sees artifacts published before it started, never a running sibling’s — declare depends_on to order a producer ahead of its consumer. A loop_group’s until_bash runs after its body, so it sees what that iteration’s body published.
  • Failures stay visible. errors lists every record the read could not turn into an artifact (malformed or foreign-run sidecar, unsafe path, missing or unreadable content). A corrupt sidecar has no trustworthy type, so it never disappears into an empty type list. An absent key means no matching readable metadata; runId lets a consumer reject a listing handed to it out of scope.
  • Everywhere an invocation runs. The path is delivered in bash: and script: nodes (as both $TYPED_ARTIFACTS_FILE and the TYPED_ARTIFACTS_FILE environment variable), agent prompts, loop attempts, and deprecated approval.on_reject rework prompts. A prompt that references it in a context with no listing fails rather than substituting an empty string. A --dry-run preview has no artifacts and substitutes an empty string instead, so it cannot detect a reference made from a context that never receives a listing.

Successful bash stdout is retained by default on the completed run as a bounded audit preview in node_completed.data.node_output. Output over 32 KiB (32,768 UTF-8 bytes) ends with a truncation marker, and the event also includes node_output_truncated: true plus node_output_original_bytes. Because stdout is persisted, never print secrets or credentials from bash nodes. This preview is separate from output_type: declaring output_type opts into a best-effort file sidecar that may contain the full output and is not required for ordinary bash audit retention.

Separately from the value channel above, the engine retains what each subprocess printed in the run’s own transcript (~/.archon/workspaces/<project>/logs/<run-id>.jsonl), as one exec_output row per subprocess:

Use archon workflow logs <run-id> to print the current transcript or add --follow to stay attached while the run executes. The foreground run-start output and archon workflow get <run-id> both expose the resolved local path, so callers do not need to reconstruct the project storage layout.

FieldMeaning
stepThe node id. An until_bash probe uses <node>-iteration-<n>.
content<bash>, <script>, or <until_bash>.
exit_code0 on success. On failure, the process exit code — or a symbol when there is none: ENOENT, ERR_CHILD_PROCESS_STDIO_MAXBUFFER, or the signal name (SIGTERM) for a timeout kill.
stdout_tail / stderr_tailLast 2000 characters of each stream. Absent means that stream was empty.

This covers every bash: and script: node and both loop variants’ until_bash probes, on success and failure alike — including a probe’s tolerated non-zero exit, which is otherwise unrecorded. Workflows do not need to build their own logging to answer “what did this node actually do?”.

Three properties worth knowing:

  • Streams stay separate. stderr is never merged into stdout, so a git or gh warning cannot be mistaken for the value a node returned.
  • It is capped; $nodeId.output is not. A tail over 2000 characters is marked in place with …[truncated to last 2000 chars]. The cap applies only to this evidence copy — a node’s output still reaches its consumers whole.
  • Credentials are removed on write. Values the engine put in the subprocess environment (keys ending in TOKEN/KEY/SECRET/PASSWORD, DATABASE_URL, and credentials Archon injected) are replaced with [REDACTED] before the row is written. This protects the transcript, not the node_output preview above — the “never print secrets” rule still stands.

$ARTIFACTS_DIR is scoped to one run. When a workflow needs to remember something between runs — a dedup ledger of issues already commented on, a “last processed” cursor, a nudge log — write it to $STATE_DIR:

nodes:
- id: load-state
runtime: bun
script: |
import { readFile } from 'fs/promises';
const path = `${process.env.STATE_DIR}/triage/seen.json`;
let seen: string[] = [];
try {
seen = JSON.parse(await readFile(path, 'utf-8'));
} catch {
// First run — no ledger yet.
}
console.log(JSON.stringify({ seen }));

$STATE_DIR is ~/.archon/workspaces/<project>/state/, pre-created before the first node runs, and delivered to bash:/script: subprocesses as the STATE_DIR environment variable as well.

It is scoped per project, not per workflow. Every workflow in the project sees the same directory. That is deliberate — it is what lets two cooperating workflows (say a triage pair that must not both comment on the same issue) share one ledger. If you want isolation, namespace it yourself: $STATE_DIR/<workflow-name>/, exactly as you already organize subdirectories inside $ARTIFACTS_DIR.

Concurrency: no locking, and what that means

Section titled “Concurrency: no locking, and what that means”

The engine does no locking on $STATE_DIR. Two runs of the same stateful workflow in one project — each in its own worktree, so the working-path lock does not serialize them — share one directory, and the failure mode is a lost update:

  1. Run A reads {1, 2}
  2. Run B reads {1, 2}
  3. Run A writes {1, 2, 3}
  4. Run B writes {1, 2, 4} — A’s entry is gone

For a dedup ledger that means an item gets reprocessed on the next run: a duplicate comment, a repeated nudge. Note that write-then-rename prevents torn reads but does not prevent lost updates — the read happened before either write.

This is an authoring concern, not an engine one. Options, in order of preference:

  • Durable state documents. Use archon state for concurrent JSON updates with audit history, including multiple hosts sharing PostgreSQL. See below.
  • Append-only file ledger. Concurrent small O_APPEND writes are atomic on POSIX, so each run appends its own lines and readers fold the file. This is the fix if lost updates matter.
  • Accept last-writer-wins. Fine when the state is a cache or a cursor that self-corrects.
  • Don’t run the workflow concurrently. A workflow with worktree: enabled: false shares one working path, and the path lock rejects a second run on that path outright — it does not queue it. So concurrent state writes cannot arise there in the first place; the second invocation fails fast with “This worktree is in use” and the operator re-runs it afterwards.

Put the state write in a script: node, not in an AI node’s Write tool. A script: node is a guarantee; a prompt instructing an AI node to append is a convention it may not follow.

Use archon state for a standing agent’s small JSON ledger, cursor or index when updates can overlap, need audit history, or run on several hosts. $STATE_DIR remains the free-form file area for caches and non-JSON data, with its existing concurrency limitations. State documents use the selected store: files, SQLite or PostgreSQL. Container runs are not supported because the CLI and store are not delivered there.

A document belongs to a project, shared by all its workflows, or to install scope (--install), shared across projects. A node reaches only its run’s project and install scope. Choose names such as review/acme/123 with slash-separated lowercase letters, digits, ., _, or - (1–200 characters total); archon state list --prefix review/ finds them again. A document is any JSON value, so a markdown note is a JSON string; put --raw and get --raw pass it as plain text. Use artifact pointers for large content: a document’s serialized JSON is capped at 64 KiB. Values are fetched explicitly; the engine adds no state to prompts.

Every revision records its writer: the run and node path for a write from inside a run, or the surface (cli) for any other write. It never records who made the change. If a document needs to say where an entry came from (a person, an agent, a tool result), keep that inside the document’s own entries.

put --if-revision 0 creates a document; subsequent whole-document changes must name the revision read. Concurrent replacements have one winner; losers exit 3. patch applies JSON Pointer set, remove, or append operations to the latest revision; concurrent patches serialize instead of racing, though an earlier write can make a later patch invalid. When a patch was computed from what you read, such as setting a field to a value you derived from its old one, pass patch --if-revision <n> so a concurrent change makes it exit 3 instead of being silently replaced. Appending an item, or setting a key only you write, can patch the latest revision without it. An absent document starts as {}; appending to /seen creates the array. Invalid patches or oversized values exit 4 and write nothing. Exit 5 means the CLI opened a different store than the run’s. Exit 1 means usage or storage failure. The CLI reference describes pointer and tombstone semantics.

Let the agent judge what to remember, then commit its structured output in a script node when the write must happen. For example, a review agent returns a completed PR identifier; its dependent host-side node appends that identifier to a ledger:

nodes:
- id: review
prompt: Review the supplied PR and return its identifier after completing the review.
output_format:
type: object
properties:
completed_pr: { type: string }
required: [completed_pr]
- id: commit-state
depends_on: [review]
runtime: bun
with:
completed_pr: $review.output.completed_pr
script: |
const command: string[] = JSON.parse(process.env.ARCHON_CLI_COMMAND!);
const child = Bun.spawn([...command, 'state', 'patch', 'review/ledger'], {
stdin: 'pipe', stdout: 'inherit', stderr: 'inherit',
});
child.stdin.write(JSON.stringify({ops: [
{op: 'append', path: '/seen', value: process.env.INPUTS_COMPLETED_PR},
]}));
child.stdin.end();
if (await child.exited !== 0) throw new Error('State commit failed');

This recipe runs on the host. Container-isolated nodes cannot use archon state in this release: they do not receive the host’s ARCHON_CLI_COMMAND or access to its store. Commit state in a host-side step. $STATE_DIR remains available for free-form files on the host, but it also is not mounted into container runs.

Bash/script nodes already receive WORKFLOW_ID and ARCHON_NODE_EXECUTION; writes record their run ID and node path. Invoke the JSON argv in ARCHON_CLI_COMMAND instead of assuming archon is on PATH. If the nested CLI opens a store that does not know the run, it refuses the write (exit 5) instead of writing to another project. Operators can inspect archon state log review/ledger and correct a value using put --if-revision or patch --if-revision.

An AI node’s agent can also write state itself, by running archon state from its shell. On Claude, Codex, Pi and Copilot the agent’s shell receives the same WORKFLOW_ID and ARCHON_NODE_EXECUTION, and ARCHON_CLI_COMMAND on the host (empty in a container run), so the write records the run and the AI node’s path. Configured project env cannot override these keys. OpenCode runs every node in one shared server that receives no per-node environment: an OpenCode agent’s shell carries whatever identity the process hosting that server had. Its write is recorded as a CLI write from outside any run when Archon was started outside a run, and as the launching node when the run was started from another run’s node. Commit through a script node when the run must be recorded. Any process that sets a run’s WORKFLOW_ID and matching ARCHON_NODE_EXECUTION is recorded as that run: the writer is provenance, not access control.

The ledger prevents lost state updates; it does not make an external review/comment and the state commit one transaction. Use resource admission to serialize work that must not repeat an external action. Retention is operator-run: archon workflow cleanup <days> removes old history but retains each latest revision, including tombstones. Keeping the tombstone prevents a recreated document from reusing an old revision and accepting a stale write.

$STATE_DIR and $ARTIFACTS_DIR both live outside the repo on purpose: run output should never land in a user’s git history, and inside an isolated run anything written to the worktree is destroyed at cleanup. Three sanctioned ways to get content into git anyway:

  1. It is repo content. Documentation, generated code, a committed spec — write it into the worktree like any other file and let the workflow commit it normally. This is not an exception; it is the normal path for anything that is source.
  2. An explicit copy node. Produce the file in $ARTIFACTS_DIR, then add a bash: node that copies exactly what should be versioned into the worktree, and commit it. The copy is visible in the DAG, so “why is this in my repo” has an answer.
  3. Traceability without git. If you only need to find the output later, you do not need it in the repo at all: the artifact routes (GET /api/runs/:runId/artifacts) and output_type sidecars address a run’s output by run id and by type.

An include: node runs another workflow’s nodes as part of this one. This lets you factor a shared block of nodes (for example a multi-step review flow) into its own workflow file and reference it from many workflows, instead of copy-pasting the nodes and letting the copies drift apart.

A node runs with the configuration its own workflow file declares; the run owns isolation, interactivity and evidence policy. That one sentence is the whole rule. A composed workflow that declares provider: codex / model: large / effort: high runs on exactly those, whichever workflow composed it — and a composed workflow that declares nothing resolves from your config, tier presets and personal AI preferences at run time, exactly as it would on its own. The composing workflow contributes ordering and gates; it does not reach inside.

nodes:
- id: pr
include: archon-pr
# Inlines every node from archon-review, attached after pr.
- id: review
include: archon-review
depends_on: [pr]
- id: summary
prompt: "Summarize the review findings for the PR author."
depends_on: [review] # resolves to the included workflow's terminal node

The include target (archon-review here) is an ordinary workflow file discovered by name, honoring the usual precedence (bundled < ~/.archon/workflows/ < repo .archon/workflows/).

At load time each workflow’s node-affecting configuration is written onto its own nodes and then removed from the definition, so nothing can fall back to an outer file’s values:

FieldComposed behaviour
provider, model, effort, fallbackModel, betas, sandbox, persist_sessionsTravel with the workflow, onto its own nodes. A node’s own value always wins.
requiresUnions into the composing workflow, so a missing capability refuses the run at invocation instead of failing mid-block.
inputs, returnsConsumed by composition — inputs: validates the caller’s with:, returns: selects $includeId.output.
outcome_fieldOwned by the workflow being run. An included workflow’s declaration does not propagate to its composer. A top-level composer may declare its own field relative to its own returns:; an include alias is rebound before that contract is validated.
worktree, container, evidence_policy, budgetRun-owned. Whoever starts the run decides these; a composed file’s values are dropped with a load-time warning. Declare them on the top-level workflow.
mutates_checkoutRun-owned. It is dropped with the same warning, including under include: + fan_out:, whose instances run inside the composing run.
webSearchModeDropped, and this one is a real gap. It is the only workflow-level field with no per-node counterpart, so there is nowhere for it to travel. Set it on the top-level workflow — where it then applies to every node in the run.

Two consequences worth knowing:

  • A composition can span providers. Parent on Claude, one composed block on Pi, another on Codex, all in one run — each resolves independently. archon workflow run <name> --dry-run prints each node’s effective provider and model and where the value came from.
  • A composed workflow’s entry node starts a fresh session, exactly as it would if you ran that file on its own, even when the preceding node used the same provider. Set context: shared on the entry node if you deliberately want the caller’s thread to continue into the block.

Expansion happens at load time (discovery), before the workflow ever runs. By the time the executor sees the DAG there are no include nodes left — the inlined nodes are ordinary executable nodes in the include’s containing scope, so runs, events, resume, and answers all behave exactly as if you had written the nodes by hand. A top-level include produces top-level nodes; an include in a loop_group body produces body-local nodes. There is no separate child run.

  • Namespacing. Each included node n receives id <includeId>__<n.id> (double underscore) within that scope. Including archon-review under id: review yields review__scope, review__code, review__synthesize, and so on. These namespaced ids are what appear in the event stream and in archon workflow get <id>; body-local events additionally carry their enclosing group prefix.
  • Edges and command bodies. Internal depends_on edges and $id.output references are rewired to the namespaced ids automatically. This includes named command: and loop.command files: discovery resolves their bodies and compiles them into the flat DAG before namespacing. Compilation recurses through nested loop_group bodies. An unresolved included command cannot start a fresh execution because its references cannot be proven safe. Command nodes fail composition immediately; loop commands retain a private compilation error so a paused loop can still resume from its persisted, validated prompt snapshot. The include node’s own depends_on / when / trigger_rule attach to the block’s entry nodes (those with no upstream inside the block). If both the include and an entry define when: and either condition contains ||, loading fails because the grammar cannot group them without changing precedence; put the gate only on the include or inside the block.
  • Sink asymmetry (a downstream node depending on the include). A depends_on: [<includeId>] on a downstream node fans out to all of the block’s sink nodes (every node with no dependents inside the block), so it waits for the whole block to finish. But $<includeId>.output resolves to only the primary sink — the first sink in definition order (the same terminal-selection rule loop_group uses). For a single-sink block like the review block the two coincide; they differ only when a block has multiple leaf nodes.
  • Output. $<includeId>.output in another node resolves to the block’s primary sink, including when it appears in that node’s command: or loop.command file body. In the example, $review.output is the output of the block’s implement-fixes node.
  • Inside a loop_group body. An include expands inside that sealed body at load time. Its nodes keep the usual <includeId>__<nodeId> names, then the loop runtime prefixes persisted step names with the group id (for example fix-loop.review__synthesize). Every iteration re-runs the expanded nodes exactly like equivalent inline body nodes. The group’s until_bash can read $<includeId>.output and expansion binds it to the block’s declared returns: node. A workflow: node is different — it creates a child run at runtime and remains unsupported inside a loop-group body.

An include can pass an identifier-keyed map of JSON values through with:. The included block uses those values through $INPUTS.<name> in its inline text:

# parent workflow
nodes:
- id: plan
prompt: Plan the requested change.
- id: review
include: reusable-review
depends_on: [plan]
with:
plan: $plan.output
base_branch: main
# reusable-review workflow
nodes:
- id: inspect
prompt: Review $INPUTS.plan against $INPUTS.base_branch.

Input names must start with a letter or underscore and may then contain letters, numbers, underscores, or hyphens. Values may be any JSON value (string, number, boolean, null, array, object) and are inserted verbatim into prompt text during load-time expansion — they are never expressions: nothing is evaluated, computed, or interpreted. A string value is spliced in as text exactly as written; a non-string value keeps its logical type wherever the channel is typed (a nested with: value position, INPUTS_* env delivery) and splices as its canonical JSON text into text surfaces. An inserted $node.output reference remains a reference and resolves through the normal runtime output substitution. A missing input referenced by the block is a load error. Whether extra caller keys are allowed depends on whether the block declares inputs: (see Workflow Signature): a block with no inputs: ignores unrecognized keys; a block that declares inputs: rejects an undeclared key at load.

Substitution applies everywhere the value could reach the model, including inside Markdown code fences and inline code spans — $INPUTS.<name> has no documentation-only meaning, so a fenced occurrence is still a live parameter.

Executable source never receives the value. In an inline bash: body and in loop.until_bash or loop_group.until_bash, the macro becomes a double-quoted reference to the input’s environment variable: $INPUTS.base_branch expands to "${INPUTS_BASE_BRANCH}", so a value containing $(...) or quotes stays data. Write the macro bare: the expansion brings its own quotes, so "$INPUTS.base_branch" becomes ""${INPUTS_BASE_BRANCH}"", which leaves the reference unquoted and splits a value containing spaces. Inside single quotes the reference stays literal, and inside a heredoc its quotes become part of the text; read $INPUTS_<UPPER_SNAKE> directly there. An inline script: body (bun or uv) has no language-neutral quoting form, so $INPUTS.<name> there is a load error naming the variable to read instead (process.env.INPUTS_BASE_BRANCH, os.environ["INPUTS_BASE_BRANCH"]).

Command bodies use the same explicit interface

Section titled “Command bodies use the same explicit interface”

Named command: and loop.command files are the preferred home for substantial prompts and can use both workflow-local $node.output references and declared $INPUTS.<name> values. For an include:, Archon resolves and snapshots the command body during load-time composition, then applies the same input binding and node-id namespacing as an inline prompt. The authored workflow stays command-first; the executor receives a deterministic flat DAG.

Every live reference in the command body must belong to the included workflow’s lexical node scope. A direct $caller.output reference is rejected whether or not the parent happens to have a node called caller; declare an input and pass it with with: instead. Failure to resolve or read an included command is never a warning or best-effort bypass: a fresh execution fails before an AI turn. A paused loop remains resumable from its saved prompt snapshot even if the command is later deleted or made invalid. Canonical references are live even inside Markdown code fences and inline code because runtime substitution is syntax-agnostic.

Named script: files are different: they are opaque programs, not prompt templates. Archon does not scan or rewrite their source, so it delivers a composed workflow’s declared inputs to them the same way it delivers everything else user-controlled — as environment variables. Every bash: and script: node, and each loop.until_bash and loop_group.until_bash completion check (including checks after resume), receives its workflow’s resolved inputs as INPUTS_<UPPER_SNAKE> (hyphens fold to underscores, so base-branch arrives as INPUTS_BASE_BRANCH), whether the body is inline or a named file:

Terminal window
# inside the composed workflow's own script, named or inline
echo "reviewing $INPUTS_PLAN against $INPUTS_BASE_BRANCH"

The names are the ones the node’s own workflow declared, at any nesting depth: a block three files deep still reads its own input names, not the caller’s. Values are delivered through the environment and never spliced into the source, so a value containing shell metacharacters is data, not syntax. In an included bash: or until_bash body the $INPUTS.<name> macro is a shorthand for the same variable (see above); an inline script: body must read the variable.

workflow: sub-runs deliver the same INPUTS_<UPPER_SNAKE> variables, from inputs persisted in the child’s run metadata.

  • No deep access. A parent can read $includeId.output (the terminal) but not the output of an individual node inside the block. The block’s internal node names are an implementation detail.
  • Literal targets only. include: takes a literal workflow name — no include: $something and no cross-repo includes.
  • No child runs inside a loop_group body. A workflow: node remains unsupported there; use include: when the reusable nodes should participate in the same run and repeat as part of the group’s static body.
  • Depth-capped and cycle-checked. Includes may nest up to 3 levels deep; cycles (A includes B includes A) and over-deep chains are load errors that drop only the offending workflow — other workflows still load.

A workflow used purely as a building block still appears in archon workflow list. Mark it as a building block in its description: so it isn’t picked for a standalone run.


Workflow Signature: inputs:, returns:, and $INPUTS

Section titled “Workflow Signature: inputs:, returns:, and $INPUTS”

A workflow can declare a structural signature — what it takes and what it returns — so a reusable block has an explicit, caller-facing contract instead of relying on positional accidents. Two workflow-level fields:

name: review-block
description: Reviews a diff — composes into a parent, and runs on its own
inputs:
diff:
required: true
description: the diff to review
style:
default: strict
returns: synthesize # the node whose output IS this block's result
outcome_field: green # required boolean field that authors this run's verdict
nodes:
- id: gather
prompt: Gather context for $INPUTS.diff (style $INPUTS.style).
- id: synthesize
prompt: Synthesize a review from $gather.output and report whether it is green.
depends_on: [gather]
output_format:
type: object
properties:
review: { type: string }
green: { type: boolean }
required: [review, green]
- id: implement-fixes
prompt: Apply the fixes.
depends_on: [synthesize]
  • inputs: — a map of input name → { required?, default?, description? }. required: true and default: are mutually exclusive (a required input has no default; declaring both drops the key at load with a warning). Values come from with: on the include:/workflow: node that references this workflow, or — for a direct run — from --input name=value on the CLI or the run form in the web console (see Running a workflow that declares inputs). Whichever channel supplies them, the same contract applies: a missing required input and an undeclared key are both rejected, and a declared default: fills an omitted input. A workflow with no inputs: keeps the old lenient behavior: supplied keys pass through unvalidated — they are recorded on the run and still resolve as $INPUTS.<name> / INPUTS_<UPPER_SNAKE>, they are simply never checked against a declaration, because there isn’t one.
  • returns: — the node id whose output IS the workflow’s result. It selects by id, so it works for any node type and even a non-sink node (a node other nodes depend on). For an include: block, $blk.output resolves to the returns: node; depends_on: [blk] still waits on every terminal node. For a workflow: sub-run child, the child’s terminal output (threaded back as $node.output) becomes the returns: node’s output.
  • outcome_field: — an optional, explicit authored verdict relative to returns:. The selected node must declare an object output_format (type: object) whose properties declares the field, whose required lists it, and whose field schema sets type: boolean; otherwise the workflow fails to load. It cannot select a loop_group (which returns raw text) or a fan-out workflow: node (which returns an aggregate array); add a structured collector node after either one. Exact true is persisted as outcome: succeeded, exact false as outcome: failed. This is independent from engine lifecycle status: a run may be completed / failed-outcome, failed / succeeded-outcome, or paused / succeeded-outcome. Status still controls terminality, resume, cancellation, filtering, automation, and CLI exit codes. A workflow with no declaration, a selected node that has not completed, and historical runs read as outcome: null — never as failure. Resume preserves an authored outcome until the selected node actually re-executes and authors a replacement.

An included workflow’s outcome declaration never becomes the composer’s outcome, and a workflow: child owns its outcome on its own run row; neither propagates implicitly to a parent. The REST run list, detail, by-worker, and dashboard JSON expose nullable outcome beside status. CLI, console, chat, and adapter result surfaces show a non-null authored outcome separately from execution status. When outcome is null, they retain the normal status-only presentation. A dry run reports the same declaration separately as nullable authoredOutcome, derived only from the declared return node’s structured fixture value. Its existing outcome remains the simulation result and continues to control CLI exit codes and fixture expect checks. Generated placeholder stubs never author a verdict.

Deterministic checks, authored outcomes, and evidence markers

Section titled “Deterministic checks, authored outcomes, and evidence markers”

These are independent signals:

  • A deterministic check is an ordinary bash: or script: node. Exit zero completes the node; any other exit fails it and therefore affects lifecycle status. A named script read from captured source is also rejected if the run’s full source capture no longer matches its pinned identity.
  • outcome_field records the workflow author’s schema-declared boolean verdict. It does not control lifecycle status. A succeeded outcome can remain on a run that later fails, and a failed outcome can coexist with a completed run.
  • evidence_policy: { required: true } is only a terminal file-presence convention. It requires a host file at $ARTIFACTS_DIR/evidence.json before lifecycle completion. Any contents, including a self-asserted or unsupported claim, satisfy it. The marker proves neither that a check ran nor that an outcome was authored.

There is no special verification node or verification status. Put a check in a deterministic node when its execution must govern success, and use outcome_field only when the workflow must record its own conclusion. Use evidence_policy only when a conventionally named deliverable must exist.

Binding time: includes resolve at load, runs at runtime

Section titled “Binding time: includes resolve at load, runs at runtime”

$INPUTS.<name> is delivered by two deliberately separate paths, and the difference decides which surfaces can read it:

CallerWhen $INPUTS resolvesReaches prompt:/bash:/script:Reaches command: file bodies
include:Load time (the block and command bodies are compiled into the flat DAG)prompt: yes; bash: as a quoted INPUTS_<UPPER_SNAKE> reference; inline script: is a load errorYes — resolved command bodies receive the same input binding before execution
workflow: sub-runRuntime (values become inputs on the child run)prompt: yes; bash: and script: read INPUTS_<UPPER_SNAKE> env varsYes — command-backed prompts receive runtime substitution
direct run (--input / console form)Runtime — the same path as a sub-run; supplied values are persisted to the run’s own metadata, so inputs reconstitute on a cold resumeprompt: yes; bash: and script: read INPUTS_<UPPER_SNAKE> env varsYes — command-backed prompts receive runtime substitution

Both composition paths support command-backed prompts; their binding time differs. Includes snapshot and bind the resolved command body at load time, while sub-runs resolve inputs at runtime. Sub-run inputs are persisted to the child run’s metadata at spawn, so $INPUTS reconstitutes on a cold resume.

$INPUTS in bash:/script: nodes uses env vars

Section titled “$INPUTS in bash:/script: nodes uses env vars”

$INPUTS.<name> text is substituted only into non-shell (AI/prompt) surfaces — a sub-run’s input value can derive from AI output, exactly the user-controlled class kept out of shell source. A bash:/script: node instead reads each input as an environment variable named INPUTS_<UPPER_SNAKE>: hyphens become underscores and the name is upper-cased, so plan → $INPUTS_PLAN and base-branch → $INPUTS_BASE_BRANCH. (Because - and _ both fold to _, two input names that collide on one env key — e.g. foo-bar and foo_bar — are a load error.)

# in a workflow: sub-run child
nodes:
- id: check
bash: |
echo "planning: $INPUTS_PLAN" # NOT $INPUTS.plan — bash reads the env var

A node in a workflow: sub-run child — or in a workflow started directly with --input — can branch on a declared input, not just read one:

inputs:
mode: { default: thorough }
nodes:
- id: quick-pass
prompt: "Do the quick pass"
when: "$INPUTS.mode == 'fast'"
- id: full-pass
prompt: "Do the full pass"
when: "$INPUTS.mode != 'fast'"

The condition resolves the name against the run’s inputs at evaluation time; the value is never spliced into the expression, so an input containing a quote or an operator is compared as data and can never be re-read as syntax. Present object and array inputs fail the node loudly rather than stringify-comparing. An $INPUTS.<name> the run does not carry fails the node, with the same message the prompt surface gives (Unknown input '$INPUTS.mdoe'. Did you mean $INPUTS.mode?) — it never resolves to an empty string, because a condition that quietly compares nothing is the silent-branch failure the when: section warns about.

Declaring inputs: does not make a workflow callable-only. The same file runs on its own and composes into any number of parents — supply the values at invocation:

Terminal window
# one flag per input; repeat it
archon workflow run review-block --input diff="$(git diff)" --input style=terse
# the trailing message is still $ARGUMENTS, independent of --input
archon workflow run review-block --input diff=... "focus on the auth changes"

In the web console, a workflow that declares inputs: renders a field per input in the run card — description as help text, default: as the placeholder, * on the required ones. The Start button stays disabled, and says which input it is waiting for, until every required field has a value. A field left blank is omitted, so it falls back to its declared default:; the console therefore cannot send a deliberate empty string, where --input name= can.

The grammar is deliberately small:

  • --input name=value splits on the first =, so --input msg=a=b gives a=b.
  • Values are plain strings. --input name= supplies a deliberate empty string.
  • Repeat the flag per input; supplying the same name twice is an error, not last-wins.
  • Omit an input to take its default:. Omitting one with no default and no required: true leaves it unset, and a body referencing it fails at the reference site.

Whatever supplies the values, they go through one contract — the same code a caller’s with: map goes through — so a map accepted when composing is accepted here:

  • a required input with no value is refused, naming it;
  • an undeclared key is refused, naming it and listing what the workflow does declare;
  • both refusals happen before any worktree, clone, or AI cost;
  • supplied values are recorded on the run, so a resume replays exactly what the run started with.

One consequence worth knowing on chat, the run route, and the console — the surfaces that reuse a conversation. Invoking a workflow there when a resumable run of it already exists in that conversation continues that run rather than starting a new one, without any resume vocabulary (see DAG Resume on Failure). New inputs values passed on such a call are not applied — the continued run keeps what it started with, and Archon says so, naming the values it ignored. To run fresh with different values, abandon the existing run first (/workflow abandon <id>) and invoke again. The CLI has no such implicit continuation: a plain archon workflow run always starts fresh, and --input with --resume is rejected outright.

Chat platforms (Slack, Telegram, Discord, GitHub) have no channel for inputs yet: they carry only a trigger message, so a required-input workflow invoked there still refuses up front and points at the CLI and console. That gap is tracked in #2555.


Launching a Separate Governed Run with workflow:

Section titled “Launching a Separate Governed Run with workflow:”

A workflow: node runs another workflow as a child sub-run — a genuinely separate workflow_runs record with its own artifacts directory, its own pauses, its own cost line, and its own audit trail. The child’s terminal output threads back into the parent as $<nodeId>.output, exactly like any other node.

Both run another workflow as authored. They differ in how many governance objects exist, and three differences are the ones an author actually feels:

include: — compositionworkflow: — a separate launch
The pauseOne run, one id. A pause in the block is answered on the run that composed it.Two runs. A pause in the child is answered on the child by its own run id; the parent stays paused and auto-resumes when the child finishes.
Command-body freshnessThe block’s command: bodies are snapshotted at discovery, so a resumed run survives the file being deleted or edited.The child re-reads its command bodies live when it starts.

Reach for include: when you want one run made of reusable parts — the common case. Reach for workflow: when you need to answer, inspect or abandon one delegated unit independently of its siblings and of its caller, or when you want N runtime copies of one workflow (fan_out:). That independence is the one thing a single flat DAG cannot express, which is why both keywords exist.

Because a composed block has no run of its own, most launch-only options are load errors on an include: node rather than silent no-ops: isolation:, input:, and mutates_checkout: each fail with a message naming the option and pointing here. A merely unread field (an AI option on an include node, say) still only warns. The one exception is fan_out:, which an include: node accepts as the composed fan-out described in Fanning out a composed block.

nodes:
- id: plan
prompt: "Plan the change described in $ARGUMENTS."
context: fresh
# `workflow:` names any discovered workflow (bundled / global / repo) to run as a
# child sub-run; `qa-block` here is a placeholder for your own workflow file.
# Its terminal output becomes $implement-qa.output.
- id: implement-qa
workflow: qa-block
input: "$plan.output"
depends_on: [plan]
- id: summarize
prompt: "Summarize the sub-run result:\n\n$implement-qa.output"
depends_on: [implement-qa]
context: fresh

A workflow: node has one input channel per invocation: either the untyped input: string (delivered as the child’s $ARGUMENTS) or the named with: map (delivered as the child’s $INPUTS.<name> — see Workflow Signature). Setting both on one node is a load error. Use with: when the child declares named inputs: or when its command: bodies need named values:

- id: implement-qa
workflow: qa-block
with:
plan: "$plan.output"
mode: fast
depends_on: [plan]

Both reuse another workflow. They differ in governance, not syntax:

include: (load-time)workflow: (run-time)
Run recordOne — the block’s nodes flatten into the parent’s runTwo — the child gets its own workflow_runs row
Artifacts / cost / resumeShared with the parentThe child’s own, separate
PausesAnswered on the parent runThe child pauses on its own run, answered by the child’s run id
Output access$includeId.output (terminal only)$nodeId.output (child’s terminal)
When to reach for itTextual reuse of a shared block (e.g. a review sub-DAG)The block must be a separate governance object — separately auditable, gated, and cost-tracked

Rule of thumb: include: for reuse, workflow: for a governed, separately-auditable sub-pipeline.

  • A child pause pauses the whole tree. When the child pauses for an answer, the child run pauses and the parent pauses “blocked on child”. Answer the child by its own full run id (for example archon workflow approve <childRunId>). Read it from archon workflow get <parentRunId> --json at .metadata.approval.childRunId. The stored pause message contains only a short child label; a surface notice may include the full child command. When the child completes, the parent auto-resumes in-process, re-runs the workflow: node, finds the child finished, and threads its output onward.
  • Failure & recovery. A failed child fails the node and the parent run. Recovery is resume-through-parent: /workflow resume <parentRunId> re-drives the failed child once. retry: is not allowed on a workflow: node (a retry would orphan the first child run).
  • Cancel cascade. Abandoning the parent cancels its non-terminal descendants cooperatively: their executors abort at the next status check, within ~10s. A running host bash: or script: node of a run that archon serve executes is ended as described under Bash and script node timeouts, and cancelling a detached run ends its owner’s process group.
  • Cost roll-up. The child’s total cost rolls up into the workflow: node’s cost and the parent’s aggregate, and parent_run_id on the child row makes the run tree visible in archon workflow runs and the console. A run records what it spent whatever its outcome, so a child that burned tokens and then failed or was cancelled still counts — a partly-failed fan-out reports the spend of every child, not just the ones that finished.

Workflow JSONL node_complete.tokens and persisted node_completed.data.tokens use input, output, and optional cacheRead / cacheWrite fields. input is gross prompt input, including cache reads and cache writes. When both cache fields are present, uncached input is input - cacheRead - cacheWrite. An absent cache field means the provider did not report it; 0 means the provider reported zero. Which rows carry the field at all depends on the sink: the transcript carries it only on a row that reports its own spend, while the persisted row carries it on every node_completed row except a loop_group roll-up and a row whose token count is incomplete (see below). So a sum over node_completed.data.tokens counts a composed fan-out’s scope once per level, and data.accounting names each row’s role. Run metadata exposes the same totals as total_tokens_in, total_tokens_out, total_cache_read_tokens, and total_cache_write_tokens. A cache total sums every contributing node that reported that axis; when at least one node did not, the total is a floor and the aggregate carries cachePartial: true (run metadata: total_cache_partial). So input - cacheRead - cacheWrite is exact when cachePartial is absent and an upper bound on full-price input when it is set. An axis no node reported stays absent, and cache usage in records created before this contract cannot be recovered.

Read cost from cost_usd, not from the token counts. Because input is gross, pricing a node by hand means getting four axes and the cache rates right; cost_usd is the number the provider itself reported. A row reports it as its own only when the row’s accounting is node, and no row restates another row’s spend, so summing the cost the transcript’s rows report as their own reproduces the run’s own cost. Count error rows in that sum: a retried attempt that already spent reports on its own node_error row. A row that restates spend its scope already reports elsewhere in the same run — a loop_group roll-up over its body rows, a composed fan-out wrapper over its instances, an instance terminal over its own leaves — omits both usage fields from its transcript row and names itself in execution.accounting instead. The persisted node_completed row keeps cost_usd on every row and marks a restatement with data.aggregate: true; a resume skips a marked row because its scope’s own rows already carry that spend, except a composed-instance terminal, which the resume keeps as the authoritative source for its scope (#3508). JSONL workflow_complete carries the successful run’s totals as cost_usd and tokens; a DAG-owned terminal workflow_error carries the same aggregate when work reported usage before failure. These match run metadata total_cost_usd and total_tokens_*, except that an incomplete token count leaves tokens off both rows (see below). On a row that reports its own spend, an absent cost_usd means the provider reported no cost — Codex reports none — while 0 means it reported zero. On a restatement row an absent cost_usd means the scope’s spend is on the rows that row names. Claude reports a session’s running total, so Archon subtracts the source session’s total from its native session transcript when a node resumes or forks, including across Archon processes. If that persisted state is missing or invalid, the earlier total is unknown and the first result’s cost is absent rather than over-counted. Later results can report incremental cost from the newly known baseline, so the node’s cost_usd can be a partial cost that excludes the first interval. Claude tokens cover the main agent, forked skills, and subagents included in that cost: input (including cache reads and writes), output, cache read, and cache write. Fresh sessions report the full totals; resumes and forks subtract the persisted baseline on all four axes. If the token baseline is missing or incomplete (including older transcripts that stored only output tokens or positive cost with no model totals), or totals are inconsistent, token usage is absent and Archon logs claude.query_tokens_unknown. Tokens remain unknown for the rest of that query, even if later results supply usable totals. The node’s tokens also remain absent across structured-output reasks and later loop iterations after an unknown interval; cost subtraction continues independently. An incomplete count is recorded, not dropped: the node’s execution.spend.tokens (persisted as data.spend.tokens) is { "source": "unavailable", "reason": "incomplete" } and the row carries no tokens. Every total containing that node, through retries, loops, fan-outs, composed instances, child runs, and resume, is incomplete too: the containing rows omit tokens, and run metadata sets total_tokens_unknown: true with total_tokens_in, total_tokens_out, total_cache_read_tokens, total_cache_write_tokens, and total_cache_partial set to null. Cost totals keep summing every reported cost. Do not sum tokens across rows as a run total when any row is incomplete. A run that spent nothing on AI, such as a bash-only workflow, carries no cost_usd rather than 0. Governance nodes do not yet have complete terminal transcript-row coverage, so read usage from the rows that exist rather than treating an absent row as zero spend.

Choosing the child’s checkout with isolation:

Section titled “Choosing the child’s checkout with isolation:”

isolation: decides which working directory the child run executes in. It is valid only on a workflow: node — on any other node type it is rejected at load time, since only a sub-run has a checkout of its own to choose.

ValueThe child runs in
omitted (the default)the parent’s checkout — same files, same branch
inheritidentical to omitting it; write it when you want the sharing to be deliberate rather than incidental
worktreeits own git worktree, on its own branch

Archon never infers this. Nothing about a node — what workflow it names, how many children it spawns, whether they run concurrently — makes a worktree appear. A child gets one when, and only when, you write isolation: worktree. Whether a step needs its own checkout is a judgement about what that step does, and the author is the one who knows.

Most sub-runs don’t need one. A review, a research pass, or a summarizer that writes only to $ARTIFACTS_DIR is better off in the parent’s checkout: it sees the parent’s uncommitted work, and there is nothing to create or clean up afterwards.

What isolation: worktree gives you, and what it costs

Section titled “What isolation: worktree gives you, and what it costs”
- id: refactor-module
workflow: refactor-block
input: "$plan.output"
isolation: worktree # its own checkout, its own branch
depends_on: [plan]

The child gets a fresh worktree under ~/.archon/workspaces/<owner>/<repo>/worktrees/, on a new branch named archon/task-<parentRunId8>-<nodeId>-<hash>-child-<n> — for the node above, archon/task-3f9a1c2b-refactor-module-6fd3f873-child-0. The node id is what keeps two isolated sub-run nodes in one parent from landing in the same worktree; the hash covers node ids too long to fit in a branch name. Four consequences are worth knowing before you reach for it:

  • The branch starts from the repo’s base branch, not the parent’s. The worktree is cut from origin/<baseBranch> in the canonical checkout, not from the parent’s working tree. The base is levels 2–4 of the base-branch precedence table: worktree.baseBranch in .archon/config.yaml, else the codebase’s stored default branch, else git auto-detection. Level 1 is missing on purpose — the per-dispatch --base / --from overrides apply only to the run they were passed to and do not reach its sub-run children, so archon workflow run parent --base release/2.0 still cuts every isolated child from the repo’s configured base. An isolated child therefore sees neither the parent’s uncommitted edits nor the commits the parent made on its own branch. Everything the child needs has to arrive through input:, artifacts, or the repo’s base branch.
  • Nothing merges it back. The child’s commits stay on the child’s branch. Landing them is the workflow’s job — the child pushes and opens a PR, or a later parent node does. What returns automatically is only the child’s terminal output, as $<nodeId>.output.
  • It becomes a tracked environment with a lifecycle. Each child worktree registers an isolation environment, so it appears in archon isolation list next to top-level run worktrees and is governed by the same archon isolation cleanup [--merged] and archon complete <branch>. It is not removed when the child finishes — the branch deliberately outlives the run so you can inspect or land it. Isolate many children and you accumulate many worktrees and branches to clean up.
  • Resume reuses it, and fails if it is gone. As long as the child’s run row exists, a resume reuses the path recorded on it rather than making a second worktree. If that path was cleaned up in between, the node fails with “its working path no longer exists … start a fresh run” rather than dying on a deep ENOENT mid-run. Don’t run isolation cleanup while a sub-run tree is still resumable. (Only if the child’s run row itself is gone does the node spawn fresh — and it lands on the same branch name, since the name is derived from the parent run and the node id.)

Nesting works: a grandchild workflow: node can request its own worktree too, up to the sub-run depth cap.

Creating one needs a git repository and a surface that can make worktrees in it. When the run has neither, the node fails fast — it never quietly falls back to the shared checkout, because a silent fallback would produce exactly the concurrent-write collision the isolation was asked for:

isolation: 'worktree' on sub-run '<name>' requires an injected child-isolation resolver
(available for git-repo codebases run via the CLI or orchestrator). Remove the isolation
or use 'inherit' (shared checkout).

You get this when:

  • the project is a folder project — a directory registered for in-place execution with no Archon-managed git machinery; it may itself be a git repository (Multi-Repo Projects). Archon does not manage worktrees for folder projects. Use inherit, or target a separately registered repo project.
  • the run resolved no codebase at all (for example a background dispatch with no project bound, or a database lookup that failed at run start).

Whether the parent is isolated makes no difference. A parent started with --no-worktree, running in your live checkout, can still hand an isolated child its own worktree — which is a reasonable shape when the parent only reads and one step writes.

archon validate workflows cannot catch this. Whether a worktree can be created is a property of the run, not of the file, so a workflow using isolation: worktree validates cleanly everywhere and then fails at the node when it is run somewhere it can’t be honored. If a workflow only makes sense against a git repo, say so in its description:.

A worktree that fails for an ordinary git reason — no disk space, a permission problem, a branch that has diverged from its remote — fails the node the same way, with a readable message.

Two workflow: nodes in the same DAG layer start their children at the same time. What happens next depends on whether those children share a checkout.

Children in their own worktrees (isolation: worktree) never interact — separate directories, separate branches.

Children sharing the parent’s checkout meet the engine’s path-exclusive lock. Every run takes a lock on its working path at start, and a run that finds the path already held by another active run cancels itself. The lock excludes a run’s own ancestors and descendants — a child never blocks against its own parent. Children of the same fan_out node are also excluded and may share the checkout. Children of different workflow: nodes are not excluded. Two such children in one layer over one checkout collide: the older run keeps the path, the younger one cancels itself, and the parent run fails.

Resume does not recover from this. A parent’s resume re-drives a failed child, but a cancelled one is threaded straight through as it stands — so a parent that failed this way fails again identically on every resume. The only way out is a fresh run. Avoid the collision; don’t plan to recover from it.

The way to avoid it is to declare what the child does, on the child workflow itself:

# review-block.yaml — reads the repo, writes only to $ARTIFACTS_DIR
name: review-block
description: Reviews the diff and writes findings. Composes into a parent; also runs on its own.
mutates_checkout: false # skips the path lock: N of these coexist in one checkout
nodes:
- id: review
command: review-diff

mutates_checkout: false is a workflow-level field asserting that the run does not write to its checkout, so the engine skips the path lock for it. It defaults to true (take the lock, serialize runs on the same path). It is author-declared on purpose — the author of a review or research workflow is the one who knows it only reads. Children launched by separate workflow: nodes still compete for the path lock unless they declare it. Children of the same fan_out: node and their nested sub-runs may share the checkout without that declaration; see fanning out over a list.

For concurrent 1:1 children launched by different nodes, choose according to the children:

The children…Do this
only read the repo (review, research, summarize)mutates_checkout: false on the child workflow
write to the repoisolation: worktree on each workflow: node
must not overlap at allsequence them with depends_on

Each 1:1 child run presents its own pause independently, addressed by its own run ID. Concurrent children can both pause for an answer; the parent stays blocked until its children finish. Within a single run, pauses are presented one at a time, and deferred pauses re-run when that run resumes.

fan_out: turns one workflow: node into N child runs — one per element of a list produced at run time — and reduces their results back into a single node output. Each child is a full governance object in its own right: its own run record, artifacts, cost line and audit trail, exactly like a 1:1 sub-run.

nodes:
- id: pick-files
bash: |
git diff --name-only origin/main \
| jq -R -s -c 'split("\n") | map(select(length > 0))'
# review-one-file declares `mutates_checkout: false` — see "Isolation" below.
- id: review-each
workflow: review-one-file
depends_on: [pick-files]
fan_out:
items: "$pick-files.output" # must resolve to a JSON array
max_parallel: 3
# join defaults to all_done: one file failing to review still yields the rest
- id: summarize
prompt: "Summarize these per-file reviews:\n\n$review-each.output"
depends_on: [review-each]

Each item becomes one child’s $ARGUMENTS. $review-each.output is a JSON array of the children’s terminal outputs in item order, not completion order — so a downstream node can line results up against the input list positionally.

FieldDefaultWhat it does
itemsrequiredA $node.output (or $node.output.field) reference that must resolve to a JSON array at run time. Anything else — an object, a bare string, malformed JSON, a dangling ref — fails the node before any child is created. It never fans out over the characters of a string, and never silently degrades to zero items. An empty array is legal: the node completes immediately with [].
as—Names the current item as $INPUTS.<as> in each child. It must not collide with a with: key. The item also reaches the child as $ARGUMENTS.
max_parallel5How many children may be in flight at once.
joinall_doneHow N child outcomes reduce to one node outcome (below).

The items producer must be an upstream dependency — the loader rejects a reference to a node this one doesn’t transitively depend on, so the array can never be read before it is written.

max_parallel bounds concurrency, not count

Section titled “max_parallel bounds concurrency, not count”

max_parallel is a sliding window, not a limit on how many children exist. items is unbounded: a 400-element array produces 400 child runs regardless of the window. Two consequences worth planning for:

  • Cost scales with items.length, not with max_parallel. Bound the list in the producer node, not here.
  • A child that pauses keeps its slot until it finishes, so unanswered children can hold the whole window and later items wait. Paused children use no process or provider, so raising max_parallel costs nothing while they wait.
  • Abandoning the parent cascade-cancels at most 500 descendant runs (MAX_CASCADE_RUNS). A fan-out wider than that can leave children uncancelled and still billing; they have to be abandoned individually. A run-tree-wide budget ceiling is tracked in #1961.
joinThe node succeeds when…$<id>.output
all_done (default)every child reached a terminal state and at least one child run was created (or the item list was empty)JSON array in item order — each element is the child’s result value (a structured child’s terminal payload lands as the object itself, single-encoded; a text child’s output stays the raw string), with each failed/cancelled child represented as { archon_failed: true, error, status } in its slot
all_successevery child completedsame array; any failed or cancelled child fails the node instead
first_success—Racing: rejected, not deferred — see below. Rejected at load rather than silently treated as another join

Every child runs to its own terminal state before the join reduces, under both joins. A child that fails does not stop its siblings, does not stop later items from being spawned, and does not change any other child’s outcome. all_success still fails the node if any child failed — it just reaches that verdict after everyone has finished rather than by ending the others early. The failure message names the child that failed.

A non-empty workflow: fan-out fails if no child run row was created, even with all_done. The error names the node, the number of refused children, and the first refusal reason. Resume re-dispatches this failed node. If at least one child run was created, all_done still completes with the same failure markers for failed and refused children, even if every child that started failed. An empty item list still completes with [].

Resume skips a completed fan-out and reports how many children were previously refused. It does not repeat children that ran, including failed children in an all_done batch. Resume uses the captured parent source, so correcting a binding in that source requires a fresh launch. Project child workflows still resolve from the live authoring directory when spawned; installed packs remain captured. A transient refusal may also clear before resume.

Because fan-out children are independent. Two research children with different scopes, or ten triage children over ten issues, are not one job split ten ways — they are ten jobs that happen to run together, and one of them failing says nothing about the other nine. If the default were all-or-nothing, a single failed child would discard nine good results at the join, after you had already paid for them.

So the default treats failure as data. Every terminal outcome reaches the aggregate, failed ones as { archon_failed: true, error, status } in their slot, and the node succeeds. What to do about the gaps is then an ordinary decision made by an ordinary node:

- id: triage-each
workflow: triage-one-issue
depends_on: [list-issues]
fan_out:
items: "$list-issues.output" # join: all_done — the default
- id: check
script: |
const results = $triage-each.output;
// A failed slot carries the engine's reserved `archon_failed: true` marker;
// everything else is the child's own result value (an object for a structured
// child, a raw string for a text child — single-encoded either way, parse
// nothing twice). Check the marker key, NOT the presence of error/status
// fields — a child whose own schema declares `error` and `status` is still a
// result, and only the marker separates it from a failed slot.
const failed = r => r !== null && typeof r === 'object' && r.archon_failed === true;
const ok = results.filter(r => !failed(r));
console.log(JSON.stringify({ ok: ok.length, total: results.length }));
runtime: bun
depends_on: [triage-each]
- id: report
prompt: "Summarize the $check.output.ok successful triages:\n\n$triage-each.output"
depends_on: [check]
when: "$check.output.ok != '0'"

That shape is deliberate, and it is why there is no join value meaning “succeed if at least K children completed”. How many results are enough is judgement about your work, not a join rule — it depends on which children failed and why, and it changes between runs. A script or prompt node reading the aggregate can weigh that; an enum cannot, and adding a threshold would start a policy language inside a YAML field. when: gates whatever comes next.

Use all_success when the children genuinely are one job — when a gap makes the aggregate meaningless rather than smaller. That is the uncommon case, which is exactly why it is the one you have to ask for.

join: first_success — run N children, keep whichever finishes first, drop the rest — is rejected, not postponed. Writing it fails at load with a message saying so.

Racing only works by ending the losers: the moment a winner appears, the others are aborted and cancelled. That is one child’s outcome deciding its siblings’, which is precisely the coupling the independence rule forbids — and it cannot be reshaped, because a race that lets the losers finish is not a race.

The want underneath it is real: several genuinely different attempts, best result forward. That is served without any mutual cancellation — write the attempts as separate nodes, each with its own model or prompt, all feeding one collector node that picks:

- id: attempt-a
prompt: "Solve $ARGUMENTS using the existing helper."
model: large
- id: attempt-b
prompt: "Solve $ARGUMENTS from scratch."
model: medium
- id: pick
prompt: "Two attempts. Choose the better and explain why.\n\nA:\n$attempt-a.output\n\nB:\n$attempt-b.output"
depends_on: [attempt-a, attempt-b]
trigger_rule: none_failed_min_one_success

This is strictly better than racing at what racing was wanted for: the attempts can differ by model, which a fan-out cannot express, every output is preserved for the collector to weigh instead of thrown away, and selection is a judgement made by a node that can read the work rather than a stopwatch.

This is a deliberate trade, and the cost is yours to plan for: a fan-out whose first child fails still runs every remaining child. Worst-case spend is items.length attempts, not “until the first failure”. max_parallel caps how many run at once, never how many run in total, so a 200-item fan-out over a child that fails on item 1 still costs 200 children. Bound the list in the producer node if that matters, and treat the abandon-cascade note above as a real limit rather than a footnote — this is what makes #1961’s budget ceiling load-bearing.

Fan-out changes nothing about isolation:. Without isolation: worktree, children share the parent’s checkout and run up to max_parallel, including children that write to it. The engine excludes children of the same fan_out node and their nested sub-runs from one another’s path lock. Agents own coordination of their edits; the engine adds no coordination mechanism between them.

Each child’s workflow.checkout_shared events name its siblings on that checkout through sibling_run_id, with parent_run_id, parent_node_id, and working_path. This records checkout sharing, including children that use it at different times, rather than claiming their execution overlapped. Unrelated runs and children of different nodes still compete for the path lock.

Use isolation: worktree explicitly when every child should have its own checkout and branch, or max_parallel: 1 when children should run serially. Workflow-level mutates_checkout: false still skips the path lock; node-level mutates_checkout: false still enforces the checkout guard. Neither declaration is required to share a checkout within one fan-out.

A fan-out child may pause like any run: at a wait: node, or at a deprecated approval: gate or interactive loop. It stays paused as its own run and its own queue item, and nothing cancels it for pausing. Its siblings keep running to their own terminal states.

  • The parent waits. Once no more children can start, the parent pauses on a fan_out gate that lists every unfinished child (item <index>: run <id>) and how many items have not started yet. archon workflow wait on the parent wakes with the first child, in item order, that needs an answer.
  • Answer each child by its own run id. Approving or rejecting the parent is refused with a redirect naming its children. When the children it waits on finish, the parent continues on its own, starts any items that were waiting, and joins exactly as above. A child that failed before the pause is reported as failed, not run again.
  • A paused child keeps its max_parallel slot. With max_parallel: 1, items after a paused child wait until it is answered and finishes, so children never overlap in a shared checkout.
  • Abandoning the parent cascade-cancels its paused children with the rest of the tree.
  • Abandoning one child leaves the parent paused. Resume the parent; it reads that child’s outcome as cancelled, and the join treats it like any cancelled child.

Pauses around the node work as usual: a wait: attention node before the fan-out is an ordinary parent pause, and one after it reads $<id>.output holding the full aggregate.

Children are keyed by their position in the item list (metadata.child_index), which is what makes a parent resume cheap and predictable:

  • Completed children are threaded from their existing rows — never re-run, never re-billed.
  • When you resume a fan-out node that failed, failed children are re-driven in place, in the same row. Children Archon itself cancelled (for example an orphan whose item disappeared) are tagged and re-driven too. A child you cancelled out of band stays cancelled and is never resurrected.
  • When the parent continues after its paused children finish, nothing that already settled is re-driven: a failed child stays failed in the aggregate.
  • A child left running or pending by an interrupted process is not auto-cancelled — Archon can’t tell a crash orphan from a live run elsewhere. The node fails with the child’s run id and tells you to wait or abandon it.

Because the key is the index, the items list changing between attempts matters:

  • The list got shorter — a child whose index no longer exists is logged and, if still running, cancelled as an orphan. It is never silently dropped.
  • An item at some index changed (a non-deterministic producer) — resume still re-keys by index and warns (workflow.fan_out_item_content_changed) that a child’s item is not the one it was spawned with. In the normal case this can’t happen: the producer’s output is cached from the first attempt and replayed on resume. It shows up when the producer node is marked always_run: true, or when its output genuinely isn’t stable.

If you want a fan-out whose item list is guaranteed identical across attempts, keep the producer deterministic — write the list to $ARTIFACTS_DIR and read it back rather than re-deriving it.

  • Choose one input form — input: sends a single data string as $ARGUMENTS; with: supplies named $INPUTS values. The two forms are mutually exclusive.
  • No racing (join: first_success) — rejected outright, not deferred (see Why there is no racing join).
  • Not inside a loop_group body — a workflow: node, fanned out or not, is rejected there at load time (#2439).
  • Static target only. workflow: takes a literal workflow name — no workflow: $something. Self-reference and ancestor cycles (A → B → A) are rejected at run time, and the sub-run tree is depth-capped.

Fanning out a composed block inside the run (include: + fan_out:)

Section titled “Fanning out a composed block inside the run (include: + fan_out:)”

A workflow: node with fan_out: gives every item its own governed child run. When runtime cardinality does not justify separate run identity — N review comments on N files that all belong to this run’s audit trail — put fan_out: on an include: node instead: the statically named block is expanded once per item inside this run.

nodes:
- id: pick-files
bash: |
git diff --name-only origin/main \
| jq -R -s -c 'split("\n") | map(select(length > 0))'
# annotate-file is an ordinary composable block (a workflow file with its own
# inputs:/returns: signature). It runs N times HERE — one parent run row, no children.
- id: annotate-each
include: annotate-file
depends_on: [pick-files]
with:
style-guide: "${ARTIFACTS_DIR}/style.md"
fan_out:
items: "$pick-files.output"
as: file # each instance reads $INPUTS.file
max_parallel: 3 # join defaults to all_done, like a workflow: fan-out
- id: collect
prompt: "Merge these per-file annotations:\n\n$annotate-each.output"
depends_on: [annotate-each]

What you get, compared with the workflow: fan-out above:

  • One governance object. No child workflow_runs rows, artifacts directories, cost lines or gate sets per item. Every instance’s events are written under the parent run id, under engine-owned instance-qualified step names. Their exact storage form is not part of the workflow authoring contract.
  • The same package and signature semantics as a static include:. Command/script bodies resolve from the exact packaged source captured for the run, returns: selects the aggregate value, and $INPUTS binding follows the block’s declared inputs:. The item binding named by fan_out.as is required; there is no implicit $ARGUMENTS fallback.
  • Ordered aggregate. $<id>.output is a JSON array of the instances’ terminal outputs in item order, not completion order; under join: all_done a failed instance contributes the { archon_failed: true, error, status } marker. An empty item list completes immediately with []. Under join: all_success any failed instance fails the node.
  • Content-addressed resume. Each instance is keyed by a hash of its item value (plus an ordinal for byte-identical duplicates). The first ordered snapshot is persisted before anything schedules and remains authoritative: a resume ignores and logs a producer list that is now shorter, longer, or reordered. Completed instances and their completed inner nodes are threaded from persisted events instead of re-running or being billed twice. If an instance has a durable start but no terminal event, Archon cannot prove whether its side effects finished and blocks automatic replay for operator recovery.

Two hard limits, both enforced at load time before any upstream node can spend:

  • No suspension inside the complete block closure. The parent has no durable per-item pause cursor, so a block containing a wait: node, a deprecated approval: node or interactive loop:/loop_group:, or a nested workflow that can pause is rejected. Put the pause in the parent around the node, or invoke the block 1:1. When items may pause, use a workflow: node with fan_out: instead: each item is its own run and may pause and be answered on its own (see Children that pause).
  • No isolation: — a composed block runs inside this run and has no checkout of its own to isolate. Instances share the parent’s checkout and run concurrently up to max_parallel, as workflow: fan-out children do; instances that write must coordinate their edits. fan_out.max_parallel: 1 runs them one at a time.

Cancellation stops the pool from claiming new items. An in-flight item may still reach its own terminal event; under all_done, a cancelled slot is represented by the same reserved { archon_failed: true, error, status: "cancelled" } marker used by child-run fan-out. Instance and wrapper events are aggregate accounting rows, so their rolled-up usage is not counted again during resume. Engine-managed artifact names also carry the enclosing loop_group iteration path, preventing later iterations from overwriting earlier sidecars.

Choose by where the audit boundary belongs: one run row per item → workflow: + fan_out:; everything attributable to this run → include: + fan_out:. Existing workflow: fan-outs keep their exact behavior either way — nothing is migrated or reinterpreted.


A node’s output is a value, and the node that produces it owns that value’s shape. output_format is the declaration, and it is the only one: there is no separate workflow-level result schema to keep in sync with the node that actually produces the result.

QuestionAnswer
What is a workflow’s result?The output of the node named by returns: — or, with no returns:, the run’s terminal output.
Who declares its shape?That node, through its own output_format.
Which nodes can declare one?Any node whose output is a value it computes: prompt:/command: AI nodes, bash:/script: nodes, and loop: nodes.
What does declaring one buy?The value is validated before anything downstream can read it, its JSON type survives the handoff, and $node.output.<field> becomes strict — an undeclared name fails the consuming node instead of resolving to ''.
Who repeats the declaration?Nobody. A downstream node, an include: alias, and a workflow: caller all read the producer’s contract.

A malformed schema is a load error. archon validate workflows and every run compile each declared output_format before a provider is called, so a contract can never silently stop being enforced after the money is spent.

The engine records terminal facts even when failure skips your reporting node. Use archon workflow get <run-id> --json to read terminal_record, or read run.terminal_record from GET /api/workflows/runs/:runId. The record preserves execution status, authored outcome, node states and skip causes, the selected returns: value when available, and an artifact manifest. Execution status and authored outcome remain independent; the engine does not infer a delivery verdict from filenames or output prose. Runs created before this support may have no record. Resumed active runs expose null until their next terminal transition. The same response’s run.nodes carries the engine’s node states for every run: the record’s when it exists, otherwise the same fold over the events recorded so far. Active, resumed, and older runs therefore show each node’s current state.

The manifest contains file metadata, not content previews. Its limitations identify incomplete observations. Cancellation can record pending or running nodes and files that are still changing; terminalization does not make the filesystem snapshot atomic. You do not need a new YAML field or collector node to obtain these facts.

A returns: node on the success path is skipped when an upstream node fails, leaving terminal_record.returns unavailable with reason node_not_completed. Keep that success composer and add a reporter as returns:. With trigger_rule: all_done, it runs after the composer completes or is skipped; { from: "$result.output", if_skipped: null } lets it pass the composer’s value through or author a not-done report when the binding is null. always_run: true re-executes the reporter on resume, so a report that reads artifacts reflects the current pass instead of replaying a cached completion.

name: failure-report
description: Preserve an authored report after a node failure
returns: report
outcome_field: ok
nodes:
- id: fail
bash: exit 1
- id: result
depends_on: [fail]
bash: 'printf ''{"ok":true,"summary":"Done"}'''
- id: report
depends_on: [result]
trigger_rule: all_done
always_run: true
with:
result: { from: "$result.output", if_skipped: null }
script: |
const result = process.env.INPUTS_RESULT;
console.log(result === 'null'
? JSON.stringify({ ok: false, summary: 'Not done: a node failed before the result.' })
: result);
runtime: bun
output_format:
type: object
properties:
ok: { type: boolean }
summary: { type: string }
required: [ok, summary]

The run’s status stays failed; the authored outcome follows the reporter’s outcome_field: (ok: false here). The reporter owns the result schema and validates the value it passes through. Bind only the composer: a failed producer always fails a binding, so if the composer itself fails, this reporter fails too. Cancellation runs no further nodes, and an executor crash can prevent reporting; this pattern covers ordinary node failures that skip the composer, not every terminal path.

An include: caller waits on the block’s sinks. When this reporter is its only sink and completes, nodes depending on the block can run even after a node inside it failed. Gate success-only work on the returned verdict, for example when: "$block.output.ok == true". The enclosing run still fails and its terminal record still names the failed node.

A deterministic producer owns the same contract

Section titled “A deterministic producer owns the same contract”

Declare output_format on a bash: or script: node and that node certifies its own stdout:

nodes:
- id: plan
script: build-plan-result
runtime: uv
output_format:
type: object
properties:
ready: { type: boolean }
units: { type: array, items: { type: object } }
required: [ready, units]

The node’s stdout must parse as one strict JSON document and satisfy the schema. There is no code-fence stripping, no repair pass and no reask — stdout that does not match its own declaration is a bug in the script, so the node fails, naming the offending JSON path and quoting the start of stdout. That failure is never retried: a retry: block re-runs subprocess and transient failures, not a script whose stdout is deterministically wrong. On success the canonical document becomes $plan.output, the logical value feeds bindings and fan_out.items, and $plan.output.units is strict in exactly the same way as an AI node’s field access.

This is what makes a script and an agent interchangeable as the producer behind a returns: node: the contract belongs to whichever node produces the result, not to its kind. A bash:/script: node with no output_format is unchanged — stdout stays raw text, and a .field reference falls back to parsing that text. See Script nodes → Declaring a result contract for the script-side detail.

What include: and workflow: each guarantee

Section titled “What include: and workflow: each guarantee”

Both composition forms deliver the callee’s contract to the caller. They differ only in where the value comes from.

include:workflow:
Where the producer runsThis run, after flatteningA governed child run
What $node.output.field is authorized byThe included block’s returns: node — the alias is rewritten to it at load time, so there is no boundary leftThe child’s returns: node, whose declared field names travel back with the result
Caller-side output_formatIgnored with a load warning; there is nothing to assert againstA load error naming the child’s returns: node; there is nothing to assert against
After a cold resumeRe-derived from the loaded block, exactly like any other nodeRestored from the persisted result, so a parent that declares nothing keeps full field access

A workflow: node cannot declare output_format. The loader rejects the file:

Node 'sub' declares output_format on a workflow: node; the result contract belongs to the child's returns: node — declare it there

The child’s contract is the only one. $sub.output.field is strict exactly when the child’s returns: node declares a schema, and a schemaless child (no output_format on its selected node, or an older run) carries no field contract — the caller cannot add one.

A fan-out node is the one exception to “the producer declares it”. $fan.output is an engine-owned ordered array; the per-item contract belongs to each block or child, and field access on the aggregate is an error. Consume the whole array.

A small result that points at a large file

Section titled “A small result that points at a large file”

Most useful workflows produce something too big to be a node output — a plan, a report, a diff. Write it under $ARTIFACTS_DIR and return a small result that points at it:

nodes:
- id: plan
command: plan # writes $ARTIFACTS_DIR/plan.md
output_format:
type: object
properties:
ready: { type: boolean }
plan:
type: object
properties:
type: { const: archon_artifact }
run_id: { type: string }
path: { type: string }
required: [type, run_id, path]
required: [ready, plan]
{ "ready": true, "plan": { "type": "archon_artifact", "run_id": "$WORKFLOW_ID", "path": "plan.md" } }

type: "archon_artifact" is reserved. Before the value is persisted, the producing node proves each pointer names its own run (run_id is $WORKFLOW_ID) and addresses a regular file that exists under that run’s artifacts directory. An absolute path, a .. segment, a missing file, a directory, or any other run’s id fails the producing node, naming the run, the path, and the rule. The check runs once, at the producer: a workflow: parent and a fan-out aggregate relay the child’s value unchanged, because the child already certified it against the only run it may name.

The value stays a run id plus a relative path everywhere — in events, in the API, and after a resume. The engine never expands it into an absolute path and never loads the file, and there is no in-workflow resolver: a pointer is for machine readers, and GET /api/artifacts/<run_id>/<path> serves exactly that file. The read side owns reachability and real-path checks by design; that route does lexical containment today, and real-path resolution at read time is not yet implemented (#3160). A prompt inside the producing run keeps using the $ARTIFACTS_DIR/<path> string, which is the same file on disk. See Artifact pointers for the full rule set.

Everything above composes. A deterministic producer declares the contract; the block’s returns: publishes it; the caller reads a declared field as a real array and fans a composed body out over it; each instance certifies its own per-item result:

# plan-block.yaml — the callee. Its result IS the `build` node's declared value.
name: plan-block
returns: build
nodes:
- id: build
script: build-plan-result
runtime: uv # writes $ARTIFACTS_DIR/plan.md, prints the JSON below
output_format:
type: object
properties:
units: { type: array, items: { type: object } }
plan:
type: object
properties:
type: { const: archon_artifact }
run_id: { type: string }
path: { type: string }
required: [type, run_id, path]
required: [units, plan]
# the caller — no schema of its own anywhere
nodes:
- id: plan
include: plan-block # $plan.output resolves to plan-block's `build` node
# unit-block is an ordinary composable block declaring `inputs: { unit: … }`.
# Its own `returns:` node declares the per-item contract, so $work.output is an
# ordered array of validated objects rather than an array of prose.
- id: work
include: unit-block
depends_on: [plan]
fan_out:
items: "$plan.output.units" # a declared field, resolved as a real JSON array
as: unit
max_parallel: 2
- id: report
prompt: |
Per-unit results: $work.output
Full plan: $plan.output.plan
depends_on: [work]

Swapping include: plan-block for workflow: plan-block changes the governance boundary — a separate child run with its own artifacts, cost, and gates — and nothing else about the contract: $plan.output.units and the pointer read exactly the same.


Workflows can configure AI models and provider-specific options at the workflow level.

Model and options are resolved in this order:

  1. Workflow-level - Explicit settings in the workflow YAML
  2. Config defaults - assistants.* in .archon/config.yaml
  3. Provider defaults - Built-in defaults of the Claude Agent SDK and of Codex (including your own Codex config.toml)

For the Claude SDK advanced options (effort, fallbackModel, betas, sandbox) a per-node value sits above the workflow level: a node uses its own value if set, otherwise it inherits the workflow-level default. See Claude SDK Advanced Options.

name: my-workflow
provider: claude # Any registered provider (default: from config)
model: medium # Tier, alias, or literal model override

model: accepts three shapes:

  • small, medium, or large - portable tier refs resolved from tiers: in ~/.archon/config.yaml and .archon/config.yaml, with built-in defaults for claude and codex only
  • @name - custom aliases from aliases:; use these for project workflows, not bundled or global workflows, because aliases are project-specific
  • Any other string - a literal model id passed through to the resolved provider’s SDK

Tier and alias refs resolve to a provider, model, and optional effort. If a workflow or node sets both provider: and a model ref that resolves to a different provider, Archon warns and uses the provider from the resolved preset. Literal model strings keep the normal provider chain (node.provider ?? workflow.provider ?? config.assistant).

Archon does not keep an internal allow-list for literal model ids because vendor SDKs ship new models faster than this doc can. The provider’s API decides whether a literal string is valid at request time.

Common shapes you’ll see in practice:

  • Claude (Anthropic): family aliases (sonnet, opus, haiku), full model IDs (claude-opus-4-7, claude-3-5-sonnet-20241022), context-window suffixed forms (opus[1m], claude-opus-4-7[1m]), or inherit to reuse the previous session’s model.
  • Codex (OpenAI): any OpenAI model ID — gpt-5.6-sol, gpt-5.6-terra, o5-pro, etc.
  • Pi: <backend>/<model-id> refs — e.g. google/gemini-2.5-pro, openrouter/qwen/qwen3-coder.
  • Copilot (community): GitHub Copilot model names — e.g. gpt-5, gpt-5-mini, claude-sonnet-4.5, or auto.

If the SDK rejects a literal string at request time, the node fails loudly with the SDK’s error message. Use portable tiers for cross-provider workflow defaults, and pair provider-specific literal strings with an explicit provider: on the workflow or node.

name: my-workflow
provider: codex
model: gpt-5.6-sol
effort: medium # Reasoning depth — the one spelling, any provider
webSearchMode: live # 'disabled' | 'cached' | 'live'

Web search mode: controls Codex’s built-in web-search tool. It is not a network switch — Codex nodes always run with network access enabled, so disabled stops Codex from searching, not from reaching the network.

  • disabled - No built-in web search (default)
  • cached - Use cached search results
  • live - Real-time web search

webSearchMode: is workflow-level only — there is no per-node form, and no other provider reads it. Declaring it on a workflow whose node resolves to a provider other than Codex logs a warning and applies nothing, the same way any other unsupported option does.

Workflows started from the Web UI run in the background: execution is dispatched to a worker conversation, and the run’s output appears in its run view. A workflow that pauses, at a wait: attention node or a deprecated approval or interactive loop gate, pauses durably however it was started. The console’s Needs your input banner lists the paused run with its message and controls, and the CLI answers it by run id. The same holds for a --detach launch and a trigger start.

Workflow-level interactive: is deprecated and has no effect. A workflow that still declares it loads with a warning; remove the line.

Workflows are validated at load time for provider identity only:

  • Both the workflow-level provider: and any per-node provider: overrides must name a registered provider (claude, codex, pi, copilot).
  • Validation errors are shown in /workflow list.

Example validation error:

Unknown provider 'claud'. Registered: claude, codex, pi, copilot

Tier and alias model refs are resolved during workflow validation so malformed tiers: / aliases: config, unknown aliases, and missing tier presets fail before execution. Literal model strings are not API-validated by Archon; they are forwarded to the SDK and validated by the upstream API at request time.

To validate that all referenced command files, MCP config files, and skill directories exist on disk, run:

Terminal window
archon validate workflows <name>

This checks resource resolution beyond what load-time validation covers. Bundled and global workflows also reject @custom model aliases because those refs are not portable across projects. Use --json for machine-readable output. See the CLI Reference for details.

A key Archon does not recognise is dropped from the parsed workflow — the YAML still loads and the workflow still runs. Because a dropped key can be one an author believed was doing something (the classic case is interactive: true on a command node, which reads like a pause and is not one), Archon reports every dropped key as a warning naming the key, where it was found, and what to write instead:

WARNING [parse_warning] Node 'plan': unknown key 'interactive' will be ignored.
Nothing on this node pauses. To ask for an answer, add a 'wait: { attention:
<message> }' node; to ask on each iteration of a loop, use a 'loop_group' whose
body ends in one.

(The WARNING [parse_warning] prefix is archon validate workflows formatting; the other surfaces below render the same message text differently.)

What is checked. The workflow root, every node, the nested config blocks (wait:, the deprecated approval: and approval.on_reject:, retry:, loop:, loop_group:, pi:, each agents: entry, worktree:, container:, evidence_policy:), and every node inside a loop_group body.

What is exempt, because nothing is dropped from these — a key you write is a key that survives:

BlockWhy exempt
output_format:Free-form JSON Schema; every key is accepted
sandbox:Passthrough — unknown keys are preserved, not stripped
hooks:Strict — an unknown key is already a hard error, not a warning

Where the warnings appear.

SurfaceWhere
archon validate workflowsA WARNING [parse_warning] issue (also in --json)
archon workflow listInline under the workflow; parseWarnings on each --json entry
archon workflow runOn stderr before the run starts (--detach --json keeps stdout to the payload)
Chat (/workflow list)Inline with the workflow that raised it
Any run that startsRecorded on the run as a workflow_parse_warnings event — always
Chat / console (starting a run)Also posted to the conversation, best-effort
Console workflow pickerA ⚠ marker on the row; full text in the tooltip

Recorded on the run, whatever started it. When a run begins, the engine writes the dropped keys to the run’s event log as workflow_parse_warnings. This happens for every run — CLI, chat, console, REST, and sub-runs — not only the ones with a conversation to post into, and it is written by the engine rather than by the notification path, so a failed message cannot take the record with it. Read it back with:

Terminal window
archon workflow get <run-id> --verbose # human-readable
archon workflow get <run-id> --verbose --json # `parseWarnings` on the payload

(--verbose is required: the plain form returns the run row without reading the event log.)

The chat/console message at run start is a notification on top of that record. It is sent once and not retried: if the platform call fails (a revoked token, a rate limit) the run still starts and that message is lost, leaving a WARN log line — failing a run over an undeliverable warning would be worse. The finding is not lost with it; it is on the run, and still on validate, list, and the console picker.

Known gap — include:. Warnings belong to the file that declared the key. If workflow A include:s workflow B and B has an unknown key, the warning is reported against B, not against A. Running A surfaces nothing. Check the included block directly (archon validate workflows <block-name>) when auditing a composed workflow.

Example: Config Defaults + Workflow Override

Section titled “Example: Config Defaults + Workflow Override”

.archon/config.yaml:

assistants:
claude:
model: haiku # Fast model for most tasks
codex:
model: gpt-5.6-sol
modelReasoningEffort: low
webSearchMode: disabled

Workflow with override:

name: complex-analysis
description: Deep code analysis requiring powerful model
provider: claude
model: opus # Override config default (haiku) for this workflow
nodes:
- id: analyze
command: analyze-architecture
- id: report
command: generate-report
depends_on: [analyze]
context: fresh

The workflow uses opus instead of the config default haiku, but other settings inherit from config.


A bundled default that is being replaced is kept runnable during a deprecation window and marked declaratively:

deprecated:
message: Switch to the sdlc pack instead.

The field is metadata-only — it never blocks or alters execution. Every run start announces on each surface the run reports to (chat platforms, web console, CLI stderr, plus the run’s durable event trace) that the workflow will be removed in an upcoming release, carrying the declared message and both exits: switch to the replacement, or copy the workflow file into your project .archon/workflows/ or global ~/.archon/workflows/ (same filename wins discovery) to keep using it.

Any future bundle deprecation works the same way with no engine change: declare the field, get the notice. A copy without the field silences it.


Write descriptions that help with routing and user understanding:

description: |
Investigate and fix a GitHub issue end-to-end.
**Use when**: User provides a GitHub issue number or URL
**NOT for**: Feature requests, refactoring, documentation
**Produces**:
- Investigation artifact
- Code changes
- Pull request linked to issue
**Steps**:
1. Investigate root cause
2. Implement fix with tests
3. Create PR

Good descriptions include:

  • What the workflow does
  • When to use it (and when NOT to)
  • What it produces
  • High-level steps

All workflows support variable substitution in prompts and commands. The most commonly used:

VariableDescription
$ARGUMENTS / $USER_MESSAGEThe user’s input message that triggered the workflow
$WORKFLOW_IDUnique ID for this workflow run
$ARTIFACTS_DIRPre-created artifacts directory for this workflow run
$BASE_BRANCHBase branch (auto-detected or configured)
$DOCS_DIRDocumentation directory path (default: docs/)
$CONTEXTGitHub issue/PR context (if available)
$nodeId.outputOutput of a completed upstream node
$nodeId.output.fieldJSON field from a structured upstream node output

See the Variable Reference for the complete list, including the deprecated $LOOP_USER_INPUT and $REJECTION_REASON (an attention wait’s answer replaces both, as $<wait-id>.output.text), substitution order, and context variable behavior.

Example:

prompt: |
Workflow: $WORKFLOW_ID
Original request: $USER_MESSAGE
GitHub context:
$CONTEXT
[Instructions...]

name: quick-fix
description: |
Fast bug fix without full investigation.
Use when: Simple, obvious bugs.
nodes:
- id: fix
command: analyze-and-fix
- id: pr
command: create-pr
depends_on: [fix]
context: fresh
name: fix-github-issue
description: |
Full investigation and fix for GitHub issues.
Use when: User provides issue number/URL
nodes:
- id: investigate
command: investigate-issue
- id: implement
command: implement-issue
depends_on: [investigate]
context: fresh
name: comprehensive-pr-review
description: |
Multi-agent PR review covering code, comments, tests, and security.
nodes:
- id: scope
command: create-review-scope
- id: code-review
command: code-review-agent
depends_on: [scope]
context: fresh
- id: comment-review
command: comment-quality-agent
depends_on: [scope]
context: fresh
- id: test-review
command: test-coverage-agent
depends_on: [scope]
context: fresh
- id: security-review
command: security-review-agent
depends_on: [scope]
context: fresh
- id: synthesize
command: synthesize-reviews
depends_on: [code-review, comment-review, test-review, security-review]
context: fresh
name: implement-prd
description: |
Autonomously implement a PRD, iterating until all stories pass.
nodes:
- id: implement-loop
loop:
prompt: |
Read PRD from `.archon/prd.md`.
Read progress from `.archon/progress.json`.
Implement the next incomplete story with tests.
Run validation: `bun run validate`.
Update progress file.
If ALL stories complete: <promise>COMPLETE</promise>
until: COMPLETE
max_iterations: 15
fresh_context: true
name: classify-and-fix
description: |
Classify issue type and run the appropriate path.
Use when: User reports a bug or requests a feature
Produces: Code fix (bug path) or feature plan (feature path), then PR
nodes:
- id: classify
command: classify-issue
output_format:
type: object
properties:
type:
type: string
enum: [BUG, FEATURE]
required: [type]
- id: investigate
command: investigate-bug
depends_on: [classify]
when: "$classify.output.type == 'BUG'"
- id: plan
command: plan-feature
depends_on: [classify]
when: "$classify.output.type == 'FEATURE'"
- id: implement
command: implement-changes
depends_on: [investigate, plan]
trigger_rule: none_failed_min_one_success
- id: create-pr
command: create-pr
depends_on: [implement]
context: fresh

Run different paths based on conditions:

name: smart-fix
description: Route to appropriate fix strategy based on issue complexity
nodes:
- id: analyze
command: analyze-complexity
output_format:
type: object
properties:
complexity:
type: string
enum: [simple, complex]
required: [complexity]
- id: quick-fix
command: quick-fix
depends_on: [analyze]
when: "$analyze.output.complexity == 'simple'"
- id: deep-fix
command: deep-investigation
depends_on: [analyze]
when: "$analyze.output.complexity == 'complex'"

For long workflows, DAG resume lets you skip already-completed nodes — opt in with --resume:

name: large-migration
description: Multi-file migration with automatic checkpoint recovery
nodes:
- id: plan
command: create-migration-plan
- id: batch-1
command: migrate-batch-1
depends_on: [plan]
context: fresh
- id: batch-2
command: migrate-batch-2
depends_on: [batch-1]
context: fresh
- id: validate
command: validate-migration
depends_on: [batch-2]
context: fresh

If the workflow fails at batch-2, run archon workflow run large-migration --resume to skip plan and batch-1. Plain archon workflow run large-migration (without --resume) starts fresh.

Use a wait: attention node to pause until someone answers before continuing:

name: careful-refactor
description: Refactor after the proposal is approved
nodes:
- id: propose
command: propose-refactor
- id: review
wait:
attention: "Review the proposed refactor before proceeding. Check the artifacts directory."
form: approval
depends_on: [propose]
- id: stop-if-rejected
cancel: "Refactor proposal rejected"
depends_on: [review]
when: "$review.output.decision == 'reject'"
- id: execute
command: execute-approved-refactor
depends_on: [review]
when: "$review.output.decision == 'approve'"
- id: pr
command: create-pr
depends_on: [execute]
context: fresh

When the workflow reaches review, it pauses and the run needs attention. Answer it with archon workflow answer, the console, or the approve/reject shorthand on any surface (/workflow approve <run-id>, archon workflow approve <run-id>, or the API). See Attention waits for every form and answer channel.

reject is an answer, not a cancellation: the run continues with $review.output.decision == 'reject', and the graph decides what happens next. Here stop-if-rejected ends the run. Any text given with the answer is $review.output.text, so a rework node can read it instead.

To offer named choices, use form: select with options:; the next node reads the chosen id as $review.output.decision.

Use a cancel: node to stop a workflow when a precondition fails — preventing wasted compute on downstream branches:

nodes:
- id: check
bash: "git merge-base --is-ancestor HEAD origin/main && echo ok || echo blocked"
- id: stop-if-blocked
cancel: "PR has merge conflicts — cannot proceed with review"
depends_on: [check]
when: "$check.output == 'blocked'"
- id: review
prompt: "Review the PR..."
depends_on: [check]
when: "$check.output == 'ok'"

When a cancel: node executes (passes its when: gate), it sets the workflow run to cancelled with the reason string and stops all in-flight nodes. Unlike node failure, cancellation is intentional — the status is cancelled, not failed.

To repeat work until it is approved, put the work and an attention wait in a loop_group. The wait must be the body’s sole terminal sink. The next iteration reads the answer through $LOOP_PREV, and until_bash checks it:

- id: refine-plan
loop_group:
until_bash: 'decision=$ask.output.decision; [ "$decision" = "approve" ]'
max_iterations: 10
nodes:
- id: draft
prompt: |
Write or revise the plan.
Feedback on the previous draft, if any: $LOOP_PREV.ask.output.text
- id: ask
depends_on: [draft]
wait:
attention: "Review the plan. Approve it, or reject with what to change."
form: approval

Each iteration drafts, then pauses. Approving completes the group; rejecting with text runs another iteration that reads the text. Continuing past the wait permits one more iteration even after max_iterations. See Pausing to ask inside the body.

This replaces two deprecated shapes: interactive loops (loop.interactive: true with gate_message and $LOOP_USER_INPUT) and approval.on_reject rework prompts with $REJECTION_REASON. Both still run, with a deprecation warning. See Migrating from approval gates.


Terminal window
bun run cli workflow list
Terminal window
bun run cli workflow run {name} "test input"

Watch the streaming output to see each step.

After a workflow runs, check the artifacts in the $ARTIFACTS_DIR for that run (located at ~/.archon/workspaces/owner/repo/artifacts/runs/{workflow-id}/).

Print the current JSONL transcript, or follow it through completion:

Terminal window
archon workflow logs {workflow-id}
archon workflow logs {workflow-id} --follow
archon workflow logs {workflow-id} --follow --format text # the same rows, readable

Each line of the default output is a JSON event (step start, AI response, tool call, retained subprocess output, etc.). archon workflow get {workflow-id} also prints the resolved local transcript path.


Before deploying a workflow:

  1. Test each command individually

    Terminal window
    bun run cli workflow run {workflow} "test input"
  2. Verify artifact flow

    • Does the first node produce what the second expects?
    • Are paths correct?
    • Is the format complete?
  3. Test edge cases

    • What if the input is invalid?
    • What if a node fails?
    • What if an artifact is missing?
  4. Check iteration limits (for loops)

    • Is max_iterations reasonable?
    • What happens when limit is hit?

  1. Workflows orchestrate commands — YAML files defining a DAG of execution nodes
  2. nodes: define the graph — each node runs a command, inline prompt, bash script, or loop
  3. Artifacts are the glue — commands communicate via files, not in-memory context
  4. context: fresh — forces a fresh AI session for a node (works from artifacts only)
  5. Parallel by default — nodes in the same topological layer run concurrently
  6. Conditional branching — when: conditions and trigger_rule control which nodes run
  7. output_format — enforce structured JSON output from an AI node, or from a bash:/script: node’s stdout, for reliable branching
  8. allowed_tools / denied_tools — restrict tools per node (all providers except Codex)
  9. retry: — AI nodes auto-retry transient errors (default: 2 retries / 3 total attempts, 3 s backoff); bash:/script: retry only with an explicit retry: block
  10. hooks — attach SDK hook callbacks to Claude nodes for tool control and context injection
  11. mcp: — attach per-node MCP servers via JSON config (Claude/Codex/Copilot)
  12. skills: — select exact active skills on Claude and declare skills for Pi/Copilot; Codex workflow bodies use explicit $skill-name
  13. plugins: — name the Claude or Codex plugins a node loads; every other installed plugin stays off
  14. agents: — inline Claude sub-agent definitions invokable via the Task tool
  15. effort — reasoning depth per node or workflow, on every provider that has request-level reasoning control (Claude/Codex/Pi/Copilot)
  16. maxBudgetUsd — set a USD cost cap per node; fails with error if exceeded (Claude only)
  17. systemPrompt — set system instructions per node (Claude/Codex/Pi/Copilot/OpenCode)
  18. sandbox — OS-level filesystem/network restrictions per node or workflow (Claude only)
  19. output_type — tag a node’s output with a semantic type; the engine writes a typed sidecar for cross-node/cross-run lookup by type (any node type). Top-level nodes use $ARTIFACTS_DIR/nodes/<id>.md + .meta.json; loop-body executions use iteration-specific paths
  20. Loop nodes — use loop: within a DAG node for iterative execution until a declared completion condition is met
  21. Bundled workflows as templates — browse .archon/workflows/sdlc/ in the Archon repository for real examples to copy and modify
  22. Test thoroughly — each command, the artifact flow, and edge cases