Documentation — Node registry
Reference

Node registry

Every workflow node agentd implements, what it needs, and what it does. The tables are generated from the binary's own registry (agentd --workflow-schema), so the required-field columns are what the parser actually enforces.

72 kinds — 10 start nodes and 62 steps. All are implemented. If this page and the binary ever disagree, the binary is right; regenerate from agentd --workflow-schema.


Before the tables: six things that are easy to get wrong

These are the traps, in the order people hit them.

1. References are steps.<id>.output.<path>. A step's result is not at its bare name. Writing {{ hook.body.id }} reads nothing; you want {{ steps.hook.output.body.id }}. The namespaces are:

NamespaceHolds
steps.<id>.outputwhat a completed step returned
vars.<key>what assign/transform wrote (writes: names the key)
inputsthe run's inputs, from the start node's inputs mapping
envinstance, run id, instruction — plus, per step: env.step, env.attempt, and env.idempotency_key (stable across retries of the step; env.ts is NOT)
memory.<key>durable memory, read through for keys the definition names
signalsrecently delivered signals

Templates are resolved at RUN time, not validated at author time. A typo in a path is not a config error — it is a step failure at the moment the step runs. --validate-config will not catch it.

2. switch cases name ONE step, as a string. cases: {select: prepare}, not cases: {select: [prepare]}. A case value is compared against a step id, so a list can never match anything; the loader refuses it outright, with a message saying what to write instead of leaving a branch that is silently unreachable.

3. A step's status can be pruned. A branch nobody chose is pruned, not skipped, and the difference is load-bearing: a skipped step satisfies its dependents (that is how several start nodes work, and how uneven joins proceed), while a pruned one does not. A step is pruned only when EVERY inbound path is pruned — one live parent keeps it alive.

4. Signals are edge-triggered. workflow.signal wakes waiters that are ALREADY suspended. A signal sent before the waiter parks is missed — there is no buffering. Two sibling steps in one run, one signalling and one waiting, is a race with no ordering primitive to fix it; signal across runs, or from a tool or an operator, where the waiter is demonstrably parked first.

5. CEL is a build feature. when, until, filter and the expr of map/filter/reduce need it. The release binaries ship with it enabled; a binary built without it exits 2 on a config that uses those fields rather than ignoring the expression and running the step anyway.

6. Nested steps are scoped. A step inside a foreach/parallel/race/ subgraph gets a compound id — each[0].work, par{branch}.work, sub.work — which is what you will see in the logs and what cancellation matches on.


Start nodes — what creates a run

KindRequiredOther fieldsWhat it does
oncepolicy inputsFires when armed at boot/restore, or on workflow.run. policy: ensure skips if a run is already live.
manualinputsFires only on workflow.run (a tool call or an A2A command). Nothing arms it.
loopinterval delay until max_iterations backoff inputsFires again each time the previous run finishes. interval/delay pace it, until and max_iterations stop it, backoff slows it after failures.
schedulecron every tz jitter catch_up at inputsFires on a 5-field UTC cron or an every interval. at is one-shot and consumes itself. catch_up decides what a missed window does.
subscribeserver uridebounce_ms coalesce filter deliver on_no_listener window inputsFires when an MCP resource changes (notify-then-read). debounce_ms/coalesce collapse bursts; filter drops uninteresting reads; window: {samples: N} delivers the last N read values (output.window) — the trend, not just the reading.
signalnamefilter deliver inputsFires on a named signal from another run, a tool, or an operator.
eventonfilter inputsFires on an internal event — `workflow.finished
streamstreamsubject filter from rate inputsFires once per event on a declared stream — including events another workflow emitted. subject matches exactly or by prefix.* glob; from: earliest replays the backlog into a consumer that did not exist when the events were published; the offset is durable, so a restart resumes where it left off, exactly once. A workflow never fires on its own emits; rate: "<burst>/<per>" paces consumption (events queue durably — rate: "1/1d" turns a stream into a worked-off daily queue). Output is the event: …output.subject, …output.data.*, …output.correlation.
a2acommand roles schema inputsFires when a principal sends a message whose command matches. Declaring command REGISTERS it as an A2A command the listener accepts; schema is the payload CONTRACT — a non-conforming command is refused at the listener, synchronously, naming the mismatch. roles narrows who may fire it. Output: …output.args.* (the typed payload), plus parts/text/principal.
webhookpathmethods auth parallelism on_overflow rate idempotency respond filter signal inputsFires on an inbound HTTP request at path. Needs webhooks.listen; a non-loopback listener must authenticate every route. rate: "<burst>/<per>s" throttles arrivals (429 + Retry-After past it). signal: "name/{{ body.field }}" also fires that signal with the payload — the webhook→signal relay as one field.

Control flow

KindRequiredOther fieldsWhat it does
switchon casesdefault on_no_matchRoutes to ONE named step per case. Case values and default are step-id STRINGS, not lists. The chosen branch runs even if its deps are not terminal; the others are skipped. No case and no default is a FAILURE unless on_no_match: skip — then the switch completes and every branch is pruned.
parallelbrancheson_errorRuns every branch concurrently. on_error decides whether one failure fails the step.
foreachover bodybatch collect on_error asRuns body once per element of over. batch {size, parallel, rate} paces it; collect gathers outputs; as names the element.
batchover bodyby size parallel rate collect on_errorLike foreach but the body sees a GROUP of elements — size/by form the groups.
iteratebodywhile until max_iterations collectRepeats body while/until a condition, bounded by max_iterations. The loop primitive when there is no list to walk.
racebranchestimeout min_successRuns branches concurrently and takes the first to finish. min_success requires more than one; losers are cancelled.
joinhandlestimeout min partialsAwaits async handles (from workflow {mode: async} or subagent). min and partials decide what "enough" means.
subgraphbodyAn inline nested graph. Scopes ids, so the same step names can repeat in different subgraphs.
workflownameinputs mode start version cascadeStarts another workflow as a child run. mode: sync blocks, async returns a handle for join, detached forgets it. cascade propagates cancellation.
waitonserver uri condition signal run subagent conversation webhook stream subject match timeout on_timeoutSuspends until on resolves: `resource
sleepdurationSuspends for duration. Durable: the timer survives a restart.
assertconditionmessageFails the run unless condition holds. A guard you want loud.
failmessage codeEnds the run as failed with message/code.
noopDoes nothing. A join point, a switch target, or a placeholder.
checkpointnameForces a durable checkpoint here rather than at the next natural boundary.
finishstatus output reasonEnds the run with status (`completed

Data shaping (deterministic, no model, no network)

KindRequiredOther fieldsWhat it does
assignvaluewrites modeWrites value into the run vars at writes (default: the step id). `mode: overwrite
transformvaluewrites modeIdentical to assign; the name reads better when the value is computed from other data.
mapover exprasApplies expr to every element of over. as names the element (default item). Needs CEL.
filterover exprasKeeps elements of over whose expr is true. Needs CEL.
reduceover exprinitial as accFolds over with expr from initial; acc names the accumulator. Needs CEL.
sortoverby orderOrders over, optionally by a field and `order: asc
dedupeoverbyRemoves duplicates from over, optionally by a key.
chunkvalue sizeby overlapSplits value into pieces of size, with optional overlap.
templatetext valueRenders text (or value) against the run data. The general-purpose string builder.
parsetextformatParses text into data — `format: json
validatevalue schemaChecks value against a JSON schema; fails the step if it does not conform.

Durable state

KindRequiredOther fieldsWhat it does
memory.setkey valuettlWrites a durable key/value, with optional ttl.
memory.pushkey valueAppends to the ARRAY at key (created if absent) — the durable queue primitive.
memory.shift / memory.popkeyRemoves and returns the first / last element ({found: false} on empty — a drain loop just stops).
memory.getkeyReads a durable key. The result is {found, value}.
memory.listprefix limitLists keys under prefix, bounded by limit.
memory.deletekeyRemoves a durable key.
artifact.createnamemime content from_step sensitiveStores a durable blob — name, mime, content or from_step. sensitive keeps it out of transcripts.
artifact.getidReads an artifact by id.
artifact.deleteidRemoves an artifact by id.
KindRequiredOther fieldsWhat it does
knowledge.searchquerytop_k filtersSearches a configured knowledge source — query, top_k, filters.
knowledge.getid uriFetches one knowledge document by id/uri.
search.queryquerykind limit freshnessQueries a configured search source — query, limit, freshness.
search.fetchurlmax_bytesFetches one url through the search source, bounded by max_bytes.

Integration — reaching outside

KindRequiredOther fieldsWhat it does
mcp.toolserver toolargs idempotency breaker rateCalls tool on a declared MCP server with args. The main way a workflow reaches the outside world. Always attaches a retry-stable agent/idempotency_key in _meta; idempotency: {value: …} substitutes an application key.
mcp.resourceserver opuri name arguments reference argumentReads MCP resources — `op: read
toolnameargsCalls a tool by registry name, wherever it lives (internal, code-registered, or MCP).
httpurlmethod headers query body json timeout expect allow_private sign idempotency breaker rateOne outbound HTTP request. SSRF-guarded: resolved once and dialled by the vetted address. allow_private is a separate, larger decision. idempotency: {header: NAME} (or {query: NAME}) sends a retry-stable derived key; value: overrides it with an application key.
a2a.sendtoparts command args context timeout idempotency breaker rateNotifies a peer and continues — fire-and-forget. Completes when the peer ACCEPTS the message. idempotency: true pins the A2A messageId across retries so the peer can deduplicate. command + args send the TYPED DataPart the peer's a2a start matches on — deterministic dispatch, not prose the peer's model interprets.
a2a.delegatepeerobjective command args output_contract timeout idempotency breaker rateDelegates an objective to a peer and BLOCKS for the result. Request/response, where a2a.send is a notification. With command + args the payload is TYPED (and checked against the command's declared schema at the peer); the delegate blocks until the command's run finishes and returns its output. idempotency: true pins the messageId across retries.
a2a.waitconversation timeoutSuspends until a message arrives on a conversation. The reply half of a2a.send.
messagetotext parts wait timeout on_timeoutDelivers into one of THIS instance's own conversations, so a run can hand work to the agent rather than only the reverse. to is a context id, root, or new (a fresh conversation). The delivery takes the same readers, durability and per-context lock as an inbound A2A message. wait: reply parks on the answer; without it the step completes once the delivery is durable and the turn happens on its own schedule. Chained deliveries are capped by limits.max_message_depth (default 8) — see Loops.
workflow.signalnamepayload runSends a named signal. Edge-triggered: the waiter must already be suspended.
workflow.waitruntimeoutBlocks until another run reaches a terminal status.
workflow.cancelrunreasonCancels another run with a reason.
emitstream subject data correlation note audit metric valueWith stream/subject (they travel together, or a load error): publishes an event to a declared stream that any workflow's stream start can consume — durable, replayable, exactly-once downstream (the event id is the step's idempotency key, so a crash-replayed emit lands under the same id and consumers drop the copy). Without them: writes note to the root transcript, logs audit as an audit record, and returns value as the step output.

Intelligence — the steps that cost tokens

KindRequiredOther fieldsWhat it does
thinkpromptoutput_schema reads check retries skills system modelOne model call. output_schema shapes the answer; check/retries re-ask until it conforms.
agentinstructionoutput_contract output_schema tools servers limits context skills system modelA full agentic loop with tools — think, call, observe, repeat. tools/servers narrow what it may reach. context is a seed-message array, or the object form {template: <name>, seed: […]}template names an entry in context.templates to render this step's system prompt with, in place of the instance default.
subagentinstruction or templateparams mode tools servers limits priority context output_contract output_schema skills durableA child PROCESS running its own loop, with narrowed tools and trust. The supervisor can always kill it. template instantiates a subagents.templates entry — params fill its declared holes (schema-checked), tools/servers are refused (the template defines the grant), and an instance-tier template spawns a full child daemon whose handle is an A2A peer name. limits adds OS caps — memory (RLIMIT_AS), cpu (RLIMIT_CPU) — beside steps/tokens/deadline; priority: low|normal|high maps to niceness and sheds low first under pressure.
classifyinput classesprompt skills modelPuts input into one of classes.
extractinput output_schemaprompt skills modelPulls input into the shape of output_schema. No tools — the safest way to read untrusted text.
summarizeinputlength prompt skills modelShortens input to length.
judgeinput rubricprompt skills modelScores input against a rubric.
routeinput choicesprompt skills modelPicks one of choices for input. The model-driven alternative to switch.
humanquestionschema to timeoutAsks a person question and suspends durably until they answer — the answer can arrive after a restart. schema is ENFORCED on the reply: a mismatch re-asks with the reason rather than being accepted. to names who must answer (see Addressed gates); omit it and any watcher may answer. reply_uri is refused at load — nothing implements it.

model names an intelligence.models tier on any kind that makes a model call — the five shaping presets included, since those are the cheap high-volume steps a tier catalogue exists for. It resolves to the wire model at the edge, so a tier name never reaches a provider, and an unknown one is exit 2 rather than a run asking for a model nobody serves.


Cross-cutting fields

Every step accepts these regardless of kind:

FieldEffect
depends_onthe DAG edge. A non-start step with no dependency is refused as an unreachable root
whena CEL guard; the step is skipped when it is false (needs cel)
retry{max, backoff} — retry on failure: exponential doubling with deterministic ±20% jitter, durable timer between attempts
breaker{failures, cooldown} — cross-run circuit breaker, on the remote-effect kinds only (http, mcp.tool, a2a.send, a2a.delegate): opens after N consecutive failures, fails fast, one probe per cooldown; durable per workflow/step
rate"<burst>/<per>s" — outbound throttle on the same kinds: the step WAITS (durable timer, no attempt consumed) for a token, so fan-outs drain at the declared pace instead of bursting
timeoutbound the step; a suspended step resumes as timed out
on_errorfail (default) · continue · goto:<step>
output_schemavalidate the step's output; also SHAPES the answer for model kinds
cache{key, ttl} — reuse a previous result
budgeta per-step token/step allowance
skillsskills to load for a model step
on_replaywhat restore does with a step caught in flight by a crash: retry (default), skip, or fail
idempotentparsed and validated, but nothing reads it — use on_replay to control a replay
description, oteldocumentation and trace attributes

Durability, and what a restart does

Every effectful step checkpoints before its effect (pure data steps replay deterministically from the last checkpoint instead of writing one each). On restore:

  • A step that was Running is replayed (on_replay decides: retry, skip, fail).
  • A step that was Suspendedwait, sleep, human, a2a.wait — resumes waiting, with its deadline intact. A human gate opened before a restart can still be answered after it.
  • A timer whose durable record went missing is repaired at restore rather than leaving the step unreachable.

Effects can therefore run twice. Give a step whose effect must not repeat on_replay: fail (or skip), or make the underlying tool idempotent — every remote-effect step already carries a retry-stable idempotency key for that.

Addressed gates

A human gate — and ask_human — normally reaches whoever is watching this agent's tasks. to narrows that to a named decider:

approve:
  kind: human
  question: "Refund {{ inputs.order_id }} for {{ inputs.amount }}?"
  schema: {type: object, required: [approved], properties: {approved: {type: boolean}}}
  to: "*@finance.example"                      # a principal-id glob
  # or, when identity is better described than enumerated:
  # to: {role: user, labels: {team: finance}}
  timeout: 24h
  on_error: continue                           # a lapsed gate is a "no", not a crash

on_timeout is a wait field, not a human one — a gate has no expected-branch routing, so a deadline that passes fails the step. Route it with on_error (continue, or goto:<step>) and branch on the answer with a rendered default rather than a CEL when: an unevaluatable guard is fail-closed and fails the whole run, so {{ steps.approve.output.approved | false }} into a switch is the construction that behaves the same whether the gate was answered, refused, or timed out.

A reply from anyone else is refused with an explanation and the gate stays open, rather than the answer vanishing into the conversation. Conditions are ANDed, so adding one always narrows — an operator tightening a gate never widens it by accident. Labels are the durable form (people change, teams do not) and come from a2a.principals[].labels.

Three declarations are load errors rather than accepted-and-ignored, because each produces a gate that looks routed and is not: one that names nobody (to: {}), one that names role: anonymous — precisely the identity nothing vouches for — and any typo in a field or role name.

An addressed gate is never auto-answered, whatever agent.approval says. A model judge standing in for the finance lead makes the record a lie, and an operator who set approval: auto was making a statement about the agent's own asks, not about a gate that names someone.

An operator can still answer, and this is deliberate. Refusing them would be theatre — an operator can already rewrite the config, the store or the definition — so what matters instead is that it is visible: the answer is recorded as operator_override, logged, and audited under the id of whoever actually replied. The audit line names the person rather than "human", which is what makes "the finance lead approved this refund" a record instead of a claim.

Both the addressee and the answer schema live in the run's durable wait record, so a restart rebuilds the gate exactly as declared. That matters more than it sounds: a gate whose enforcement lived only in memory would quietly weaken on restart, accepting anyone and anything.

Message loops

message closes a cycle the runtime did not previously have: a run can start a turn, and a turn can start a run. Left alone that re-arms forever — message → turn → workflow.run → finish → message — and it is not something pressure shedding can hold, because shedding queues new turns while the chain keeps adding more.

The guard is hop depth, not volume. Twenty unrelated workflows greeting the operator are not a loop; one workflow greeting itself is. A run inherits the depth of the work that caused it, each delivery adds one, and a delivery past limits.max_message_depth (default 8) is refused before it becomes durable. The step fails and names the limit, so a chain that would have spun silently is a visibly broken workflow instead.

Two refusals fall out of the same rule: message.send will not deliver into the conversation its own caller is running in, and on_workflow_finished: think continues the run's chain rather than starting a fresh one.

Choosing between near-neighbours

If you wantUseNot
a peer to do something, and you need the answera2a.delegatea2a.send
a peer to know something, and you carry ona2a.senda2a.delegate
to branch on data you already haveswitchroute (a model call)
to branch on meaning, not a valueroute / classifyswitch
to read untrusted text safelyextract (no tools)agent (has tools)
a child that can be killedsubagent (a process)agent (in-process loop)
to walk a listforeachiterate
to repeat until a conditioniterateforeach
to run branches and take the firstraceparallel
to run branches and need them allparallelrace

Regenerating this page

agentd --workflow-schema | jq '.["$defs"].kinds'

The same schema is published at https://agentd.dev/schema/workflow.json for editor autocomplete — see Editor autocomplete.

Each entry carries fields, required, start and implemented. That is the authority; this page is prose around it.