Table of Contents
- Directory Structure
- graph.yaml Top-Level Fields
- Node Types
- State and Template Syntax
- state_updates
- Routing & Tolerant-Fail
- Parallel Execution
- The super-step model
- Static fan-out
- Dynamic fan-out via map
- Reducers
- Branch isolation
- Multi-branch UX
- Streaming behavior
- Error propagation
- Multi-End rejection
- Approval and input nodes are forbidden in fan-out
- What sequential graphs see
- Structured Output (output_schema)
- Worked Example
- Validation
- Invocation Entry Points
- Streaming and Observability
- Limitations / Gotchas
- See Also
Graph-based agents are a declarative, YAML-driven workflow engine layered on top of Coyote's existing agent system. Where a normal agent runs as a single LLM loop driven by tool calls, a graph agent is a directed graph of typed nodes. Each node performs one well-defined step (call an LLM, run a script, ask the user a question, spawn a child agent, etc.) and routes to the next node based on its result.
Graph agents are best for workflows that:
- Have a fixed shape (e.g. parse -> query -> grade -> synthesize -> verify)
- Mix LLM calls with deterministic steps (scripts, user prompts)
- Need explicit human-in-the-loop checkpoints
- Benefit from per-step model / tool / temperature overrides
- Fan out work across parallel branches: Research three sources at once, map an LLM call over a runtime-determined list of inputs, then merge results back through declared reducers (see Parallel Execution)
If you just want an agent that takes a goal and figures out the steps on its own, stick with a regular agent.
Directory Structure
A graph agent is defined by a single graph.yaml. It holds both the
agent-level config (model, tools, MCP servers) and the workflow:
<coyote-config-dir>/agents
└── my-graph-agent
├── graph.yaml # agent config + workflow definition
├── tools.sh # optional custom tools
├── <rag-node-id>.yaml # auto-built knowledge base for a rag node
└── scripts/ # optional script-node implementations
├── decide.py
└── verify.py
<rag-node-id>.yaml files are generated by Coyote at agent load time - one
per rag node - and should not be hand-edited.
An agent directory must contain either a config.yaml (a normal,
LLM-loop agent (see Agents)) or a graph.yaml (a graph
agent). Never both. The presence of graph.yaml is what marks an agent
as a graph agent; when Coyote runs it, execution is driven entirely by the
graph.
Both files present is an error. If an agent directory contains both
config.yaml and graph.yaml, Coyote refuses to load it and tells you to
remove one. Pick the model that fits: config.yaml for an open-ended
LLM-loop agent, graph.yaml for a fixed-shape workflow.
graph.yaml Top-Level Fields
name: my-graph-agent
description: |
Plain prose describing what the workflow does.
version: "1.0"
# --- agent-level config ---
model: anthropic:claude-sonnet-4-6 # default model for llm nodes
temperature: 0.0 # default sampling temperature
top_p: null # default sampling top-p
global_tools: # global tools available to nodes
- web_search_coyote.sh
mcp_servers: # MCP servers available to nodes
- pubmed-search
skills_enabled: true # optional; master switch for skills in `llm` nodes
enabled_skills: # optional; the *universe* of skills referenceable by any llm node
- code-review
- git-master
inject_skill_instructions: true # Inject a hint pointing the model at `skill__list`. Defaults to true; suppressed
# automatically when no skills are available.
skill_instructions: null # Custom text for the skill hint (optional; uses the built-in default if omitted).
conversation_starters: # suggested prompts in the UI
- "Research WebAssembly outside of the browser"
variables: # optional; agent variables
- name: project_dir # exposed to script nodes as
description: Project root # LLM_AGENT_VAR_PROJECT_DIR, overridable
default: "." # via `--agent-variable` at runtime
settings:
max_loop_iterations: 100 # PER-NODE visit cap; default 100 (see below)
log_state_snapshots: true # log state JSON before each node executes
validate_before_run: true # run the graph validator on startup
timeout: 600 # optional overall timeout in seconds
max_concurrency: 8 # cap on simultaneously running parallel branches; default 8
reducers: # optional; declares how parallel branches merge writes
results: append # see "Parallel Execution" below
cost_usd: sum
initial_state: # optional seed state for the run
topic: "auth"
start: parse_input # required: ID of the first node to run
nodes:
parse_input: { ... }
...
version: Currently only"1.0"is accepted by the parser. Anything else fails at startup. This is the graph schema version, not your agent's version.- Agent-level config (
model,temperature,top_p,global_tools,mcp_servers,conversation_starters,variables) are all optional. These are the same fields a normal agent'sconfig.yamlcarries; in a graph agent they live at the top ofgraph.yamlinstead.model/temperature/top_pact as the defaults forllmnodes that don't set their own.global_toolsandmcp_serversdefine the tool universe that anllmnode'stools:whitelist selects from (a node with notools:field gets none of them). variables: Same shape as a normal agent'svariables:block. Each declared variable becomes available to script nodes as the env varLLM_AGENT_VAR_<UPPER_NAME>, exactly like bash tools called by normal agents. Values may be overridden at runtime viacoyote -a <agent> --agent-variable <name> <value> "...". For LLM nodes to see a variable inside prompts, seed its value into state (typically via a setup script that reads the env var and writes it into state) so{{name}}resolves.can_spawn_agentsis derived, not declared. A graph agent can spawn child agents iff its graph contains at least oneagentnode. You don't set a flag. Theagentnode's presence is the declaration.max_loop_iterations: This is a per-node visit cap, not a total graph-step cap. If the same node id is entered more than this many times, execution aborts withNode 'X' visited N times (max_loop_iterations=...). Default: 100.timeout: Wall-clock cap on the entire graph run. The executor checks this between every node transition; nodes that block longer than the timeout will still finish before the check fires.initial_state: A JSON-compatible object. Values are seeded into state before any node runs and are referenced from any node via{{key}}templates.max_concurrency: Caps the number of branches that can execute concurrently in any single parallel super-step. Default 8. Per-mapnodes can override this with their ownmax_concurrencyfield. The graph-wide cap is a safety net against LLM rate-limit blowups when anext: [a, b, c]fan-out is followed by deeper parallelism. Must be>= 1.reducers: Declares how state-key writes from concurrent branches are combined. Required for any state key that two or more parallel branches write in the same super-step (the validator catches missing reducers at load time). See Parallel Execution for the full reducer reference and merge semantics.skills_enabled/enabled_skills: Optional skills policy for the graph.skills_enabled: falseturns skills off for everyllmnode in the graph regardless of node-level settings.enabled_skillsis the universe: the set of skill names anyllmnode may reference in its ownenabled_skills. Subset-violations are caught by the validator at load time. See Skills in Graph Agents for the full per-node story.
{{initial_prompt}}: Automatically Seeded
When Coyote invokes a graph agent with a user prompt (whether from the
command line coyote -a my-agent "what is X?", from the REPL, or from a
parent agent that spawned it as a sub-agent), the dispatcher automatically
seeds the prompt text into state under the key initial_prompt before
any node runs.
This means every graph agent's first node can reference the user's request
via {{initial_prompt}}:
parse_input:
id: parse_input
type: llm
prompt: "{{initial_prompt}}" # the user's command-line / REPL text
...
You do not need to (and should not) put initial_prompt in initial_state as it is overwritten by the dispatcher.
Node Types
There are eight node types: agent, script, approval, input, llm, rag, map, and end. Every node has these common fields:
my_node:
id: my_node # must match the map key
type: <one of the eight>
description: optional # free-form
next: another_node # optional default next node; semantics vary per type
# OR
next: [a, b, c] # fan out to multiple branches in parallel
The next field defines the default routing edge. Node types interpret it
differently (some types ignore it in favor of internal routing; see each type
below). next: is polymorphic: a single string (next: foo) routes
sequentially; a list (next: [a, b, c]) declares a static fan-out. Every
target runs as a parallel branch in the next super-step. Approval and input
nodes cannot use the list form (the validator rejects parallel user
prompts). See Parallel Execution for the full model.
agent
Spawns a Coyote sub-agent and waits for it to finish. This is how a graph agent delegates a sub-goal to a fully autonomous Coyote agent (with its own tool loop and configuration).
research_topic:
id: research_topic
type: agent
agent: deep-researcher # name of an existing Coyote agent
prompt: "Research {{topic}}" # interpolated against state
timeout: 600 # optional, in seconds (default 300)
state_updates:
findings: "{{output}}"
output_schema: { ... } # optional, see "Structured Output" below
next: render
agent: Name of the child agent to spawn. Must exist in<coyote-config-dir>/agents/.prompt: The user message sent to the child agent. Templated against the current graph state.timeout: Hard wall-clock cap. If the child agent exceeds it, the whole graph fails (no built-in fallback path on agent nodes).state_updates: Map ofstate_key: "{{template}}". The child agent's final text is available inside this map as{{output}}.
script
Runs a Bash, Python, or TypeScript script and merges its JSON-object stdout
into state. Script files live under the agent's scripts/ directory.
Supported extensions and runtimes:
| Extension | Runtime invoked | Notes |
|---|---|---|
.sh |
bash <script> |
|
.py |
python3 <script> |
not python. Must be Python 3 |
.ts |
npx tsx <script> |
requires Node + tsx available on PATH |
.js / .mjs / other extensions are not supported. The shebang line
inside the script is not used for script-node dispatch (it is for normal
custom-tools); the file extension is the source of truth.
route_after_parse:
id: route_after_parse
type: script
script: scripts/route_after_parse.py
timeout: 30 # seconds, default 30
fallback: handle_error # optional: where to route on script failure
state_updates: # applied after stdout merge
last_run: "{{some_value}}"
Environment
The script receives the current graph state in one of two forms (use whichever fits):
| Env var | Contents |
|---|---|
GRAPH_STATE |
Inline JSON when serialized state is <= 32 KiB |
GRAPH_STATE_FILE |
Path to a temp JSON file when serialized state exceeds 32 KiB |
Exactly one of the two is set per script invocation; always check both. The temp file (when used) is cleaned up automatically after the graph finishes.
Script nodes also receive the same environment that custom agent tools get when called from normal agents:
| Env var | Contents |
|---|---|
LLM_ROOT_DIR |
Coyote config dir (e.g. ~/.config/coyote) |
LLM_PROMPT_UTILS_FILE |
Absolute path to .shared/prompt-utils.sh |
LLM_AGENT_DATA_DIR |
The agent's own data directory (where this graph.yaml lives) |
LLM_AGENT_VAR_<NAME> |
One per declared variables: entry; uppercased name |
PATH |
Coyote's functions bin dir prepended to the inherited PATH |
CLICOLOR_FORCE, FORCE_COLOR |
Both set to 1 so child tools emit ANSI colors |
The script's working directory is the coyote invocation directory (where
the user ran coyote -a <agent> ...), not the agent directory. This matches
the behavior of bash tools called by normal agents, and lets scripts use
"." or relative paths to refer to the user's project. To resolve sibling
files inside the agent directory (for example .shared/utils.sh), use
"$(dirname "$0")" from inside the script. Note that$0 is an absolute path to
the script file itself.
The script must print a single JSON object on stdout. All keys merge into
state; the reserved _next key is extracted and overrides the default next
routing.
#!/usr/bin/env python3
import json, os
def load_state():
if path := os.environ.get("GRAPH_STATE_FILE"):
with open(path) as f:
return json.load(f)
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
state = load_state()
codes = (state.get("web_search_results") or "").strip()
next_node = "query_db" if codes else "ask_for_code"
print(json.dumps({"_next": next_node, "trimmed_codes": codes}))
Tolerant-fail: if the script exits non-zero or produces invalid JSON, the
node routes to fallback (if set) or to next (if set). Without either,
the graph errors.
Do not pause for user input from inside a script node.
The script process has no TTY and no I/O channel back to the REPL; any interactive prompt
(read, input(), password prompts, blocking waits for keypresses, etc.)
will hang forever and permanently stall the graph. The only supported ways
to ask the user something from a graph are the input and
approval node types. The same restriction applies to custom
agent tools invoked by llm nodes. See Custom Tools for
more information on custom tools.
approval
Prompts the user with a question and a list of options, then routes based on their answer. This is the human-in-the-loop checkpoint.
approve:
id: approve
type: approval
question: |
Final report:
{{report}}
Approve?
options:
- "yes"
- "no"
routes:
"yes": end_accepted
"no": end_rejected
on_other: clarify # Required - see below
state_updates:
decision: "{{choice}}"
The on_other field
This field is required and easy to miss. Coyote's user__ask tool always
gives the user a "type your own answer" option in addition to the listed
options. There is no way to disable this. Without on_other, a user who
types something other than the listed options would crash the graph at
runtime.
on_other says where to route when the user's answer does not match any
routes key. The free-form text they typed is available downstream via
the {{choice}} template variable inside state_updates.
Common patterns:
- Free-form means "I want to clarify" ->
on_other: clarify_nodewhereclarify_nodeis aninputorllmnode that processes their text. - Free-form means "rejection by default" ->
on_other: end_rejected.
input
Collects a free-form string from the user.
ask_for_code:
id: ask_for_code
type: input
question: "Enter a search term:"
default: "{{last_used_code}}" # optional, interpolated against state
validation: "len(input) > 0" # optional, see below
state_updates:
web_search_result: "{{input}}"
next: query_db
default: If the user submits an empty response, this template is used. Onlydefaultitself is templated, not the surrounding question (which is also templated).validation: A length predicate of the formlen(input) <op> <integer>, where<op>is>,>=,<,<=, or==. This is a deliberately narrow grammar; regex / type / range validation are not yet supported. If validation fails, the node fails (no fallback).- The user's text is exposed to
state_updatesas{{input}}.
llm
A one-shot LLM call with an optional bounded tool-call loop. Unlike agent
nodes, this does NOT spawn a sub-agent; it runs in a fresh isolated context
with a caller-supplied system prompt and user prompt. Tool access is strictly
opt-in: an llm node gets no tools at all unless its tools field
explicitly lists them (see below).
grade_research:
id: grade_research
type: llm
instructions: | # optional system prompt
You decide whether research is needed for {{topic}}.
prompt: | # required user prompt
Research context:
{{research_text}}
Reply with YES or NO.
tools: [] # see below
model: anthropic:haiku # optional override
temperature: 0.0
top_p: null
max_attempts: 1 # transient-error retries (default 1)
max_iterations: 10 # tool-call-loop turn cap (default 10)
fallback: skip # routes here if all attempts fail
state_updates:
grade: "{{output}}"
output_schema: { ... } # optional, see "Structured Output" below
timeout: 120 # optional; node wall-clock cap in seconds (unset = no timeout)
next: synthesize
The tools field (whitelist)
The tools field is a strict opt-in whitelist: an llm node receives
only the tools it explicitly lists, never the agent's full tool set.
Three modes:
- Unset (field omitted) -> no tools. The LLM produces output but
cannot make any tool calls. This is identical to
tools: []. Leaving the field out does not inherit the agent's tools. tools: []-> no tools. Same as unset.tools: [a, b, mcp:server-name]-> only those specific tools, and nothing else. Entries are either exact tool names (matchingglobal_tools, agent custom tools, or individual MCP function names) or the shorthandmcp:<server-name>(which enables all functions for that MCP server).
Even when tools lists entries, the LLM receives exactly that set. The
whitelist is enforced against global tools, agent custom tools, and MCP
alike. Each entry is validated at startup against the active agent's tool
list; an unknown entry is a startup error.
Failure routing
| Outcome | Behavior |
|---|---|
| Success | Routes via next. |
Failure with fallback set |
Routes via fallback; state_updates are still applied; {{output}} holds the error. |
Failure without fallback |
Graph fails at this node with a clear error message naming the underlying cause. |
state_updates are always applied when the node has a fallback route
(success or failure). On failure with no fallback, the graph aborts before
downstream nodes run, so downstream {{output}} references never see error
strings; the upstream cause is reported instead.
Retries (max_attempts)
max_attempts retries the LLM call only on transient errors. The
failure message containing one of: timed out, rate limit, 429,
Connection reset, Connection refused, or produced no output. Any
other error fails immediately without consuming further attempts. The
default is 1 (no retries).
Skills on llm nodes
llm nodes are the only place skills attach inside a graph. Two
optional fields, mirroring the same fields on roles and agents:
implement:
type: llm
prompt: "{{plan_summary}}"
tools: [fs_write, fs_patch, fs_cat]
skills_enabled: true # optional; default inherits from graph level
enabled_skills: # optional per-node restriction; must be a subset
- code-review # of graph-level enabled_skills
- verification-gates
Semantics (discovery-only, no auto-load):
skills_enabled: Per-node override of the skills master switch.falsedisables skills for this node only: noskill__list/skill__load/skill__unloadmeta-tools are exposed to the model.trueor omitted inherits the graph-level / agent-level setting.enabled_skills: Per-node restriction of which skills the model can see and load. The graph-levelenabled_skillsis the universe; the per-node list must be a subset (the validator catches violations at load time). When set,skill__listshows only these skills andskill__loadonly accepts these names.- The model loads what it needs. Nothing is auto-loaded. While the
node runs, the model uses
skill__listto discover what's available andskill__loadto bring a skill's body / tools / MCP servers into scope when it actually needs them. This matches the design intent of skills as a dynamic capability layer. - Per-node policy isolation. The node temporarily narrows the active
agent's skill policy to the node's
enabled_skills. The original policy is restored when the node finishes, so subsequent nodes see their own policy. Any skills the model loaded during the node persist into subsequent nodes by default; they're real registry insertions, not node-scoped state. (Use the skill's ownauto_unload: truefrontmatter for skills that should clean up automatically at turn end.)
Agent nodes (which spawn full sub-agents) intentionally have no
enabled_skills field. The spawned child agent's own
config.yaml/graph.yaml declares its skill policy.
rag
Runs a hybrid (vector + keyword) retrieval against a per-node knowledge base
and writes the result into state. This is how a graph agent does
Retrieval-Augmented Generation: the rag node retrieves context, downstream
llm/agent nodes inject it into their prompts via normal templating.
research_context:
id: research_context
type: rag
documents: # required; The knowledge sources
- ./knowledge/
- https://example.com/spec
query: "{{initial_prompt}}" # templated; defaults to "{{initial_prompt}}"
top_k: 5 # optional; default = the knowledge base's own top_k
timeout: 120 # optional; retrieval timeout in seconds (default 120)
state_updates: # required in practice (see below)
rag_context: "{{output.context}}"
rag_sources: "{{output.sources}}"
next: answer
answer:
type: llm
prompt: |
Use this context to answer:
{{rag_context}}
Question: {{initial_prompt}}
documents: Knowledge sources: files, directories, URLs, or loader-protocol paths. Required. It's what makes the node aragnode. Relative paths resolve against the agent's directory.query: The retrieval query, templated against state. Defaults to{{initial_prompt}}. Set it to{{refined_query}}to retrieve against a query an upstreamllmnode produced.top_k: Number of chunks to retrieve. Defaults to the knowledge base's own configuredtop_k.timeout: Retrieval timeout in seconds. Default 120.state_updates: Where the result goes. Aragnode with nostate_updatesdiscards its result (the validator warns).
Knowledge-base build config (all optional; used only when the knowledge base is first built):
embedding_model: Embedding model for the corpus.chunk_size: Document chunk size.chunk_overlap: Overlap between chunks.reranker_model: Reranker applied to hybrid-search results.batch_size: Embedding-request batch size.
Each falls back to the app-level rag_* config when omitted. When
embedding_model, chunk_size, and chunk_overlap are all set, the
knowledge base builds with no interactive prompts. So a fully-specified
rag node works in non-interactive runs.
{{output}} shape
Inside state_updates, {{output}} is a JSON object:
{
"context": "[Source: ./knowledge/a.md]\n...chunk...",
"sources": ["./knowledge/a.md", "https://example.com/spec"]
}
{{output.context}}: The retrieved context block, ready to inject into a prompt.{{output.sources}}: An array of source paths;{{output.sources[0]}}indexes individual sources (useful for downstream citation/verification nodes).
Knowledge base lifecycle
Each rag node's knowledge base is built once, at agent load time, into
<agent-dir>/<node-id>.yaml:
- If that file exists -> it is loaded (no prompt; works non-interactively).
- If it's missing and the node is fully specified (
embedding_model+chunk_size+chunk_overlapall set) -> it is built directly, no prompts. Works in non-interactive runs. - If it's missing, not fully specified, and Coyote is interactive -> you are asked to initialize it, then prompted for the missing build values; declining is a hard error.
- If it's missing, not fully specified, and Coyote is non-interactive (no TTY) -> hard error, with a hint to set the build-config fields or run the agent once interactively.
A graph with a rag node whose knowledge base isn't built cannot run.
This is deliberate fail-fast behavior. (In --info mode the agent is only
inspected, not run, so knowledge-base building is skipped entirely.)
Retrieval
Retrieval at execution time is fast (no re-embedding of the corpus). It's the same hybrid vector + keyword search normal Coyote RAG uses. The corpus embedding/chunking cost is paid once, at load time.
map
Dynamic fan-out over a runtime-determined list. The map node reads a JSON
array out of state (via the over: template), spawns one parallel
sub-branch per item invoking the same branch node, and collects each
sub-branch's output into a state array in input order.
This is how a graph agent runs "research these N subjects in parallel" when
N is determined at run-time. For a fixed-cardinality fan-out (you know the
exact set of branches at YAML-write time), use next: [a, b, c] on the
preceding node instead.
fan_out_subjects:
id: fan_out_subjects
type: map
over: "{{subjects}}" # required; template must resolve to a JSON array
as: subject # required; bound name inside each sub-branch
branch: research_subject # required; node id to invoke once per item
output_key: output # optional; state key the branch writes (default "output")
collect_into: research_results # required; state key to receive the array of outputs
max_concurrency: 5 # optional; overrides settings.max_concurrency
next: rank # required; where to go after the map collects
research_subject: # the branch node (atomic — runs N times)
id: research_subject
type: llm
prompt: "Research {{subject}} in depth."
state_updates:
output: "{{output}}" # branch must write to `output_key`
# Note: no `next:` on a map branch; map branches are atomic
Field reference
over: A template expression that must resolve to a JSON array of items. Each item becomes one sub-branch invocation. If the array is empty, the map runs zero sub-branches and writes[]tocollect_into(which is correct semantically. I.e. mapping over nothing yields nothing).as: The state key under which each item is bound inside its sub-branch. The branch node then references it via{{<as>}}in its prompt / templates.branch: The id of the node to invoke per item. The branch's outputs across all sub-branches are aggregated intocollect_into.output_key: The state key each sub-branch must write its result to. Default"output". The map executor reads this from each sub-branch's state, extracts the value, and inserts it into thecollect_intoarray.collect_into: The state key in the parent's state that receives the array of all sub-branch outputs, in input-list order regardless of which sub-branch finished first. This is the map's user-visible determinism contract.max_concurrency: Per-map cap on concurrent sub-branches; falls back tosettings.max_concurrency(default 8) when unset. Must be>= 1if set.next: Required. Where the parent super-step continues after the map collects.
Branch node constraints
Map branches are atomic. One node, one execution per item, no chaining inside the branch. The validator enforces several rules at load time:
- Branch type must be
llm,agent,rag, orscript. Approval, input, end, or another map node as the branch is a load-time error. - Branch must not have
next:declared. Anything chained off the branch belongs after the map (downstream of thecollect_intowrite). - Branch's
state_updateskeys must be a subset ofoutput_key. The branch can write to its ownoutput_keyand nowhere else. - Branch must not declare
output_schema. Top-level schema-properties would auto-merge into state, polluting it across N parallel branches. Use explicitstate_updates: { output: "{{output}}" }instead.
If a sub-branch fails to write output_key, the map node errors loudly
(no silent missing-output behavior).
Empty over
If over resolves to [] (an empty array), the map invokes zero
sub-branches and writes an empty array to collect_into. This is not
an error. It's the correct semantic for "map over nothing yields
nothing". If you want to reject empty over as an error, gate it with a
script node upstream.
Nested map nodes are not supported in v1
A map branch cannot itself be another map node. The validator rejects this at load time. If you need M * N parallel work, restructure to a single map over a flattened list.
end
Terminates execution and returns a final result.
end_accepted:
id: end_accepted
type: end
output: |
Approved report:
{{report}}
state_updates: # optional last state mutations
completed_at: "now"
output: Templated against state, printed as the graph's final result.- Multiple
endnodes are fine; you pick which one routes here based on upstream conditions.
State and Template Syntax
Graph state is a serde_json::Value map. Templates use {{path}} syntax
inside any string field.
| Form | Resolves to |
|---|---|
{{key}} |
top-level value |
{{a.b.c}} |
nested object path |
{{arr[0]}} |
array index |
{{matrix[0][1]}} |
nested array indices |
{{users[0].name}} |
object field via index |
{{a.b.arr[2].field}} |
mixed path |
Rendering rules per value type:
- String -> as-is
- Number / bool / null -> stringified (
true,42,null) - Array / Object -> JSON-encoded compactly (
["a","b"],{"k":"v"})
Missing keys / paths behave differently per template-evaluation site:
- Inside a node's primary fields (
prompt,instructions,question,output) -> strict mode, missing keys raise an error. - Inside
state_updatesvalues -> lenient mode, missing keys become empty strings.
state_updates
Every node type (except end, which has a slightly different shape) accepts
an optional state_updates map:
state_updates:
some_key: "{{template}}"
other_key: "literal text with {{var}}"
After the node body executes, each template is interpolated against state and
the result is stored under the corresponding key. Three scoped variables are
available only inside state_updates:
| Variable | Available in | Resolves to |
|---|---|---|
{{output}} |
agent, llm |
The node's primary text output (or parsed JSON value if output_schema is set) |
{{choice}} |
approval |
The option the user picked, or their free-form text |
{{input}} |
input |
The user's text (or interpolated default if they submitted empty) |
These variables are cleared after state_updates runs, so they don't leak
into the next node's templates.
End nodes are different. An
endnode'sstate_updatesruns with plain lenient interpolation. There is no scoped{{output}}because there is no node-body output to scope. Afterstate_updatesapply, theendnode's ownoutputtemplate is interpolated against the resulting state and returned as the graph's final result.
Routing & Tolerant-Fail
Nodes route via three mechanisms in priority order:
- Script
_nextoverride:scriptnodes can set"_next": "node_id"in their stdout JSON to dynamically choose the next node. - Internal routing:
approvalroutes via itsroutesmap (oron_otherwhen the answer matches no listed option). - Default
nextedge: thenextfield on the node.
Routing requirements per node type
| Node type | Needs next? |
Supports fan-out (next: [...])? |
|---|---|---|
agent |
Yes - next is required (unless the agent node is unreachable). Error at runtime if missing. |
Yes |
script |
Either _next from script output OR static next (or fallback on failure). Error if neither. |
Yes (when _next is not emitted) |
approval |
No - routing is via routes and on_other. next is ignored. |
No - forbidden by validator |
input |
Yes - next is the success route. |
No - forbidden by validator |
llm |
Yes - next is the success route. Failures without fallback halt the graph. |
Yes (success path; failure with fallback routes to single target) |
rag |
Yes - next is required. Error at runtime if missing. |
Yes |
map |
Yes - next is where the parent super-step continues after the map collects. |
Yes |
end |
No - terminal. | n/a |
Failure-handling contract
| Node type | Success | Failure with fallback |
Failure without fallback |
|---|---|---|---|
llm |
Routes via next |
Routes via fallback |
Graph fails at this node (use fallback: to recover) |
script |
Routes via next |
Routes via fallback |
Routes via next; {{output}} holds the error |
agent / input |
Routes via next |
n/a (no fallback) |
Graph fails at this node |
rag / map / approval |
Routes via configured edges | n/a | Graph fails at this node |
Note: LLM node failures halt the graph with a clear error message. This prevents downstream nodes from running against garbage state when an upstream LLM call fails (HTTP 4xx/5xx, timeout, structured-extraction error, etc.).
Parallel Execution
Graph agents support two flavors of parallel execution:
- Static fan-out: A node declares
next: [a, b, c]to dispatch to multiple branches at once. - Dynamic fan-out: A
mapnode spawns one parallel sub-branch per item in a runtime-determined list.
Both share the same underlying execution model (a super-step scheduler) and the same merge primitive (declared reducers).
The super-step model
The executor advances the graph in discrete super-steps. Each
super-step starts with a frontier (a set of nodes to run), spawns all
of them concurrently via tokio::spawn, waits for every branch to
finish, merges their writes, and computes the next frontier from each
branch's routing decision.
super-step 1: frontier = { triage } -> triage runs alone
super-step 2: frontier = { fetch_web, fetch_db } -> both run in parallel
super-step 3: frontier = { synthesize } -> single node, sequential
super-step 4: frontier = { done } -> terminal
This is the same Bulk Synchronous Parallel model that LangGraph uses for the same problem. Each super-step is transactional: if any branch in a super-step errors, the entire super-step is rolled back. That means no partial writes leak into live state.
Static fan-out
The simplest parallel pattern: write a list in next:.
triage:
type: llm
prompt: "Decide which sources to consult."
next: [fetch_web, fetch_local, fetch_docs] # three branches run in parallel
After triage completes, the next super-step runs fetch_web,
fetch_local, and fetch_docs concurrently. Each is a normal node that
does its work, writes to state, and routes to its own next:. When all
three branches converge on the same target (e.g. next: synthesize), the
join happens automatically: the executor dedups the next frontier, so
synthesize runs once after all three predecessors finish.
fetch_web: { type: llm, prompt: "Search web for {{topic}}.", next: synthesize }
fetch_local: { type: rag, documents: ["./knowledge/"], next: synthesize }
fetch_docs: { type: llm, prompt: "Cite docs for {{topic}}.", next: synthesize }
synthesize: # runs once, after all three finish
type: llm
prompt: "Combine: {{web}}, {{local}}, {{docs}}"
Which node types support static fan-out? agent, script, llm,
rag, and map. Approval and input nodes cannot fan out (the validator
rejects them as immediate fan-out targets, meaning N concurrent user prompts
would be unusable).
Dynamic fan-out via map
When the number of branches depends on runtime data (the count of search
results, the items returned by a script, etc.), use the map node type.
See the map node section above for a full reference. Briefly:
fan_out_subjects:
type: map
over: "{{subjects}}" # list determined at run-time
as: subject
branch: research_subject
collect_into: research_results # array, in input order
next: rank
The map executor spawns one sub-branch per subjects item, each
invoking research_subject with {{subject}} bound to the item.
Outputs are collected into research_results in input order
regardless of which sub-branch finished first.
Reducers
When two or more parallel branches write to the same state key in the same super-step, the executor merges their writes through a declared reducer. Without a reducer for a contended key, the load-time validator errors with a clear message naming the conflicting writers.
Declare reducers at the graph root:
reducers:
sources: append # see table below
cost_usd: sum
context: concat
config: merge
Built-in reducers
| Reducer | Semantics | Required types |
|---|---|---|
append |
Push each branch's value onto an array (creates [v1] from nothing, then appends v2, v3, …) |
Any value type |
extend |
Concatenate arrays ([a, b] + [c, d] = [a, b, c, d]) |
Both sides must be Array |
concat |
Join strings with \n separator |
Both sides must be String |
sum |
Numeric addition | Both sides must be Number |
max |
Numeric max | Both sides must be Number |
min |
Numeric min | Both sides must be Number |
merge |
Object union (incoming wins on key collision) | Both sides must be Object |
overwrite |
Last write wins (explicit opt-in to non-deterministic behavior) | Any value type |
Integer typing is preserved through numeric reducers when possible;
e.g. sum of 5 and 7 stays Number::i64(12), not Number::f64(12.0).
Determinism
The merge order is deterministic across runs: branches are sorted by
(node_id, invocation_index) before reducers are applied. For static
fan-out the node ids are distinct so the ordering is clear. For map
sub-branches all sharing the same branch: node id, the
invocation_index is the input-list position, so order matches the
over: list.
This means non-commutative reducers (concat, merge) produce
reproducible output across runs.
Single-writer keys don't need reducers
A state key written by only one branch in a super-step doesn't require a declaration. Only keys with 2+ writers in the same super-step trigger the "missing reducer" load-time error.
Validator-enforced
When the graph is loaded, the validator computes each node's write set
(state_updates keys unioned with the output_schema top-level properties for LLM /
agent nodes) and intersects them across parallel groups. Any key with
multiple writers and no reducer in the reducers: block is rejected at
load time with a message naming all the writers:
nodes [retrieve_web, retrieve_docs] all write key 'summary' in the same
parallel super-step but no reducer is declared for 'summary'. Add
`reducers: { summary: <reducer> }` at the graph root, or rename one
node's output.
Branch isolation
Each parallel branch runs against an independent state fork. Writes to one branch's state don't affect siblings. At the super-step boundary, the executor extracts each branch's writes (the diff between the branch's final state and the snapshot at super-step start) and merges them via the reducer pipeline.
This means:
- Branches can freely mutate
output, internal counters, etc. without worrying about siblings - Race conditions are impossible since there is no shared mutable state during the parallel phase
- Branches cannot communicate with each other mid-super-step. If you need that pattern, sequentialize the work or write it as a single multi-step subgraph
Cross-branch reads are not visible (gotcha)
A consequence of independent state forks: a branch cannot read a key that a sibling branch writes in the same super-step. Each branch sees the state snapshot taken BEFORE the super-step started; sibling writes only become visible at the next super-step (after the join).
Validator catches this at load time. If any branch's templated field references a state key that another sibling branch writes in the same super-step, you get a load-time error like:
node 'rag_lookup' reads state key(s) `db_result` which sibling parallel
branch 'query_local_db' writes in the same super-step; parallel
branches see a state snapshot taken BEFORE the super-step and cannot
observe each other's writes. Move the dependent read to a later
super-step (or remove the cross-branch reference).
Fix options:
- Remove the dependency. Drop the cross-branch reference from the prompt/query/etc. so the branch operates on what was available before the super-step.
- Sequentialize. Put the dependent node in a later super-step:
producer → reader_that_needed_producer_outputinstead of[producer, reader_that_needed_producer_output]. - Use the join. Restructure so any node that depends on a sibling's writes runs after the join. The join sees the merged state.
Caveat (scripts). The validator scans templated fields of typed
nodes (prompt, query, instructions, question, default,
output, over, and state_updates values). It does not scan
script bodies, since scripts read state opaquely via GRAPH_STATE /
GRAPH_STATE_FILE env vars. A Python script in a parallel branch
that does state.get("sibling_written_key") will silently see the
pre-super-step snapshot. The script's required state_updates
declaration (the validator's separate "parallel scripts must declare
writes" check) covers writes only, not reads. Be aware that script
branches need to be designed so they don't depend on sibling writes.
Multi-branch UX
In a TTY:
- Each parallel branch gets its own labeled spinner (e.g.
[fetch_web] running… 2.3s), drawn side-by-side via indicatif. - When a branch completes successfully, its spinner finalizes to
✓ done (2.3s). On failure:✗ failed (2.3s) — <error excerpt>. - Map sub-branches are labeled
[<branch_id>[<idx>]]so multiple invocations of the same branch node stay distinguishable.
In non-TTY mode (CI, piped output), spinners are suppressed entirely so no spinner garbage in captured logs.
Streaming behavior
When a single node runs (sequential super-step, frontier.len() == 1),
LLM tokens stream to stdout normally and thus the user sees them as they
arrive.
When multiple branches run concurrently (multi-node super-step or any
map fan-out), token streaming is suppressed across all parallel
branches. Tokens are still buffered internally so the full response
reaches state via the normal output_schema / state_updates pathway;
they just don't print mid-flight. This avoids the interleaved-tokens
mess that would otherwise happen with N concurrent LLM streams writing
to one stdout.
After the super-step joins, downstream nodes resume normal streaming.
Error propagation
Errors in a parallel super-step are transactional:
- If any branch returns an error and isn't recovered by its node's
fallback:, the entire super-step is aborted. This means no partial writes applied, and the error propagates up. - The error message includes the failing branch's node id, e.g.
at node 'worker_b': simulated failure. - Sibling branches that already completed are dropped (their writes are discarded along with the failing branch's).
This matches LangGraph's transactional super-step semantics.
Multi-End rejection
If two or more parallel branches in the same super-step both route to End nodes, the executor errors:
super-step ended with multiple End targets (end_a, end_b). Fan-out
branches must converge at a join node before terminating. To fix: route
all parallel branches to a single shared next-node, then terminate from
there.
This is intentional. Terminating from two places in one super-step almost always indicates a graph-design mistake (the user probably meant for both branches to feed into a shared end node).
Approval and input nodes are forbidden in fan-out
The validator rejects any approval or input node that's an immediate
fan-out target (next: [approve, ...] or as a map's branch:). The
reason is UX: ten concurrent "type your answer" prompts would
fundamentally not work in a CLI. Put approval / input nodes after
the join, downstream of the merge.
What sequential graphs see
If your graph has no fan-out and no map node, nothing changes. The
super-step scheduler runs sequentially (one node per super-step).
The parallel infrastructure is opt-in via next: [...] or a map node.
Structured Output (output_schema)
Both llm and agent nodes can specify an output_schema field: a JSON
Schema (written inline in YAML) describing the expected shape of the node's
output:
extract_task:
type: llm
prompt: 'Parse: "{{raw_task}}"'
output_schema:
type: object
properties:
action: { type: string }
items:
type: array
items: { type: string }
time_minutes: { type: ["integer", "null"] }
priority:
type: string
enum: [low, medium, high]
required: [action, items, priority]
When output_schema is set:
- The node body runs normally.
- The raw text output is tried as JSON first (with light cleanup of markdown code fences); the fast path. If parsing succeeds, that's the structured output.
- Otherwise Coyote invokes a built-in
__structured_output__role (constructed inline; not visible in the user's role list) to extract a JSON object matching the schema. One repair retry on extractor failure. - When the parsed value is a JSON object, its top-level keys
auto-merge into state permanently (a non-object result is still
reachable via
{{output}}but has no top-level keys to merge). {{output}}(insidestate_updates) resolves to the full parsed value.- Explicit
state_updateswin over auto-merge if the same key is set in both.
After the example above, downstream nodes can use {{action}}, {{items}},
{{items[0]}}, {{priority}}, etc. directly.
LLM nodes vs Agent nodes: schema-hint injection
This is the most important behavioral difference between the two node
types when output_schema is set:
- LLM nodes: Coyote automatically appends a schema hint to the prompt
(to the system prompt if
instructionsis set, otherwise to the user prompt). The hint tells the model to respond with JSON matching the schema. This means the main LLM call usually emits valid JSON directly -> the fast path succeeds -> the extractor LLM call is skipped entirely (cheaper, faster, more reliable). - Agent nodes: Coyote does NOT inject any schema hint. Agents are multi-turn with their own tool-use loop; stuffing a schema into the initial prompt risks the agent fixating on JSON output instead of doing its actual work. The agent runs to completion freely, and the extractor converts its final text to JSON afterward.
If you need an agent to emit JSON-shaped output, include schema language in its prompt yourself. The auto-injected hint for LLM nodes uses this form:
Respond with a JSON object that matches this schema. Output ONLY the JSON
object with no surrounding prose or markdown fences.
Schema:
{...}
Tolerant-fail for extraction
- LLM node: extraction failure = node failure. Routes via
fallbackif declared; otherwise the graph fails at this node with the extractor error message. - Agent node: extraction failure propagates as a graph error (agent
nodes have no
fallback).
Worked Example
A compact illustrative graph -input -> llm (with output_schema) ->
end - exercising structured output and all template-path forms. For a
full-featured reference covering every node type and field, see the
heavily-commented graph.example.yaml at the root of the Coyote repository.
Illustrative graph.yaml:
name: structured-test
version: "1.0"
start: ask_task
nodes:
ask_task:
id: ask_task
type: input
question: "Describe a task in free-form text."
validation: "len(input) > 0"
state_updates:
raw_task: "{{input}}"
next: extract_task
extract_task:
id: extract_task
type: llm
instructions: |
You are a task parser. If a field cannot be determined, use a sensible
default (empty array, null, or "medium" for priority).
prompt: 'Parse this task description: "{{raw_task}}"'
tools: []
output_schema:
type: object
properties:
action: { type: string }
items:
type: array
items: { type: string }
time_minutes: { type: ["integer", "null"] }
priority:
type: string
enum: [low, medium, high]
details:
type: object
properties:
urgent: { type: boolean }
deadline: { type: ["string", "null"] }
required: [urgent]
required: [action, items, priority, details]
next: done
done:
id: done
type: end
output: |
Action: {{action}}
Priority: {{priority}}
Time: {{time_minutes}} min
Urgent? {{details.urgent}}
First item: {{items[0]}}
All items: {{items}}
With the sample input Buy groceries: milk, eggs, bread. About 15 minutes. Urgent.
Sample state after extract_task:
{
"raw_task": "Buy groceries: milk, eggs, bread. About 15 minutes. Urgent.",
"action": "buy",
"items": ["milk", "eggs", "bread"],
"time_minutes": 15,
"priority": "high",
"details": { "urgent": true, "deadline": null }
}
Validation
When validate_before_run: true (the default), Coyote validates the graph at
startup.
Errors (abort startup):
- Start node missing or pointing to a non-existent node
- Any
next/routes/fallback/on_othertarget pointing to a non-existent node - Any cycle in declared static edges (cycles are always errors. The
per-node
max_loop_iterationsis a runtime safety net for dynamically- routed loops, not a license for static cycles) - Graph has zero
endnodes. Execution would never terminate approvaloption without a matchingroutesentryscriptfile path does not exist relative to the agent's directoryagentnode references an agent name that doesn't exist in the coyote agents directory, or that exists but has neither aconfig.yamlnor agraph.yamlragnode with nodocuments(at least one knowledge source is required)llmnode referencing an unknown tool ormcp:<server>in itstoolswhitelist, or an unknownmodel. Validated against the agent's tool, MCP-server, and model sets- Parallel write collision without a reducer: two or more nodes in the
same parallel super-step (the immediate
next: [...]targets of one source node) write to the same state key, and thereducers:block has no entry for that key. Error names all the colliding writers - Approval or input as a fan-out target: the immediate target of a
next: [...]is anapprovalorinputnode. These would queue N concurrent user prompts, which is a UX disaster. So they're rejected at load time - Map branch violates strict mode: the validator enforces that a
mapnode'sbranch:target (1) isllm,agent,rag, orscript(not approval/input/end/another map); (2) has nonext:declared; (3) writes only to itsoutput_keyviastate_updates(if any); (4) has nooutput_schemadeclared - Script in a parallel branch with no
state_updates: a script node whose immediate predecessor is a fan-out has unknown writes from the validator's perspective (scripts can emit arbitrary JSON to state). Declare an emptystate_updates: {}to acknowledge known writes, or add explicit declarations max_concurrencyset to 0: eithersettings.max_concurrency: 0orMapNode.max_concurrency: 0is rejected (would deadlock the semaphore)- Cross-branch read in a parallel super-step: one branch's templated
field (
prompt,query,instructions,question,default,output,over, orstate_updatesvalue) reads a state key that another sibling branch writes in the same super-step. Branches see a snapshot taken before the super-step started, so a sibling's writes are not yet visible. The error names the reader, the reads-key, and the sibling writer. Scripts are exempt because their reads are opaque to static analysis. See Cross-branch reads are not visible for the script caveat
Warnings (printed, execution continues):
- Any node unreachable from the start via declared static edges
- No
endnode reachable from the start via declared static edges approvalroutesentry without a matching optionragnode with nostate_updates(its retrieval result goes nowhere)
Why some of these are warnings and not errors: the validator only follows declared static edges (
next,routes,fallback,on_other). Script nodes can also route dynamically at runtime via_nextin their JSON output, and those edges are invisible to static analysis. To avoid false positives against dynamically-routed graphs, "unreachable" and "no reachable end" are reported as warnings, not errors.
Invocation Entry Points
A graph agent can be entered from three places, all of which seed the
caller's prompt into state as {{initial_prompt}}:
- Top-level CLI:
coyote -a my-graph-agent "user prompt here" - REPL: When the active agent has a
graph.yaml, every user message in the REPL runs the graph fresh; the message becomes{{initial_prompt}} - Child-agent spawn: When another (graph or normal) agent invokes
this one via Coyote's sub-agent mechanism, the parent's request becomes
{{initial_prompt}}for the child graph
After the graph finishes, any sub-agents this graph spawned via
agent-type nodes are cancelled, so a graph cannot leak background tool
loops. The graph's final end node output is what's returned to the
caller.
Streaming and Observability
Graph execution has two observability channels:
1. stderr narration: Dimmed ▸ lines you follow along with in real
time, regardless of log level:
▸ graph: my-agent (start: extract_task)
▸ extract_task (llm)
▸ llm call: model=<active> tools=<none>
▸ extract_task -> done
▸ done (end)
▸ graph done in 2.41s
2. tracing logs: Structured info!/debug!/warn!/error!
records gated by RUST_LOG (see Configuration below).
This is the developer-facing channel and includes:
- Graph start / completion / failure
- Per-node entry and routing decisions (
debug) - A performance summary at completion — every node's visit count,
total/avg/max wall-clock time, slowest first:
[graph:my-agent] performance summary (slowest first): [graph:my-agent] deep_research: 1 visit(s), total 8200ms, avg 8200ms, max 8200ms [graph:my-agent] extract_task: 1 visit(s), total 1400ms, avg 1400ms, max 1400ms
State snapshots: when log_state_snapshots: true (the default), before
each node runs Coyote logs the state's byte size and key list at debug
level, and the full state at trace level. The full state is
deliberately kept at trace because graph state can contain secrets so
be careful sharing trace-level logs.
Configuration
Control the tracing channel with RUST_LOG:
RUST_LOG=coyote::graph=debug coyote -a my-agent "..." # graph debug logs
RUST_LOG=coyote::graph=trace coyote -a my-agent "..." # + full state snapshots
RUST_LOG=coyote::graph=info coyote -a my-agent "..." # start/end/perf summary
The stderr ▸ narration is always shown and is not affected by RUST_LOG.
Limitations / Gotchas
A short, honest list of things that bite people:
- A graph agent is
graph.yaml-only. It must not also have aconfig.yaml. Both files present is a hard load error. - Graph agents do not support sessions. A graph manages its own state
(
GraphState), so there is no conversational history to persist. Explicitly requesting a session is a hard error.--sessionon the CLI, a session name passed to.agentin the REPL, or running.sessionwhile inside a graph agent. Any app-levelagent_sessiondefault is silently skipped for graph agents rather than applied. - RAG is per-node, not agent-wide. Graph agents do RAG via
ragnodes (each with its own knowledge base); there is no agent-widedocumentsfield at thegraph.yamltop level. - A
ragnode's knowledge base is built once, at load time. Changing aragnode'sdocumentsdoes not rebuild it. Delete<agent-dir>/<node-id>.yamlto force a fresh build on next run. on_otheris required on everyapprovalnode becauseuser__askalways permits free-form responses (see the approval section).validationoninputnodes is length-only. The grammar islen(input) <op> <integer>with<op>in> >= < <= ==. No regex, no type coercion, no range checks. Use a follow-upscriptnode for richer validation.- An
inputnode'sdefaultis not re-validated. When the user submits an empty response and thedefaultis substituted in, that substituted value is not checked againstvalidation. Make sure anydefaultyou set would itself satisfy thevalidationpredicate. - Tool whitelist is
llm-only.agentnodes always use the child agent's full tool universe. They ignore anytools:field. This is by design: child agents own their tool surface. {{output}},{{choice}},{{input}}are scoped tostate_updates. Outsidestate_updates(e.g. in another node'sprompt), these scoped variables are not available unless the previous node explicitly stored them viastate_updates.endnodes do NOT get a scoped{{output}}. They have no node body output to scope.- Schema-hint auto-injection happens for
llmnodes only, notagentnodes (see Structured Output). - Script-output JSON must be an object, not an array or primitive,
even if you only want to set
_next. - Cycles in declared static edges are always errors. The per-node
max_loop_iterationsis a runtime safety net for cycles built via dynamicscript._nextrouting, not permission to write static cycles. - Schema version is fixed at
"1.0"today. Any other value is a startup error. - Script extensions are exactly
.sh,.py,.ts. No JavaScript, no Ruby, no Lua. Python must be available aspython3and TypeScript requiresnpx tsxon PATH. - Token streaming is suppressed during parallel super-steps.
Sequential graphs stream tokens to stdout normally; multi-branch
super-steps (
next: [...]fan-out or any map fan-out) buffer tokens silently and emit only the final outputs after the join. Avoids the interleaved-tokens mess that would otherwise mix N LLM streams into one stdout. Log lines (node entry / routing decisions) still appear, just without the per-token output. - Map sub-branches always run in Silent mode even when there's only
one item in
over:. If you want streaming visibility for a 1-element map, restructure the graph to avoid the map entirely. max_loop_iterationsdoesn't count map sub-branches. The per-node visit cap fires for cycle / runaway-loop detection on graph edges. Amapover a 1,000-elementover:runs the branch 1,000 times but increments the branch's loop count by zero. Theover:array bounds the iteration count, not the cycle detector.- Map sub-branches' tool-call history doesn't accumulate across
invocations. Each sub-branch gets a fresh
ToolCallTracker. The parent's tool-call history is unchanged after the map completes (the branch RequestContext clones are discarded at super-step boundary). - A reducer's type-mismatch error fires at runtime, not load time.
(e.g.
sumdeclared on a key but a branch writes a string). The validator can't tell statically what type a node will write, so the reducer apply phase errors loudly with a message likereducer 'sum' on key 'cost' requires numeric values, got String("forty two"). fallback:distinguishes failure from success cleanly now. LLM nodes (and RAG via no-fallback default) return a typed outcome (Continuefor success,FellBack(target)for failure-with-fallback), so a fan-out LLM withfallback:matching one of itsnext:targets works correctly. The fallback path routes only to the fallback, not all Many targets.- Script nodes and custom agent tools cannot pause for user input.
They run as subprocesses with no TTY and no I/O channel back to the
REPL; any interactive prompt (
read,input(), password prompts, etc.) will hang forever and permanently stall execution. Useinputandapprovalnodes; they are the only supported pause-for-user points in a graph. The same restriction applies to custom tools invoked fromllmnodes.
See Also
graph.example.yaml- A fully-commented, full-featured reference graph agent at the root of the Coyote repository (every top-level field, every node type).- Built-in graph agents shipped with Coyote:
coder(implement -> verify_build -> verify_tests -> self_review -> fix-loop),deep-research(the canonical reference that exercises every node type),librarian(triage -> parallel doc + OSS search -> synthesize -> trim; a compact illustration of static fan-out with reducers). See Agents > Built-In Agents for descriptions. - Agents - non-graph agent system (config.yaml + LLM loop)
- Custom Tools - building
tools.sh/tools.py/tools.tsfiles for use in graph nodes - Roles - note that the built-in
__structured_output__role used byoutput_schemais intentionally internal and is not user-visible - MCP Servers -
mcp:<server>shorthand inside anllmnode'stools:whitelist