The surprise, reading a workflow engine from the inside, is how little of it is about graphs. Topological sorting is a first-year algorithm and it takes maybe fifty lines. Almost everything else is bookkeeping: who holds the lock, what counts as a fatal error, what happens when a process dies without saying so.
That is the honest description of the category. The DAG is the part you would write in an afternoon. The rest is the part you would get wrong for a year.
None of this is required reading. Part three said what Archon is and what it gives you, and that argument stands without any of what follows. Read on if you are building something like it: the lock expiry, the order the error classifier checks its patterns in, and forking a session instead of appending to it are the same three problems in any system where work outlives the process that started it.
Continues from part three, and assumes its vocabulary: command, workflow, worktree, node.
A run, in four phases
Every execution moves through four phases, and the engine is the same code in every case: the front door you came through only decides who gets told about it.

Figure 1: The whole engine on one page. More of it is checks than work.
| Phase | What happens |
|---|---|
| Discovery | YAML files are found, parsed and structurally validated |
| Routing | the right workflow is selected, by name or by asking a model |
| Setup | config loads, a run row is created, the path lock is taken |
| Execution | nodes run in topological layers, concurrently within each layer |
Discovery: three sources, three checks
Every YAML file found across the three scopes goes through three checks before it is allowed to exist:
- It parses. A file that does not is skipped, not fatal.
- Every node validates against a schema, for types, required fields and valid enums.
- The graph validates. No duplicate node IDs, no
depends_onpointing at a node that is not there, no cycles, and no$nodeId.outputreference to a node that does not exist.
The failure mode is the good part. A broken YAML file records a WorkflowLoadError and shows up
in /workflow list. It does not abort the discovery pass or block any other workflow from
loading. One malformed file breaks one workflow.
Routing: a model that cannot use tools
When a message arrives without an explicit workflow name, the router builds a prompt listing every
available workflow with its description, adds platform context such as the issue title, labels and
thread history, and asks a model to reply with /invoke-workflow <name>.
Tool use is disabled at the API level with tools: [], so the router cannot make a tool call.
It can only emit text. For a component whose entire job is to pick a name out of a list, removing
its ability to do anything else is the right call, and it is the kind of thing that only gets done
deliberately.
The name it emits then goes through a four-tier fuzzy match: exact, case-insensitive, suffix
(plan matches feature-plan), then substring. If everything fails, the system falls back to a
general assistant workflow rather than guessing.
Setup: unglamorous and load-bearing
Before a single node runs, setup does seven things:
- reads config and merges per-project environment variables;
- resolves provider and model;
- checks whether a previous failed run on this working directory can be resumed;
- creates the run row;
- takes the path lock;
- makes the per-run artifacts directory;
- exposes that directory to prompts as
$ARTIFACTS_DIR.
The worktree is created here too, on an auto-generated branch you can override with --branch, and
these are git-native worktrees rather than clones. A workflow that does not touch the checkout
declares mutates_checkout: false and opts out of both the worktree and the lock guarding it.
Isolation you have to clean up
The part that is easy to skip when building this yourself is the other end:
archon complete <branch>removes the worktree, the local branch and the remote one once a finished branch has been pushed and merged.archon isolation cleanupsweeps worktrees older than a week.--mergedtakes merged branches too, and--include-closedtakes abandoned pull requests.
Isolation you cannot clean up is just a slowly filling disk.
Two constraints come with it. One branch maps to exactly one worktree, so the same branch cannot be run twice at once. And a paused sub-run’s worktree is reused when it resumes, so cleaning up too eagerly turns a paused run into a lost one.
Execution: layers, then nodes
The executor builds topological layers from the depends_on edges and runs every node in a layer
at once. Per node it:
- evaluates the
when:condition and thetrigger_rulejoin policy; - runs two passes of variable substitution;
- executes;
- on success, stores the output in an in-memory map and persists a
node_completedevent.
On a transient error it retries up to max_attempts, twice by default, with a three second
backoff. On a fatal error it stops immediately. What counts as fatal is a section of its own.
The node types

Figure 2: The colors are the argument. Only the blue ones put a model in the path.
| Type | Category | How it runs |
|---|---|---|
prompt / command |
AI | calls provider.sendQuery(); a command node loads its prompt from a file in .archon/commands/ |
loop |
AI | calls the model repeatedly, scanning each output for a completion tag, until it appears or max_iterations is hit |
bash |
Shell | execFileAsync('bash', ['-c', script]). No model anywhere in the path |
script |
Shell | TypeScript via bun run or Python via uv run, with deps: and timeout: support |
approval |
Gate | writes status: paused, messages the user, waits for /workflow approve or /workflow reject |
cancel |
Gate | marks the run cancelled and stops execution, usually on a conditional branch |
Every type captures output the same way, into $nodeId.output, which is what lets them compose.
For AI nodes it is the concatenated assistant text; for shell nodes it is stdout; for an approval
node with capture_response: true it is whatever the human typed.
Prompt and loop nodes also accept a long list of per-node overrides, currently Claude only:
model, systemPrompt, allowed_tools and denied_tools, mcp, hooks, skills, agents,
effort, thinking, maxBudgetUsd, fallbackModel, betas, sandbox. Capabilities are checked
at execution time, and anything the provider does not support emits a warning and is ignored rather
than failing the run.
The model resolution chain
The claim part two leaned on hardest was that the invoking session has no say in what serves a node. Here is the chain in full:
| Priority | Source |
|---|---|
| 1 | model: on the individual node |
| 2 | model: at the top of the workflow YAML |
| 3 | assistants.claude.model in .archon/config.yaml |
| 4 | the SDK default |
| Never | the model of the Claude session that invoked Archon |
Provider identity is validated when the YAML loads, so an unknown provider: is a hard error.
Model strings are deliberately not validated: they go verbatim to the SDK, which is the actual
source of truth for what model names exist. Refusing to maintain a second list is the right call
and a slightly uncomfortable one, since a typo surfaces at execution rather than at load.
Context and sessions
Context is threaded rather than accumulated. Sequential nodes on one provider inherit the previous
node’s session; a parallel layer resets that, because concurrent nodes cannot share one session;
and any node can opt out with context: fresh.
Cross-provider handoff has to pass context explicitly, which is what the second substitution pass is for:
- id: plan
provider: claude
prompt: "Analyze the repo and plan: $ARGUMENTS"
- id: implement
depends_on: [plan]
provider: codex # different provider, no shared session
prompt: |
Implement according to this plan:
$plan.output # full text of the plan node, injected at runtime
Pass one substitutes run-scoped variables ($ARGUMENTS, $BASE_BRANCH, $ARTIFACTS_DIR,
$REJECTION_REASON and friends). Pass two resolves $nodeId.output for the whole text, or
$nodeId.output.field to parse the output as JSON and pull one field out, which is how structured
data moves between nodes.
For bash bodies, substituted values are wrapped in single quotes before hitting the shell. Node output is model-generated text going into a shell command, so this is the injection boundary, and it is handled where it should be.
The state machine, and what resume does not restore

Figure 3: The two callouts are the parts you would not write yourself until the day you needed them.
| Transition | Trigger |
|---|---|
→ pending |
run created; the row reserves the working directory |
pending → running |
dispatcher starts |
pending → cancelled |
path-lock conflict, or abandoned before it ever ran |
running → paused |
an approval node is reached |
paused → running |
/workflow approve or /workflow reject |
running → completed |
every layer finished |
running → failed |
fatal error, or retries exhausted |
failed → pending |
/workflow resume |
The database is authoritative rather than a file, which is what allows more than one process to observe and act on the same run. That is the mechanical reason an approval can be answered from Slack while the run itself is on someone’s laptop.
Resume replays node_completed events to reconstruct prior outputs and skips those nodes. And here
is the honest limitation, documented rather than hidden: resume does not restore the AI session
context. Only the $nodeId.output values come back. A node that depended on what a previous node
had read rather than what it returned may need to re-read artifacts, and the resume message
says so explicitly.
Which is the same trade the previous series arrived at from the other direction. Artifacts survive; conversations do not. An engine does not fix that, it just makes the surviving part durable.
Where the hard calls are
Everything above is what you would expect a workflow engine to contain. What follows is what separates one that works from one that mostly works, and none of it would show up in a feature comparison.
The lock, and the five-minute window
Part one argued that arbitrating two runs against one checkout needs a lock with an owner and an expiry. Here is that lock.
The pending row is the lock token. Before a run starts, the engine checks whether another
run is already running or paused on the same worktree, and if so the new run is cancelled
immediately with an actionable message rather than queued or silently run anyway.
The expiry is a five-minute stale-pending window, which exists for exactly one situation: a
dispatch that crashed between inserting the row and starting work. Without it, one crash poisons
that working directory forever. Workflows that do not touch the checkout opt out of both the
worktree and the lock with mutates_checkout: false.
Refusing to guess
The rule Archon states most explicitly is a refusal:
When a process cannot reliably distinguish “actively running elsewhere” from “orphaned by a crash”, it does not autonomously mark the run as failed.
Instead it surfaces the ambiguity with actionable options. Only heuristic, recoverable operations (retry backoff, subprocess timeouts) are allowed to bypass this.
This is the single most transferable idea in the codebase, and it has nothing to do with AI. A distributed system that guesses about liveness will eventually kill a healthy run, and the user will not know why. Encoding “I cannot tell, so I will ask” as a design rule rather than an oversight is the difference between an engine you trust and one you babysit.
Three classes of error
classifyError() sorts every caught error into three buckets:
| Class | Patterns | Action |
|---|---|---|
| Fatal | auth failure, permission denied, credit balance, 401, 403 | abort immediately: retrying wastes credits or loops forever |
| Transient | rate limit, timeout, process exit, 429, overloaded | retry with exponential backoff |
| Unknown | everything else | retry, but count consecutive occurrences and abort after three |
The ordering matters. Fatal patterns are checked before transient ones, so a message containing
both, like unauthorized: process exited with code 1, classifies as fatal. Get that precedence
backwards and an expired credential turns into a retry loop burning money on a request that
cannot succeed.
The unknown bucket retries, because most unknown errors are transient, but it keeps a consecutive counter so an unrecognized permanent failure cannot spin forever. That is a considered answer to “we do not know”, rather than picking one of the other two buckets and hoping.
Four channels, and the three that can answer you
A run writes itself down in four places. Event rows go to the database, which is what resume replays. A JSONL file log records the same run linearly. An in-process emitter feeds the web console over SSE. Anonymous telemetry goes to Archon.
Only the first three can tell you why a run is stuck, and they are not redundant: the database has what finished, the log has what was said, the stream has what is happening now. Part three’s complaint about having to guess which channel holds the answer is this list seen from the outside.

Figure 4: Error classification, the observability channels, and the two substitution passes.
Forking instead of mutating
Whenever a node resumes an earlier session, the executor sets forkSession: true:
const shouldForkSession = resumeSessionId !== undefined;
Forking copies the transcript before appending rather than mutating the source. The reason is retries: if node B fails and runs again, the second attempt forks node A’s session afresh instead of appending to a transcript the first attempt already polluted. Without this, a retry inherits the wreckage of its own previous failure, and nothing in the output would tell you.
An engine that does not import an AI SDK
@archon/workflows imports only from @archon/providers/types, a subpath with zero SDK
dependencies. The actual @anthropic-ai/claude-agent-sdk and @openai/codex-sdk packages live
exclusively in @archon/providers.
So the workflow engine can be tested with no AI SDK installed at all. That is the boundary doing real work: the thing that sequences steps has no opinion about who executes them, which is the same inversion part two described, enforced at the level of the dependency graph.
What the engineering gets right
None of the decisions above is visible in a demo, and every one of them was somebody choosing the harder correct thing over the obvious one.
- The lock has an expiry, because a dispatch that crashes between writing the row and starting work would otherwise poison that working directory permanently.
- Fatal errors are checked before transient ones, so an expired credential does not become a retry loop billing you for requests that cannot succeed.
- Resume forks a session rather than mutating it, so a second attempt does not inherit the wreckage of the first.
- The engine does not import an AI SDK at all, so the thing that sequences steps has no opinion about who executes them.
- When liveness cannot be determined, the system refuses to decide and says so.
What follows
All of that is a good picture of a run. Whether it is a good picture of your work is the question the series is named for, and part five answers it without mentioning Archon again: what an engine gives you that no skill will, and the one risk no engineering fixes.