A workflow lets an agent process work as an explicit graph of steps — with branches, loops, and waits — instead of one flat ReAct loop. agentd already is an implicit single-node graph executor (the loop is a hard-coded cycle, the reactive router is an event→action edge set); a workflow reifies that into an explicit graph the agent (or an operator) authors and agentd drives. Think LangGraph, but the agent builds and runs the graph by itself, over the same subagents, MCP tools, and structured data it already uses.
Feature-gated, opt-in. Workflows compile only under
--features workflow(default off — an agentd built without it is byte-for-byte unchanged). It is dependency-free (serde +serde_jsononly). The degenerate single-agent-node graph reproduces today's one-shot behavior, so the graph is a superset, never a replacement.
| Capability | Mechanism | Where |
|---|---|---|
| Mix intelligence and determinism per step | twelve node kinds: agent (a full reasoning turn), infer (one schema-checked structured ask), tool/assign (zero model tokens) |
Node kinds |
| Route on data — or on judgement | branch: deterministic predicates (free), CEL expressions, one opt-in semantic tier |
Conditions |
| Accumulate instead of overwrite | writes_mode reducers: append / merge / union |
Reducers |
| Process an array without feeding it through the LLM | foreach: one body × N items, up to 8 parallel lanes |
Fan-out |
| Run different things at once, then continue | parallel: N named bodies, one result object, the same lane pool |
Parallel |
| Run phases as isolated processes | subgraph {async} + join: supervised child workflows, fan-in later |
Async subgraphs |
| Wait for the world | wait: suspend on an MCP resource update, at zero idle cost |
Waits |
| Ask a human — over A2A | human: the task projects input-required; the reply is a spec-native SendMessage |
Human gates |
| Survive crashes; fork; time-travel | the MCP checkpointer: per-superstep durable state, --workflow-resume |
Durable state |
| Loop safely | layered termination: step budget, shared token pool, wall deadline, visit caps, progress guard — each with a typed reason |
Termination |
| Grow the plan mid-run | workflow.patch: additive-only self-modification |
Patching |
| Stay supervised | the driver runs in a killable child; the supervisor owns the kill ladder, cgroups, drain, and the exit-code contract | Termination |
Everything below is the same graph language everywhere: what the model authors
via workflow.define is exactly what an operator pins with --workflow — one
dialect, advertised as surfaces.workflow.dialect in the capabilities manifest.
A workflow is the same serde JSON object every way:
-
Operator-pinned — run a workflow from a file to completion, then exit:
agentd --mode workflow --workflow ./pipeline.json --intelligence https://gw.example/v1
No
--instructionis needed (the nodes carry the work), but intelligence is still required foragent/infernodes. The run is supervised exactly like--mode once: the driver lives in a child process while the supervisor owns the kill ladder, cgroup limits, liveness, drain, and the run report. The result (with the workflow status, reason, steps, and token cost) prints to stdout and the graph status maps onto the normal exit table (see Termination). -
Agent-authored — the agent defines and runs a workflow itself, mid-reasoning, via three self-tools (a root agent only):
workflow.define{workflow}— validate + store a workflow, returns aworkflow_id.workflow.run{workflow_id}— drive the stored workflow to completion synchronously, returning its status + reason + result as the tool result.workflow.run{workflow_id, detach: true}— hand the workflow to a spawned subagent and return a handle immediately; the child process drives it under full supervision while the agent keeps working. Collect withsubagent.await{handle}(blocks) or peek withsubagent.status{handle}. Fan-out/fan-in = detach several, then await each.workflow.patch{workflow_id, patch}— grow a stored workflow additively (see Patching).
This is the "agent orchestrates by itself" path: the agent decides the shape of the work, writes the workflow, and runs it — no operator or per-step hand-holding.
-
Delegated — a parent agent hands a whole workflow to a child directly:
subagent.spawn{workflow: {...}}(theinstructionbecomes optional). The child drives the graph instead of running a ReAct loop, with the usual scope narrowing, depth/breadth/rate caps, andasync/detachdispositions.
All paths share one driver, so a workflow behaves identically wherever it runs.
A workflow is pure topology — a start node id and a map of nodes:
{
"start": "fetch",
"nodes": {
"fetch": { "kind": "agent", "instruction": "fetch the next item", "writes": "item",
"edges": { "ok": "route", "error": "done" } },
"route": { "kind": "branch",
"cases": [ { "when": {"op":"eq","key":"item","pointer":"/status","value":"pending"},
"goto": "work" } ],
"default": "done" },
"work": { "kind": "tool", "server": "fs", "tool": "process",
"args": { "id": { "$from": "item", "pointer": "/id" } },
"writes": "item", "edges": { "ok": "fetch", "error": "done" } },
"done": { "kind": "halt", "status": "completed", "result_from": "item" }
}
}Every node carries its own out-edges as a label → target map (a branch uses
per-case gotos instead). A target that points back to an ancestor is a back-edge
— cycles are legal by construction (work → fetch above is a loop). The resume
point, the blackboard, and the budget are run state, never part of the graph —
the authored graph stays deterministic, replayable topology.
All nodes share a blackboard: a string-keyed map of JSON values, threaded
through the run. A node's writes key stores its result; reads lists fold named
blackboard values into a model call's context; {"$from": …} references inject
them into tool args and assign templates. This is how data flows between steps —
one node's output becomes the next node's input.
The blackboard is coordination state, not bulk transport: a single value is capped
at 1 MiB (serialized). An oversized node result is replaced by a small error
marker and takes the node's error edge — never an unbounded memory sink.
Anywhere in tool.args or assign.value, an object of the form
{ "$from": "item", "pointer": "/id", "default": 0 }is replaced (at node-execution time) by the blackboard value at item +
RFC 6901 pointer /id. pointer and default are optional; a missing path
with a default resolves to it, a missing path without one takes the
node's error edge before any tool is called. An unknown extra key in a
reference object is rejected (a typo shield).
Pointers support computed segments: {bbkey} expands to the stringified
scalar at blackboard[bbkey], so "pointer": "/items/{index}" addresses a
loop-carried position dynamically. Expanded string values are RFC-6901-escaped
(a value containing / cannot smuggle in extra path levels); a missing or
non-scalar placeholder takes the error edge.
Every node has a kind. There are twelve (dialect 2, RFC 0021):
| Kind | Does | Key fields | Emits |
|---|---|---|---|
agent |
Runs a full ReAct turn on instruction (with reads folded into context, honoring an optional output_contract) against the MCP tools. |
instruction, reads?, writes?, writes_mode?, output_contract?, retry?, edges |
ok / error |
tool |
Calls one MCP tool on server with args (with $from references resolved). |
server, tool, args?, writes?, writes_mode?, retry?, edges |
ok / error |
assign |
Pure data shaping — resolves a value template (with $from references) and writes it. No model, no tool. |
value, writes, writes_mode?, edges |
ok / error |
infer |
ONE structured intelligence call: the model answers prompt as a JSON object satisfying schema (field → type); invalid answers are automatically re-asked with the validation errors, up to retries times. |
prompt, schema, reads?, writes?, writes_mode?, retries?, retry?, edges |
ok / error |
branch |
Routes on the blackboard (see Conditions). | cases, default, semantic? |
(per-case goto) |
foreach |
Fans out over an array (see Fan-out): runs body once per item on a scoped board, collecting results positionally. |
items, body, parallel?, on_error?, writes?, writes_mode?, edges |
ok / error |
parallel |
Fans out over NAMED heterogeneous branches (see Parallel): each branch body runs concurrently on a scoped board; results collect into ONE OBJECT keyed by branch name. | branches, on_error?, writes?, writes_mode?, edges |
ok / error |
wait |
Suspends until on_uri updates or timeout_ms elapses, writing the read content. |
on_uri, timeout_ms, writes?, writes_mode?, edges |
updated / timeout |
human |
A HUMAN GATE (see Human gates): publishes payload, flips the served A2A task to input-required, and suspends until an A2A reply / reply_uri update / timeout. |
payload, reply_uri?, timeout_ms, writes?, writes_mode?, edges |
replied / timeout / error |
subgraph |
Runs a nested workflow inline (waits included) — or async: true: SPAWNS it as a supervised child process and writes {"handle"} immediately. |
graph, async?, writes?, writes_mode?, edges |
ok / error |
join |
Fans IN: awaits async-subgraph handles (a handle, a {"handle"} object, or an array), collecting results positionally. |
handles, timeout_ms, writes?, writes_mode?, edges |
ok / error / timeout |
halt |
Terminates the workflow with an author-chosen status, projecting a result. | status, result_from? |
— |
A node that emits a label with no matching edge, an unhandled node, or a dangling
edge fails closed to a Crashed outcome — a mis-authored workflow never runs
away. Unknown node fields are define-time errors (RFC 0021 §4): a typo'd
writes_mode is refused, never silently ignored. The manifest advertises the
graph language as surfaces.workflow.dialect (currently 2) — feature-detect
from it, not the version string.
By default a node's writes overwrites its key. writes_mode folds instead:
| Mode | Semantics | Type mismatch |
|---|---|---|
overwrite (default) |
replace | never errors |
append |
absent → [v]; array → push |
error edge |
merge |
absent → v; object+object → shallow merge, incoming wins |
error edge |
union |
as append, skipping a deep-equal duplicate |
error edge |
{ "kind": "agent", "instruction": "find one more issue", "writes": "issues",
"writes_mode": "append", "edges": { "ok": "route", "error": "fail" } }Reducers are pure (no model, no tool); the reduce happens before the 1 MiB
clamp (the accumulated value is what must fit); a mismatch writes a readable
error marker and takes the error edge — never a silent coercion. CEL
assign.expr remains the escape hatch for custom folds.
infer is how a workflow turns free-form intelligence into checked structured
data the deterministic branches can route on:
{ "kind": "infer", "prompt": "Classify this ticket.", "reads": ["ticket"],
"schema": { "category": "string", "urgency": "number", "escalate": "boolean" },
"writes": "triage", "edges": { "ok": "route", "error": "manual" } }The schema is a minimal field → type map (string | number | boolean |
array | object | any) — a floor, not a ceiling (extra fields are allowed).
An answer missing a field or with a wrong type is re-asked with the exact
validation errors folded in (default 1 re-ask, max 3); exhaustion takes the
error edge with the reason. A downstream Tier-1 predicate can then branch on
triage/urgency deterministically — no second model call.
The effectful kinds (agent, tool, infer) accept a retry policy:
{ "kind": "tool", "server": "q", "tool": "push", "args": {},
"retry": { "max": 2, "backoff_ms": 500 },
"edges": { "ok": "done", "error": "alert" } }On an error result the node re-runs up to max more times (cap 5), sleeping
backoff_ms between attempts (cap 60s), before following error. Retries happen
within one node visit, so the loop/stall guards are not tripped by an
intentionally-identical retry — but every retry charges the step budget, so a
retry storm can never outrun the run's cap. (An authored self-edge is NOT a
retry: re-entering a node with an unchanged blackboard is a stall by design —
use retry for "try again", edges for "make progress".)
The map primitive: a tool returns {"items": [...]} with hundreds of entries,
and each needs the same processing — without feeding the array through the
model (a big array through an agent node burns tokens per item and can blow
the context):
{ "kind": "foreach",
"items": { "$from": "scan", "pointer": "/items" },
"body": {
"start": "handle",
"nodes": {
"handle": { "kind": "tool", "server": "q", "tool": "process",
"args": { "id": { "$from": "item", "pointer": "/id" } },
"writes": "out", "edges": { "ok": "done", "error": "failed" } },
"done": { "kind": "halt", "status": "completed", "result_from": "out" },
"failed": { "kind": "halt", "status": "crashed", "result_from": "out" }
}
},
"parallel": 4,
"on_error": "continue",
"writes": "results",
"edges": { "ok": "summarize", "error": "triage" } }itemsresolves against the blackboard (a$fromreference or a literal array; cap 1024 items). Each iteration runsbody— a full nested workflow (waits included) — on a scoped blackboard: a clone of the parent board with the reserved keysitem(the element) andindex(its position) seeded. Body writes never flow back; only each body's halt result does, collected positionally intowrites. A failed item's slot carries{"index", "error"}so downstream consumers keep alignment.on_error: "fail_fast"(default) stops at the first failing item and takes theerroredge with the partial results;"continue"processes everything, records per-item markers in place, and takesok— branch on the results content (e.g. alen/containspredicate) to decide what failure means.parallel: N(cap 8) runs items on N worker lanes, each with its own intelligence + MCP connections — no client is shared across threads, and every lane's model usage still lands on the workflow's shared token pool. Default 1 = inline sequential (per-item budget/deadline checks between items).- Cost model: every item charges one budget step; a body of pure
tool/assign/branchnodes makes zero model calls per item — the whole fan-out is deterministic. Put aninfer/agentnode in the body only where an item genuinely needs intelligence, and the shared pool still bounds the total.
Where foreach maps one body over N items, parallel runs N different
bodies at once — "run the security review AND the perf review, then continue"
(RFC 0021 §6):
{ "kind": "parallel",
"branches": {
"security": { "start": "s0", "nodes": { "…": "a full sub-graph" } },
"perf": { "start": "p0", "nodes": { "…": "a different sub-graph" } }
},
"on_error": "continue",
"writes": "reviews",
"edges": { "ok": "synthesize", "error": "fail" } }- Each branch runs on a scoped board (a clone of the parent's, with
branch= its name seeded); branch writes never flow back — the collected result does: one object keyed by branch name (a failed branch's slot carries{"branch","error"}). - Bounds: ≤ 16 branches; concurrency rides the SAME 8-lane pool
foreachuses — one pool, so composingparallelinsideforeach(or vice versa) never multiplies lanes. Every branch pre-charges a budget step; all branches draw the one shared token pool. on_error:fail_fast(default — any failed branch → theerroredge) orcontinue(okiff at least one branch succeeded; markers stay in place).haltinside a branch halts the branch, not the run.
A human node is the human-in-the-loop primitive (RFC 0021 §7): publish
something for a person (or any A2A peer) to inspect, suspend, and resume on
their reply — A2A is the conversation channel.
{ "kind": "human",
"payload": { "question": "Ship it?", "diff": { "$from": "patch" } },
"reply_uri": "approvals://deploy-42",
"timeout_ms": 86400000,
"writes": "verdict",
"edges": { "replied": "route_on_verdict", "timeout": "escalate" } }What happens, in order:
- The resolved
payloadtravels up to the supervisor; when the run is a served A2A task, the task transitions toTASK_STATE_INPUT_REQUIREDwith the payload as its status message — a spec-conformant A2A client (a human's UI, another agent) sees the wait viaGetTask/SubscribeToTask. - The workflow suspends. Three resume paths race, first one wins:
- an A2A
SendMessagecarrying this task'staskId— its text parts become the reply (the spec-native human answer); - an update on
reply_uri(any MCP resource — the notify-then-read read is the reply); - the
timeout_msexpiry (nothing written; thetimeoutedge).
- an A2A
- The reply lands on
writes(throughwrites_mode) and the node takesreplied. The task returns toworking.
What a human's UI (or any conformant A2A client) actually sees, end to end. Dispatch the work and note the task id:
Poll (GetTask {"id":"a3"}) or stream (SubscribeToTask). When the workflow
reaches its human node, the task is visibly waiting — and the question is
IN the task:
// ← {"id":"a3", "status":{
// "state": "TASK_STATE_INPUT_REQUIRED",
// "message": {"role":"agent","parts":[{"text":"{\"question\":\"Ship it?\",\"diff\":\"+1 -0\"}"}]},
// "timestamp": "…"}}The human answers with a plain SendMessage that continues the task by id
— no agentd-specific API, just the A2A spec's multi-turn shape:
// → SendMessage {"message":{"taskId":"a3","parts":[{"text":"yes"}]}}
// ← {"task":{"id":"a3", …}} // the reply is accepted; the run resumesThe reply text lands on the gate's writes key (parsed as JSON when it is
JSON — reply {"approve":true,"reason":"lgtm"} and branch on /approve), the
workflow takes replied, and the next GetTask shows TASK_STATE_WORKING,
then the terminal state with the distillate artifact.
The gate deliberately does not encode approve/reject — the reply is data,
and routing on it is a branch (predicates or CEL on the verdict), so
multi-approver schemes and rejection reasons stay authorable. Notes:
- A reply while another is pending is refused (
-32004 UnsupportedOperation); an unknowntaskIdis-32001 TaskNotFound; a message to a live task with no open gate is-32004(agentd runs are single-instruction — the gate reply is the one supported mid-task continuation). - Without
--serve-mcpthe gate degrades to a plain wait onreply_uri/timeout — never a hard serving requirement. - In the reactive-daemon shape the gate suspends the daemon's workflow like
a
wait(the payload appears onagent://workflow); it resolves byreply_uri/timeout — the A2A reply path serves served async tasks. - An unresolvable
$frominpayloademitserror(route it or fail closed).
A branch decides where to go next. Conditions are two-tier:
A case fires when its when predicate holds over the blackboard; the first
matching case wins, else default. Predicates are cheap, total (a missing path is
simply false), and never call the model:
{ "op": "eq", "key": "item", "pointer": "/status", "value": "ready" }key selects a blackboard entry; pointer is an RFC 6901 JSON Pointer into it
(empty = the whole value). Operators:
| Op | Holds when |
|---|---|
eq / ne |
the value deep-equals / does not equal value |
lt / lte / gt / gte |
numeric comparison against value |
in |
the value deep-equals one of values |
exists |
the path resolves to a present, non-null value |
contains |
a string contains the substring / an array contains the element |
starts_with / ends_with |
string prefix / suffix |
len |
the length of a string/array/object is within [min, max] |
all / any / not |
composition |
{ "op": "all", "preds": [
{ "op": "gte", "key": "triage", "pointer": "/urgency", "value": 8 },
{ "op": "in", "key": "triage", "pointer": "/category", "values": ["ops", "security"] }
] }Cross-key comparison: the comparison value of eq/ne/lt/lte/gt/
gte/contains (and elements of in) may itself be a {"$from": key, "pointer": "/p"} reference — branch on one blackboard value against another
("is the retry count below the configured limit?") with no model call. An
unresolvable reference makes the predicate false (fail-closed, even for
ne).
A predicate that can never hold (an empty in set, inverted len bounds) is
rejected at define time, not silently routed around.
A build with the cel feature adds CEL — the expression
language Kubernetes admission policies and Envoy use — wherever the structural
ops run out (arithmetic, string functions, collection macros). CEL is
non-Turing-complete, does no I/O, and always terminates, which makes it the one
form of "code" a model can safely author and agentd can immediately execute.
Three surfaces:
- Branch predicates —
{"op": "cel", "expr": "..."}(composable withall/any/not); every blackboard key is a top-level identifier:Must return a bool; a non-bool, an eval error, or an unresolvable reference is{ "op": "cel", "expr": "results.filter(r, !has(r.error)).size() >= results.size() * 9 / 10" }false(fail-closed). - Computed
assign—"expr"instead of"value": filter, map, aggregate, and assemble deterministically, with zero model tokens:{ "kind": "assign", "expr": "scan.items.filter(i, i.ok).map(i, i.id)", "writes": "ids", "edges": { "ok": "fan" } } infervalue constraints —"check"runs over the (schema-valid) answer's fields; a type-correct but out-of-bounds answer is re-asked with the constraint named:{ "kind": "infer", "prompt": "score it", "schema": { "score": "number" }, "check": "score >= 0.0 && score <= 1.0", "writes": "s", "edges": { "ok": "next", "error": "manual" } }
Reactive subscriptions get the same power: a wake condition may be
{"op": "cel", "expr": "content.items.exists(i, i.urgent)"} (the resource
content — or the value at the condition's pointer — is content), so a
daemon wakes only for the states it actually cares about.
Every expression is compile-checked at define/parse time (length-capped at
4 KiB), and a build without the feature rejects CEL right there with a
clear message — never a silent mis-evaluation. JSON numbers are normalized to
CEL ints/floats so count + 1 > limit behaves the way it reads. This is the
one gated exception to the zero-dependency default build; --features cel is
opt-in precisely so the moat holds everywhere else.
When the deterministic cases all miss and a branch carries a semantic spec,
agentd runs one tool-less model call to pick a labelled choice — a routing
decision the predicates can't express ("is this document acceptable?"):
{ "kind": "branch", "cases": [], "default": "reject",
"semantic": { "prompt": "Is the draft acceptable?", "reads": ["draft"],
"choices": { "approve": "publish", "revise": "rewrite" } } }The model is asked to answer with one label (exact match first, else the longest
contained label — so overlapping labels resolve to the specific one); an
unrecognized answer falls through to default. On a build with no reachable
intelligence, a semantic branch degrades safely to its default. Prefer an
infer node + Tier-1 predicates when the decision can be made structural — one
extraction can feed many cheap branches.
A wait node pauses the workflow on an external dependency — a job finishing, a
flag flipping — without burning a thread:
{ "kind": "wait", "on_uri": "file:///inbox.json", "timeout_ms": 30000,
"writes": "event", "edges": { "updated": "handle", "timeout": "giveup" } }agentd subscribes to on_uri, blocks until the resource updates (then reads its
current content, notify-then-read) or the timeout elapses, and resumes on the
updated or timeout edge. A back-edge into a wait is a long-lived reactive
loop that costs nothing while idle. The suspended run state is serializable, so a
long wait survives across a process boundary. Waits work inside subgraphs too.
Scope. All current paths resolve waits in-process (they block until the wait resolves, inside the supervised child). A fully asynchronous, non-blocking reactive-daemon workflow is a roadmap item.
subgraph { async: true } spawns the nested workflow as a child process
through the same machinery subagent.spawn uses — the depth, breadth, and
spawn-rate caps all apply — and writes {"handle": …} immediately. A later
join collects:
{ "start": "s1",
"nodes": {
"s1": { "kind": "subgraph", "async": true, "graph": { "…": "phase A" },
"writes": "h1", "edges": { "ok": "s2", "error": "fail" } },
"s2": { "kind": "subgraph", "async": true, "graph": { "…": "phase B" },
"writes": "h2", "edges": { "ok": "gather", "error": "fail" } },
"gather": { "kind": "assign", "value": [{ "$from": "h1" }, { "$from": "h2" }],
"writes": "hs", "edges": { "ok": "join" } },
"join": { "kind": "join", "handles": { "$from": "hs" }, "timeout_ms": 60000,
"writes": "results", "edges": { "ok": "done", "error": "triage", "timeout": "late" } },
"…": {}
} }Both phases run concurrently while the parent workflow proceeds to the
join. Results collect positionally (a failed child's slot carries
{"handle", "error"}); stragglers at the timeout take the timeout edge with
the partials written — they keep running and may be joined again. An async
subgraph starts with an EMPTY blackboard (data flows OUT via its halt result,
not in); use foreach when items must flow into parallel work.
A long-lived workflow whose wait nodes hold no process at all:
agentd --mode reactive --workflow ./pipeline.json --intelligence https://gw.example/v1 --mcp inbox=https://mcp-inbox.internal/mcpThe daemon drives the workflow in a supervised child; when it reaches a wait,
the child suspends — it exits, serializing the run slice (cursor +
blackboard + budget) into its result — and the DAEMON arms the subscription and
the timeout clock. On the resource update (or the timeout) a fresh child
resumes on the updated/timeout edge, budget continuing where it left off.
No --subscribe or --instruction is needed: the workflow's waits are the
subscriptions and its nodes are the work.
The daemon's lifetime is the workflow's: a terminal workflow exits with its
projected code, while an event-loop workflow (a back-edge into a wait) runs
indefinitely — idling between events with zero child processes alive. The live
state is observable at the Management-only agent://workflow resource:
driving, suspended (with the watched uri and spent budget), or terminal.
Cluster compatibility. A reactive workflow daemon is a single-instance shape: its wait uris are its own dependencies, not a partitioned work stream — so
--shard N>1,--standby, and--assign-fromare rejected at startup when combined with it (the shard filter would silently drop the workflow's own wait updates).--subscriberoutes may ride the same daemon (they then require the usual--instruction), but don't point a--claimroute at a uri the workflow also waits on — a wait resolving consumes that delivery before the claim gate.
Cyclic workflows need to stop. The guards, each with a distinct status and a
recorded reason (which guard tripped, at which node):
- Step budget — a total node-visit cap →
Exhausted. - Token pool — one intelligence-token budget for the WHOLE workflow (every
agentturn,inferask, and semantic judgement draws from it) →Exhausted. N model-calling nodes share one pool; they never multiply a per-node grant. - Wall-clock deadline — the whole workflow is bounded by the run's
--deadline(checked on every node entry) →Exhausted. - Per-node visit cap — a node visited more than 100 times is a runaway cycle
→
LoopDetected(awaitis exempt — it suspends, it does not spin). - Progress guard — re-entering a node with an unchanged blackboard means the
cycle made no progress →
Stalled. - Author-time validation — before it ever runs, a workflow must have a
startthat exists, no dangling edge, at least onehaltreachable fromstart(no-exit is rejected), everywaitwith a non-empty uri and non-zero timeout, retry/infer caps within bounds, satisfiable predicates, and node/edge/key/nesting counts within limits.
The engine statuses are distinct from a node's halt status (which is one of the
usual terminal statuses — completed, refused, …). Reaching a halt with
completed is Completed; any other author status is Halted. Under
--mode workflow the child projects the status onto the exit table:
Completed → 0, Halted → its terminal code, Exhausted → 7 (deadline/tokens/
steps distinguished by the reason), LoopDetected / Stalled → 3,
Crashed → 1. The result body always carries
{workflow_status, reason, steps, tokens, result} so the operator sees why and
at what cost, not just the code.
A workflow can persist its run slice after every superstep — crash-resume, state history, and fork/time-travel — with zero new dependencies: the checkpointer is any MCP server implementing a three-tool profile. Declare the policy at the graph root:
{ "checkpoint": { "server": "state", "key": "run/{run_id}", "every": 1,
"on_error": "continue" },
"start": "…", "nodes": { "…": "…" } }servernames a configured--mcpserver;keyis the state lineage ({run_id}interpolates — a stable operator-chosen key makes the run resumable across pod replacements);everygates the periodic writes (a suspension and ahaltalways checkpoint).- The envelope is versioned and self-describing:
{v:1, seq, workflow_hash, state, ts_ms}—seqis the superstep count (monotonic, carried across resume),workflow_hashis the SHA-256 of the canonical graph JSON (resume verifies it), andstateis the same serialized run slice awaitsuspension produces (cursor, blackboard, budget, visit counts). Its cursor is the next unexecuted node — resume is exactly-once for checkpointed nodes, at-least-once for the one in flight. - The server contract (any language, any store):
state.put {key,seq,state}(MUST refuseseq <=latest with{ok:false,latest}— the split-brain guard; a refused put is ALWAYS fatal for the run),state.get {key[,seq]},state.list {key}. Postgres, S3, sqlite, etcd — all are somebody's MCP server; agentd links none of them. on_error:continue(default — a failed write degrades durability, never the run;workflow.checkpoint.failtelemetry records it) orhalt.
Resume / fork:
$ agentd --mode workflow --workflow pipeline.json \
--mcp state=https://ckpt.internal/mcp \
--workflow-resume state:run/abc # latest — the crash-recovery flow
$ agentd … --workflow-resume state:run/abc@17 # a specific seq, under a NEW
# --run-id = a FORK (time-travel)The child fetches the envelope after connecting, verifies the workflow
hash (a mismatch is a refusal, exit 5 — the state was not taken from this
graph; --workflow-resume-force overrides for deliberate
graph-edit-and-continue, resetting the loop guards but keeping board and
budget), and drives on. Budgets carry over: a resumed run does not get a
fresh token pool — the budget is a property of the work, not the process.
agentd never resumes implicitly: a Job with restartPolicy: OnFailure opts
in by passing --workflow-resume with the stable key.
A checkpoint's cursor is the next unexecuted node, so semantics after a
hard kill (OOM, node loss, kill -9) are exactly what you want: every
completed node is exactly-once; the one that was in flight when the
process died is at-least-once (it re-runs — pair it with idempotent tools
/ the agent/run_id dedup meta, RFC 0011 §7). A Kubernetes Job that
survives pod replacement:
spec:
backoffLimit: 3
template:
spec:
restartPolicy: OnFailure
containers:
- name: agent
image: ghcr.io/agentd-dev/agentd:latest
args:
- --mode=workflow
- --workflow=/etc/agent/pipeline.json # checkpoint.key: "job/nightly-2026-07-04"
- --mcp=state=https://ckpt.internal/mcp
- --workflow-resume=state:job/nightly-2026-07-04 # see the subtlety belowOne subtlety: --workflow-resume of a key that does not exist yet is a
refusal (resuming nothing is a config error), so attempt 1 must run without
the flag — an init step that checks state.list (or a wrapper that drops the
flag when the key is empty) picks the variant. The explicitness is deliberate:
agentd never silently resumes state you didn't name.
History is immutable and @seq-addressed; a fork is a resume from any
recorded superstep under a new run id (and therefore a new checkpoint
lineage — the original history is never rewritten):
$ agentd … --workflow-resume state:run/abc@12 --run-id run-abc-fork1Want to fork with an edited blackboard (the "what if the review had said
no?" experiment)? The envelope is plain JSON behind a plain MCP server — fetch
it with any MCP client, edit state.blackboard, state.put it under a new
key, and resume from that. Time-travel needs no agentd surface at all; it
falls out of state-behind-MCP.
workflow.patch lets an agent elaborate its own plan at runtime — add nodes and
edges to a stored workflow as it learns more, without redefining the whole thing:
{ "workflow_id": "w1",
"patch": { "add_nodes": { "verify": { "kind": "agent", "instruction": "double-check",
"edges": { "ok": "done" } } },
"add_edges": [ { "from": "work", "label": "error", "to": "verify" } ] } }Patches are additive only — never overwrite a node or retarget an existing edge — so a patch can't strip reachability or a termination guarantee out from under a run. The grown workflow is re-validated; a rejected patch leaves the stored one untouched.
Extract structured data once, branch on it deterministically, loop a draft until a judge approves — bounded by the budget, the token pool, and the deadline:
{
"start": "classify",
"nodes": {
"classify": { "kind": "infer", "prompt": "Classify the ticket.", "reads": ["ticket"],
"schema": { "category": "string", "urgency": "number" },
"writes": "triage", "edges": { "ok": "route", "error": "manual" } },
"route": { "kind": "branch",
"cases": [ { "when": { "op": "gte", "key": "triage", "pointer": "/urgency", "value": 8 },
"goto": "page" } ],
"default": "draft" },
"page": { "kind": "tool", "server": "pager", "tool": "page",
"args": { "category": { "$from": "triage", "pointer": "/category" } },
"retry": { "max": 2, "backoff_ms": 1000 },
"writes": "paged", "edges": { "ok": "done", "error": "manual" } },
"draft": { "kind": "agent", "instruction": "draft a response", "reads": ["ticket", "triage"],
"writes": "draft", "edges": { "ok": "judge", "error": "manual" } },
"judge": { "kind": "branch", "cases": [], "default": "revise",
"semantic": { "prompt": "Is the response ready to send?", "reads": ["draft"],
"choices": { "yes": "done", "no": "revise" } } },
"revise": { "kind": "agent", "instruction": "revise the response", "reads": ["draft"],
"writes": "draft", "edges": { "ok": "judge", "error": "manual" } },
"done": { "kind": "halt", "status": "completed", "result_from": "draft" },
"manual": { "kind": "halt", "status": "refused" }
}
}The revise → judge back-edge is the loop; the semantic branch decides when to
exit; the visit cap + progress guard stop a draft that never converges; the
infer output feeds a free deterministic branch.
The dialect-2 surface in one graph — review a change three ways at once
(parallel), fold the verdicts into one object (merge reducers inside the
branches, one object out), ask a human over A2A with the full evidence
(human), branch on the answer, and survive a mid-pipeline crash
(checkpoint):
{
"dialect": 2,
"checkpoint": { "server": "state", "key": "release/{run_id}" },
"start": "reviews",
"nodes": {
"reviews": { "kind": "parallel",
"branches": {
"security": { "start": "s", "nodes": {
"s": { "kind": "agent", "instruction": "security-review the change", "reads": ["change"],
"writes": "r", "edges": { "ok": "h", "error": "hf" } },
"h": { "kind": "halt", "status": "completed", "result_from": "r" },
"hf": { "kind": "halt", "status": "crashed", "result_from": "r" } } },
"perf": { "start": "p", "nodes": {
"p": { "kind": "infer", "prompt": "Estimate the perf impact.", "reads": ["change"],
"schema": { "risk": "string", "p99_delta_ms": "number" },
"writes": "r", "edges": { "ok": "h", "error": "hf" } },
"h": { "kind": "halt", "status": "completed", "result_from": "r" },
"hf": { "kind": "halt", "status": "crashed", "result_from": "r" } } },
"tests": { "start": "t", "nodes": {
"t": { "kind": "tool", "server": "ci", "tool": "run_suite",
"args": { "ref": { "$from": "change", "pointer": "/ref" } },
"retry": { "max": 2, "backoff_ms": 5000 },
"writes": "r", "edges": { "ok": "h", "error": "hf" } },
"h": { "kind": "halt", "status": "completed", "result_from": "r" },
"hf": { "kind": "halt", "status": "crashed", "result_from": "r" } } }
},
"on_error": "continue",
"writes": "evidence", "edges": { "ok": "gate", "error": "gate" } },
"gate": { "kind": "human",
"payload": { "question": "Ship it?", "evidence": { "$from": "evidence" } },
"timeout_ms": 86400000,
"writes": "verdict",
"edges": { "replied": "route", "timeout": "abort" } },
"route": { "kind": "branch",
"cases": [ { "when": { "op": "eq", "key": "verdict", "pointer": "/approve", "value": true },
"goto": "ship" } ],
"default": "abort" },
"ship": { "kind": "tool", "server": "deploy", "tool": "rollout",
"args": { "ref": { "$from": "change", "pointer": "/ref" } },
"writes": "rollout", "edges": { "ok": "done", "error": "abort" } },
"done": { "kind": "halt", "status": "completed", "result_from": "rollout" },
"abort": { "kind": "halt", "status": "refused", "result_from": "evidence" }
}
}What each capability buys here:
- The three reviews run concurrently on the lane pool — an agent turn, a
structured
infer, and a plain CI tool call, each in the shape it deserves;on_error: continuemeans one failed review does not blind the human — its error marker lands inevidencealongside the others. - The human sees everything (
evidencerides the A2A task's status message), replies{"approve": true}from any A2A client, and the deterministic branch routes on/approve— free, no model call. - Durability: the fan-out completes as one superstep (no mid-lane checkpoints
— a deliberate v1 simplification), so a pod lost during the reviews re-runs
them; a pod lost after them resumes at
gatewith the evidence intact — the human is never re-asked for already-gathered facts, and a crash while waiting on the human resumes the wait (suspensions always checkpoint). - The whole run stays inside the step budget / token pool / deadline, and the
operator reads its live face on
agent://workflowand the A2A task states.
- modes-and-triggers.md — the four base modes and reactive routing (a workflow is the explicit form of the same event→action machinery).
- subagents.md — the
agentnode runs a subagent turn;subagent.spawn{workflow}/workflow.run{detach}delegate whole workflows to children; the spawn caps still apply. - configuration.md —
--workflow/--mode workflowand the run limits a workflow inherits (--max-steps,--max-tokens= the shared token pool,--deadline= the whole-workflow wall clock).