Graph Agents - Dark-Alex-17/coyote GitHub Wiki

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
reasoning_effort: null               # default reasoning effort for llm nodes (model-dependent; e.g. low, medium, high)
max_concurrent_jobs: null            # optional; graph-wide [background job](Background-Jobs) budget shared by all llm nodes
                                     # (jobs themselves are node-local); overrides the global setting, 0 disables
can_spawn_agents: null               # optional; whether llm nodes may spawn dynamic sub-agents via the agent__* tools;
                                     # defaults to true iff the graph contains at least one agent node
max_concurrent_agents: null          # optional; cap on dynamically spawned sub-agents running at once (default 4)
max_agent_depth: null                # optional; max agent nesting depth, i.e. agents spawning agents (default 3)
global_tools:                        # global tools available to nodes
  - web_search_coyote.sh
mcp_servers:                         # MCP servers available to nodes
  - pubmed-search
mcp_tools:                           # optional; per-server tool whitelists for those servers
  pubmed-search: [search_*, get_*]   # (YAML list values; see MCP-Servers "Restricting MCP Tools")
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, 0 = no cap (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; 0 = no bound
  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's config.yaml carries; in a graph agent they live at the top of graph.yaml instead. model / temperature / top_p act as the defaults for llm nodes that don't set their own. global_tools and mcp_servers define the tool universe that an llm node's tools: whitelist selects from (a node with no tools: field gets none of them).
  • mcp_tools: Optional per-server tool whitelists over the graph's mcp_servers (a map of server name -> tool-name patterns). Applies to every node; llm nodes can narrow it further with a node-level mcp_tools: field (see The mcp_tools field).
  • variables: Same shape as a normal agent's variables: block. Each declared variable becomes available to script nodes as the env var LLM_AGENT_VAR_<UPPER_NAME>, exactly like bash tools called by normal agents. Values may be overridden at runtime via coyote -a <agent> --agent-variable <name> <value> "..." (or when the graph agent is spawned as a subagent), via agent__spawn's variables parameter (see Passing Agent Variables at Spawn). Declared variables are seeded into the initial graph state automatically (resolved value first, then declared default), so {{name}} resolves inside llm node prompts with no setup script required. Seeding never overwrites an explicit initial_state: key. A declared variable sharing a name with one is shadowed (the initial_state value wins), and the graph validator emits a warning naming each shadowed variable.
  • can_spawn_agents is declared, or derived when omitted. When the key is absent, a graph agent can spawn dynamic child agents iff its graph contains at least one agent node; an explicit value overrides that derivation. Set can_spawn_agents: true on a graph with only llm nodes to build an orchestrator graph: its llm nodes get the full agent__* toolset (spawn/collect/messaging/task queue) and can drive dynamic sub-agent teams. Setting it false on a graph that does have agent nodes still runs those nodes; only dynamic spawning from llm nodes is disabled (the agent-recursion depth guard stays active either way). max_concurrent_agents (default 4) and max_agent_depth (default 3) cap dynamic spawning; the validator warns on zero values.
  • 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 with Node 'X' visited N times (max_loop_iterations=...). Default: 100. 0 disables the cap. Use it when a node is legitimately re-entered a data-dependent number of times; e.g. a reflexion loop that pulls jobs from a list re-enters review once per job × per round, so no fixed cap is right. If the job list is known up front, prefer a map over it. Each item forks fresh visit counts, so the cap then measures per-job non-convergence (use max_concurrency: 1 if jobs must be sequential); use 0 when the list is discovered mid-run or the loop needs approval/input gates (disallowed inside branches). With the cap off, a non-converging loop is stopped only by settings.timeout, an enclosing agent node's timeout, or Ctrl-C (which aborts the run from anywhere, see Abort and cancellation); if you also set timeout: 0, nothing but Ctrl-C bounds the run before completion. The validator warns on the explicit 0. The 0 = no bound rule applies to timeout, max_loop_iterations, and an llm node's max_iterations; max_concurrency: 0 is a validation error.
  • timeout: Wall-clock cap on the entire graph run. Checked before each super-step and enforced during super-steps: if the budget elapses while nodes are still running, the in-flight branches are aborted and the run fails with a timed out after Ns during super-step error. Note that a graph-level timeout is a hard bail. It does not route through node fallbacks, so any cleanup/reporting nodes are skipped. Prefer per-node timeouts on llm nodes (which do route via fallback) as the operative bound, and treat the graph-level timeout as a generous backstop. 0 disables the bound entirely (equivalent to unset; the validator warns on the explicit 0 at start-up so the choice is visible). For graphs that must run until they complete, e.g. an end-to-end implementation run; pair it with a sane max_loop_iterations. Bounds compose outward-in: if this graph is run by another graph's agent node, that node's timeout (default 300) still applies and will drop this graph regardless of its own settings; set timeout: 0 there too. Conversely, a parent's timeout: 0 does not lift a child's non-zero settings.timeout; the child's own executor enforces it. A root graph run directly is bounded only by its own settings.timeout. An unbounded run ends only on completion, error, an enclosing bound, or Ctrl-C. Ctrl-C aborts the run: the graph flags abort, an in-flight model call is interrupted, a running tool is killed within ~100 ms, in-flight branches and map items are cancelled, and the run fails with Graph ... aborted before super-step or Graph ... aborted during super-step (either means "aborted"; see Abort and cancellation). The 300s agent default was a de-facto safety net for a wedged child; timeout: 0 is the explicit opt-out of that net.
  • 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-map nodes can override this with their own max_concurrency field. The graph-wide cap is a safety net against LLM rate-limit blowups when a next: [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: false turns skills off for every llm node in the graph regardless of node-level settings. enabled_skills is the universe: the set of skill names any llm node may reference in its own enabled_skills. Subset-violations are caught by the validator at load time. See Skills in Graph Agents for the full per-node story.

Abort and cancellation

A graph run observes a user interrupt from anywhere, not just between super-steps. Ctrl-C (or the enclosing turn being aborted) sets the run's abort flag; the frontier join reacts to it exactly like a graph timeout: every in-flight branch task is cancelled, and the run fails with Graph '<name>' aborted during super-step with frontier [...] (or, if the abort and the join land in the same tick, ... aborted before super-step ... one iteration later; either means "aborted"). What "cancelled" means per node type:

  • llm: an in-flight model call is interrupted; every turn also checks the flag before it creates a client, so a tool result returned after the abort never reaches the model. The node fails with llm node aborted and the run ends before any fallback: route gets to run.
  • Tools inside llm nodes: a running tool process is killed within ~100 ms and reports Tool call '<name>' aborted.
  • script: the shell is killed with the branch task.
  • map: cancelling the map cancels every in-flight item chain with it.
  • agent: the child graph inherits the session's interrupt, so Ctrl-C reaches every nested graph agent directly. A child graph is also cancelled when its parent aborts or times out; its in-flight synchronous tool then runs to tool_timeout before the child unwinds. A child agent's own agent__spawned subagents are cancelled with it, but its job__started jobs stay in job__list until the next prompt's Ctrl-C.
  • Teammates: a cancelled branch or item still retires its identity, so peers see it as finished.

Headless runs (coyote --agent ... "prompt") end with the Graph ... aborted error (exit status 1) instead of dying on the signal (130). approval and input prompts run in raw mode: a Ctrl-C there is a key event that fails the node, not a SIGINT.

{{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, and inputs.initial_prompt on a parent's agent node is rejected (see Passing state into a child graph agent).


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, seconds; default 300, 0 = no bound
  max_attempts: 2                 # optional; retry transient child failures (default 1)
  fallback: report_unavailable    # optional; route here when the child ultimately fails
  inputs:                         # optional; only when the child is a GRAPH agent
    max_parallel: "{{inner_width}}"  # seeds the child's initial_state (see below)
  teammates: false                # optional; peer messaging with concurrent siblings (see below)
  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 per attempt. A timeout counts as a transient failure: remaining max_attempts retries run, each with a fresh budget; the final failure routes via fallback when declared, otherwise the whole graph fails at this node. 0 disables the bound; the child runs until it returns. Required on the calling node to let a child graph's own timeout: 0 take effect; the child's non-zero settings.timeout still applies.
  • max_attempts: Transient-error retries with the same matcher and semantics as llm nodes (see Retries). Each retry re-spawns the child agent from scratch with the same prompt and a fresh timeout budget; a concurrent-teammate identity (inbox + roster) is preserved across attempts. Default 1 (no retries). Only set this on children that are safe to re-run from scratch; e.g. read-only reviewers, researchers, report writers. Leave it at 1 for side-effectful children (agents that write code or deploy things); a child that authors files (e.g. a probe lane writing test artifacts) is mildly side-effectful, meaning a failed first attempt may leave partial artifacts behind. Two contracts to keep in mind: worst-case node wall time is max_attempts × timeout (every attempt gets its own fresh budget), and teammate message delivery is at-most-once across the retry boundary. Messages a failed attempt already drained are not re-delivered to the retry (same inbox Arc, no replay), and messages the failed attempt SENT remain with its peers.
  • fallback: Failure route with the same contract as llm nodes: on the final failure (retries exhausted, hard error, or structured-output extraction failure) the node writes Agent node failed: <error> as {{output}}, applies state_updates, and routes to fallback instead of failing the graph. Without fallback the error propagates and the graph fails at this node. Inside a map branch the fallback target must be branch-local.
  • state_updates: Map of state_key: "{{template}}". The child agent's final text is available inside this map as {{output}}.
  • inputs: Map of child_state_key: "{{template}}". Each value is interpolated against the parent's state and the resolved map is overlaid on the child graph agent's initial_state before its first node runs. Only valid when agent names a graph agent (one with a graph.yaml). See Passing state into a child graph agent.
  • teammates: Opt-in peer messaging. When true and this node runs concurrently with other flagged agent nodes (as branches of one map fan-out, or alongside other teammates: true agent nodes in the same super-step), the sibling sub-agents can message each other during the run. See Teammate messaging in fan-outs.

Passing state into a child graph agent (inputs)

prompt: is the only thing an agent node hands to a config-only child. When the child is itself a graph agent, inputs: lets the parent seed any key of the child's initial_state as well:

research:
  type: agent
  agent: deep-research
  prompt: "{{finding}}"
  inputs:
    max_parallel: "{{inner_width}}"     # lone {{key}}: the child gets the value as-is
    local_context: "Repo: {{repo}}"     # mixed text: the child gets a string
  state_updates:
    verdict: "{{output}}"

Overlay order. The child's state is seeded in three layers, later layers winning:

  1. the child's own initial_state from its graph.yaml;
  2. the resolved inputs map (keys the child never declared are simply added);
  3. initial_prompt, which the dispatcher always sets from prompt:.

Nothing is validated against the child's YAML, and nothing about inputs persists past the child's executor start: the child sees an ordinary initial_state and runs exactly as if those values had been written in its own file.

Typing rule. Every value is a template against the parent's state, resolved strictly (a missing key fails the node before the child starts, with Failed to interpolate inputs.<key> for agent '<agent>'):

  • A value that is exactly one reference ("{{inner_width}}") hands the parent's JSON value through unchanged: numbers stay numbers, lists stay lists, objects stay objects. Scripts in the child see the real type; the child's own templates stringify it the usual way. Nested paths work the same way ("{{limits.width}}" hands through limits.width with its type intact).
  • Anything else ("Repo: {{repo}}", "{{a}}-{{b}}") renders to a string.
  • null is a value, not an absence. A lone "{{k}}" whose parent value is null overlays the child's default with null. A missing parent key, by contrast, fails the interpolation strictly: inputs: {x: "{{output}}"} after a node whose state_updates did not name output errors (the transient completion binding is removed again once its templates render) instead of handing the child x: null.

Two things are rejected, both at load time by the validator and again at runtime for graphs running with validate_before_run: false. The validator messages below name the node; the runtime guards name the agent instead (agent '<name>' has no graph.yaml; ... / agent '<name>': inputs.initial_prompt is reserved ...) and arrive wrapped in the usual at node '<id>': Agent '<name>' failed: context, so the node is still recoverable from the error chain.

  • inputs: on a node whose agent has a config.yaml but no graph.yaml (agent node '<id>': inputs: requires agent '<name>' to be a graph agent (no graph.yaml found)). Presence is what matters: inputs: {} on a config-only agent is still an error, while on a graph agent it is a no-op.
  • An input named initial_prompt (agent node '<id>': inputs.initial_promptis reserved (the dispatcher seeds it fromprompt:)). The dispatcher owns that key; put the text in prompt:.

inputs values are templated reads like any other, so the cross-branch read check covers them: an inputs value that references a key a sibling branch writes in the same super-step is a load-time error.

Example: narrowing a child's fan-out. deep-research fans out 4-wide inside each of its runs. An orchestrator that maps over 4 findings and delegates each to deep-research therefore runs 16 concurrent LLM calls, and without inputs nothing on the parent side can change that. With a templated cap in the child:

# child: deep-research/graph.yaml
initial_state:
  max_parallel: 4                    # the default when run standalone
nodes:
  fan_out:
    type: map
    over: "{{questions}}"
    max_concurrency: "{{max_parallel}}"
    ...

the parent can size it per run:

# parent
initial_state:
  inner_width: 2
nodes:
  research:
    type: agent
    agent: deep-research
    prompt: "{{finding}}"
    inputs:
      max_parallel: "{{inner_width}}"  # 4 items × 2-wide = 8 concurrent, not 16

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, 0 = no bound
  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
COYOTE_CURRENT_MODEL The session's active model in client:name form

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}))

Failure: if the script exits non-zero, times out, prints nothing, or prints invalid JSON, the node routes to fallback when one is set. Without a fallback the graph fails at this node (next is not consulted on failure).

A failed script merges nothing into state (it never got to print its JSON object), so a fallback target would otherwise see the state exactly as it was before the node ran and could not tell a crash from a clean pass. The engine therefore writes one key before routing to fallback:

State key Contents
last_script_error script '<node id>' failed: <error>, where <error> names the exit code and stderr (or the timeout / invalid-JSON cause). Capped at 8 KiB: the first quarter and last three quarters are kept around a ... [N bytes truncated] ... marker.

Read it in the fallback target to branch on why you were reached, and reset it ("last_script_error": "") once handled so a later fallback is not mistaken for the same crash. When two parallel branches both write it in one super-step the values are concatenated (an implicit concat reducer; an explicit reducers: entry for the key still wins). The key is reserved for the engine, so do not emit it from your own scripts.

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__select 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_node where clarify_node is an input or llm node 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. Only default itself is templated, not the surrounding question (which is also templated).
  • validation: A length predicate of the form len(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_updates as {{input}}.

llm

A one-shot LLM call with an optionally 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
  reasoning_effort: null        # optional override; model-dependent effort level (e.g. low, medium, high)
  max_attempts: 1               # transient-error retries (default 1)
  max_iterations: 10            # tool-call-loop turn cap (default 10; 0 = no cap)
  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 or 0 = 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 (matching global_tools, agent custom tools, or individual MCP function names) or the shorthand mcp:<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.

Built-in functions inside llm nodes

Coyote's built-in function families are not all governed by the tools: whitelist the same way:

  • user__* requires explicit opt-in. Inside an llm node the interactive builtins are only offered to the model if the node's tools: list names them (e.g. tools: [user__select]). A node that doesn't list them never sees them. This is deliberate: graph nodes usually run headless, and a structured-output node calling user__select ("Ready to finalize?") suspends the node as an escalation instead of producing its output. The graph validator warns when a node with an output_schema explicitly opts into user__* tools. Approval gates are unaffected; use a approval node for human sign-off, not an llm node with user tools.
  • todo__* is unavailable in llm nodes entirely. Listing a todo tool in a node's tools: is a validation error. The todo system drives chat turn loops (auto_continue): the usage prompt, the incomplete-item check at turn end, and the continuation nudge all live at the agent turn boundary. An llm node has no turn boundary. Its continuation is owned by the node's tool-call loop, max_iterations, and graph edges. So a node-local todo list would never be driven to completion and its writes would leak into agent/session state. For driven multi-step work inside a graph, use an agent node whose agent sets auto_continue: true: spawned children get a fresh todo list and are pushed to completion normally.
  • agent__*, memory__*, job__*, skill__*, and RAG builtins stay implicitly available (subject to their usual gates: can_spawn_agents for spawning, the skill policy for skills, and so on) regardless of the node's tools: list. Orchestrator graphs spawning sub-agents from llm nodes, teammate fan-out messaging, node-scoped background jobs, memory, and graph RAG all depend on this.

The mcp_tools field (per-server tool patterns)

Where tools: selects which servers a node may use, an optional mcp_tools: map narrows which tools inside a server the node may call:

review:
  type: llm
  prompt: "{{diff}}"
  tools: [mcp:github]
  mcp_tools:
    github: [get_*, list_*]    # read-only github for this node
  • The node map is one more filter layer stacked on top of the graph-level mcp_tools: (and every other active layer). Layers intersect, so a node can only narrow what the graph already allows. See Restricting MCP Tools for the full model.
  • Values must be YAML lists (graph.yaml does not accept the comma-separated string form).
  • The validator rejects keys naming servers the graph doesn't enable: llm node 'mcp_tools' references MCP server '<name>' not enabled by this graph. Keys may also be mapping_mcp_servers aliases. When the graph uses mcp_servers: [all], this check is skipped and any key is accepted.

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.

A node-level timeout counts as a failure like any other: it routes via fallback when one is set, and fails the graph otherwise. This makes per-node timeouts the preferred way to bound long-running llm nodes. Unlike the graph-level settings.timeout, a node timeout still gives the graph a chance to recover or report through its fallback route. timeout: 0 (or unset) disables the bound.

llm nodes stream the completion internally, quietly: chunks are accumulated as they arrive and nothing is rendered. This matters for the interplay with the client-level read_timeout (Clients, default 300s), which fires between successive reads. Because the node receives bytes throughout generation, read_timeout only bounds a genuine stall between chunks, while the node timeout bounds the node's chat loop (the structured-output extraction pass that follows it is a separate call outside the node timeout, bounded only by read_timeout stalls). A node timeout above 300s therefore genuinely allows generations longer than 300s; without streaming, the first byte would not arrive until the reply was complete and read_timeout would cap it regardless.

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, produced no output, broken pipe, connection error, or error sending request. Any other error fails immediately without consuming further attempts. The default is 1 (no retries).

agent nodes support the same key with the same transient matcher: the child agent is re-spawned from scratch on a transient failure, each attempt with a fresh timeout budget (a per-attempt timeout counts as transient). A retried teammate keeps the same identity, inbox, and roster across attempts. See agent for when re-running a child is safe; structured-output extraction failures are never retried on either node type (they route via fallback instead).

Fail-closed conventions in the bundled review suite

The bundled review agents (adversary, review-gauntlet, code-reviewer, finding-verifier, deep-research, step-runner) use max_attempts + fallback so that every run emits its machine-readable sentinel even when a stage dies: stage failures degrade to PIPELINE-FAULT:-prefixed markers in state, verdict scripts treat any fault as blocking (a degraded run can never read as CONFORMS / PASS / MERGE-READY), and detection is prefix-anchored so a report merely quoting the marker is never misclassified. The adversary additionally records caller-declared verification_commands once in a deterministic run_checks stage. Execution criteria ("tests green") are judged against that recorded run, never re-executed by review branches.

Turn cap (max_iterations)

max_iterations caps the number of model-call turns in one execution of the node (default 10). If the model is still calling tools on the last turn, the node fails with llm node hit max_iterations (N) before LLM concluded and routes to fallback if declared. 0 disables the cap; the validator warns on the explicit 0.

Watcher pattern (short/medium polls). Use max_iterations: 0 for a node whose job is to poll a condition through tool calls (CI status, a file appearing, a queue draining) and conclude only when it holds. A loop that converges on an external signal, not on a turn count. Rules:

  • The tool does the waiting (sleep 30 && check), not the model.
  • Keep tool output tiny (pending / ready), never a log dump.
  • Set a non-zero timeout: on the node. An llm node's timeout is unset by default, which is no bound.
  • Leave max_attempts at 1: a retry restarts from the original prompt and replays every tool side effect.

What still ends an unbounded node, honestly:

  • (a) The model concludes (returns without tool calls).
  • (b) timeout: on the node or settings.timeout on the graph. Either fires only between tool calls, so the effective bound is timeout + one tool call.
  • (c) The context window. Every turn appends its tool results to the conversation and nothing compacts them between turns, so input cost grows quadratically in the number of turns and the node eventually fails with Exceed max_input_tokens limit (routes to fallback if declared), which is an error unrelated to anything you configured.
  • (d) Ctrl-C, from anywhere in the loop: during a model call (the call is interrupted), during a tool (the tool is killed within ~100 ms and reports Tool call '<name>' aborted), or between turns (the turn-top check fires before the next call). The node fails with llm node aborted and the run ends with Graph ... aborted before any fallback: route gets to run. See Abort and cancellation.

For long-lived watchers, prefer a graph-level self-loop: an llm check node with the default cap, a script gate that emits _next: check | done, and settings.max_loop_iterations: 0, with the wait in the script or the tool. Each visit starts with a fresh conversation (flat context) and settings.timeout bounds the whole run.

settings:
  max_loop_iterations: 0
  timeout: 3600
start: check
nodes:
  check:
    type: llm
    prompt: "Run `ci_status` once and reply with exactly `ready` or `pending`."
    tools: [ci_status]
    state_updates: { status: "{{output}}" }
    next: gate
  gate:
    type: script
    script: scripts/ci_gate.sh   # reads `status` from GRAPH_STATE; prints
                                 # {"_next":"done"} when ready, else sleeps 30s
                                 # and prints {"_next":"check"}
  done:
    type: end
    output: "{{status}}"

Inside a map branch, max_iterations: 0 applies per item: with max_concurrency: N you get up to N concurrent unbounded loops.

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. false disables skills for this node only: no skill__list / skill__load / skill__unload meta-tools are exposed to the model. true or omitted inherits the graph-level / agent-level setting.
  • enabled_skills: Per-node restriction of which skills the model can see and load. The graph-level enabled_skills is the universe; the per-node list must be a subset (the validator catches violations at load time). When set, skill__list shows only these skills and skill__load only accepts these names.
  • The model loads what it needs. Nothing is auto-loaded. While the node runs, the model uses skill__list to discover what's available and skill__load to 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 own auto_unload: true frontmatter 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 + full-text + optional graph) 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; 0 = no bound)
  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 a rag node. 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 upstream llm node produced.
  • top_k: Number of chunks to retrieve. Defaults to the knowledge base's own configured top_k.
  • timeout: Retrieval timeout in seconds. Default 120; 0 = no bound.
  • state_updates: Where the result goes. A rag node with no state_updates discards 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.
  • extractor_model: Chat model for graph-based entity/relationship extraction. When set, a knowledge graph is built at index time and used as an additional retrieval signal alongside vector and BM25. Falls back to the global rag_extractor_model config when omitted. See RAG > Graph-Based RAG for guidance on model selection.
  • extractor_prompt: Custom extraction prompt template. Must contain a __CHUNK__ placeholder. Falls back to rag_extractor_prompt then the built-in prompt. Useful for domain-specific entity types (e.g. legal, medical, code). See RAG > Custom extraction prompt.
  • graph_hops: Number of graph hops to expand from matched entities at query time (default: 1). 0 returns only documents directly linked to matched entities with no traversal. Falls back to rag_graph_hops. Higher values surface more loosely related documents; keep at 1 for most corpora.
  • batch_size: Embedding-request batch size.
  • driver: Storage driver for this node's knowledge base. Can be yaml (default) or duckdb (Note that musl Linux builds cannot load DuckDB's extensions and always use yaml). Use duckdb when the node's corpus is large enough that rebuilding the in-memory index on every agent start is noticeable; it keeps vectors and text on disk in a <rag-node-id>.duckdb file beside the generated knowledge base. An invalid driver is rejected when the graph is validated, naming the offending node. Like every field in this section, it only applies when the knowledge base is first built. Changing it later has no effect until the knowledge base is deleted and rebuilt. When the node omits it and the knowledge base is built interactively, Coyote asks which driver to use; setting it here answers that question up front. The qdrant driver is not available here: attaching a remote collection is an interactive flow (.rag attach).

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_overlap all 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). The corpus embedding/chunking cost is paid once, at load time. The retrieval strategy matches normal Coyote RAG: vector + full-text (BM25) signals are always active, and a third graph-based signal is added when an extractor_model is set at build time. All active signals are fused via RRF; if a reranker_model is set, it replaces RRF and graph search is not applied.


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, and collects each sub-branch's result into a state array in input order.

Each sub-branch runs a per-item chain of nodes on a private fork of the state. The chain starts at the node named by branch: (with the item bound to as:) and follows that node's next:, script _next, and fallback: edges until it reaches a node with no next and no _next. A single node with no next: is still the simplest branch: a one-step chain.

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; entry node of the per-item chain
  output_key: finding             # optional; state key the chain writes (default "output")
  collect_into: research_results  # required; state key to receive the array of results
  max_concurrency: 5              # optional; integer or exactly one "{{key}}"
  next: rank                      # required; where to go after the map collects

research_subject:                 # chain step 1 (runs once per item)
  id: research_subject
  type: llm
  prompt: "Research {{subject}} in depth."
  state_updates:
    finding: "{{output}}"         # the chain must write `output_key`
  # no `next:` here, so this is also the last step: the chain ends

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 [] to collect_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. Every node in the chain can read it via {{<as>}} in its prompt / templates (or from GRAPH_STATE in a script). Must differ from output_key; the validator rejects a map whose as and output_key are the same key (the item binding would be collected as the chain's result).
  • branch: The id of the chain's entry node. The branch subgraph is that node plus everything reachable from it through declared next: and fallback: edges; the validator checks the whole subgraph (see Branch subgraph rules).
  • output_key: The state key whose value in the item's final fork state becomes the item's result. Default "output". The chain must write it; see Output contract. Must differ from as.
  • 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: Cap on items in flight. One slot is held for an item's whole chain, so the cap counts items, not steps. Falls back to settings.max_concurrency (default 8) when unset. Accepts either an integer (>= 1) or a template that is exactly one {{key}} (surrounding whitespace allowed, nothing else) and must resolve at run time to a positive integer: a JSON number or a numeric string, so a script can emit {"review_budget": "3"}. Anything else (0, "high", a missing key) fails the map node with an error naming the template and the value it resolved to. A quoted string that is not exactly one template ("4", "n={{k}}", "{{ key }}", "{{a}}{{b}}") is rejected at load time: write an integer or exactly one {{key}}. settings.max_concurrency itself cannot be templated.
  • next: Required. Where the parent super-step continues after the map collects.

Branch subgraph rules

At load time the validator computes each map's branch subgraph (the entry node plus everything reachable through next: and fallback:) and enforces these rules. Errors are reported on the map node and name the offending node.

  • Node types. Every node in the subgraph must be llm, agent, rag, or script. An approval, input, end, or map node anywhere in the subgraph is a load error. A chain ends by omission, never with an end node, so the main-flow idiom fallback: end_failure is an error on a branch node and gets a targeted hint: a branch's fallback must stay inside the branch subgraph. Before this release fallback: on a map branch was accepted but never honored. It is honored now, per item, so either point it at a branch-local recovery node or remove it.
  • No fan-out inside a branch. next: [a, b] on any subgraph node is a load error. Restructure as one flattened map, or a second map after the join.
  • Disjoint from the main flow. No subgraph node may also be reachable from start without entering a map branch (next, fallback, and approval routes all count as main-flow edges). Such a node would run standalone, with no as binding, and could route into the middle of a chain. The error lists the shared node(s). A branch next: that escapes to the main flow trips this rule, and if the escape path also reaches an end (or approval/input/map) node the node-type rule fires for that node too. Both errors are correct; fix the escaping edge. The check is static: a main-flow script _next into a branch node is not detected, like any other undeclared _next.
  • Writes are fork-local. Chain nodes may use state_updates with any keys, and output_schema. Everything they write lives in the item's fork and is discarded at collect, except output_key.
  • Declared writer for output_key (warning). If no node in the subgraph declares a write to output_key (a state_updates key or an output_schema property) the validator warns, because the map will error at run time if the chain never writes it. Any script node in the subgraph suppresses the warning: scripts can write any key.
  • No declared cycles. next:/fallback: back to an earlier chain node is a cycle and is rejected like any other declared cycle. Per-item loops are script _next loops; see Retrying inside a branch.
  • Two maps may share a branch subgraph. Forks are private, so a per-item pipeline can be reused by several maps.

Output contract

  • The item's result is the value of output_key in its final fork state. Any chain node may write it; the last write wins.

  • The chain must write it. The map removes output_key from each item's fork before the chain starts, so a value the parent held is never collected as if the item produced it. An item that finishes without writing the key fails the map:

    map node 'fan_out_subjects': sub-branch [1] did not write output_key 'finding';
    write it via state_updates, output_schema, or a script's JSON output
    (the parent's value is not inherited inside a map branch)
    

    Writing a value equal to the parent's is fine: removal, not diffing, decides whether the chain wrote.

  • Reading output_key before writing it sees the key absent, and where the read happens decides what that means: in a prompt/instructions template it is a strict-interpolation error; in a state_updates template it silently renders as ""; in a script the key is simply missing from GRAPH_STATE. The accumulator idiom output_key: summary + state_updates: { summary: "{{summary}}\n{{output}}" } therefore no longer prepends the parent's value. A per-item accumulator seeded from the parent is cross-item coupling that maps don't offer; seed per-item input through as: instead.

  • Prefer a named key. With the default output_key: output, an llm/agent/rag chain step whose state_updates don't name output does not write it (the transient completion binding, see the implicit output key), so the collector errors if the chain never writes it. output_key: finding + state_updates: { finding: "{{output}}" } never collides with the completion binding and reads naturally.

  • Every other fork write (draft, attempts, feedback, ...) is branch-local scratch: visible to later chain steps, gone at collect.

Retrying inside a branch with _next

A chain loops only through a script's _next. Together with fork-local scratch that gives each item its own retry loop, spawn, verify, then retry or accept, with no coupling between items:

review_each_file:
  type: map
  over: "{{files}}"
  as: file
  branch: review_file
  output_key: verdict
  collect_into: file_verdicts
  max_concurrency: "{{review_budget}}"
  next: aggregate

review_file:                      # step 1: do the work
  type: agent
  agent: file-reviewer
  teammates: true
  prompt: "Review {{file}}. Prior feedback (if any): {{retry_feedback}}"
  state_updates:
    draft: "{{output}}"
  next: check_review

check_review:                     # step 2: verify it
  type: llm
  prompt: "Does this review of {{file}} meet the bar?\n\n{{draft}}"
  output_schema:
    type: object
    properties:
      pass: { type: boolean }
      feedback: { type: string }
  next: check_gate

check_gate:                       # step 3: route (deterministic, no LLM)
  type: script
  script: scripts/check_gate.py

check_gate.py reads pass, feedback, draft, and a retry_count it keeps in fork state, then prints either {"_next": "review_file", "retry_feedback": "...", "retry_count": 2} to loop or {"verdict": ...} with no _next to end the chain. Seed retry_feedback in initial_state (or with an upstream node): the first review_file prompt reads it, and prompts fail strict interpolation on a missing key.

Rules for the loop:

  • _next must target a node inside the branch subgraph. Routing outside it fails the item: ... routed to 'aggregate' which is outside the branch subgraph rooted at 'review_file' (branch nodes: check_gate, check_review, review_file). Script _next targets inside a map branch must stay within the branch.
  • Each node's per-item visit count is bounded by settings.max_loop_iterations on the fork (0 = no cap); exceeding it fails the item and names the node and the count. Keep your own budget (retry_count) under that hard cap. 0 disables that cap too; since there is no per-item timeout, a runaway _next loop in a branch is then stopped only by settings.timeout, the enclosing agent node's timeout, or Ctrl-C (which aborts the run promptly from anywhere, cancelling every in-flight item chain; see Abort and cancellation).
  • Item errors are fail-fast and carry the chain position: map node 'review_each_file': sub-branch [2] failed at node 'check_review' (step 2): ....
  • A script step that fails consults its fallback: (a branch-local node) before failing the item, exactly like a main-flow script. An llm or agent step's max_attempts and fallback: work per item the same way.

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.

Not supported in v1

  • Fan-out or a nested map inside a branch subgraph. The validator rejects both at load time. If you need M * N parallel work, restructure to a single map over a flattened list, or a second map after the join.
  • end nodes inside a branch. Chains end by omission; end means "the graph returns".
  • Per-item error policy (on_item_error, skipping or null-filling a failed item). One failing item fails the map.
  • Per-item timeouts. settings.timeout applies to the whole super-step; node-level timeouts (llm/agent/script) apply per step. timeout: 0 on a branch's agent/script nodes is the explicit opt-out for long-running chains; the item then runs until its terminal node.
  • Shared state between items mid-run. Items communicate via teammate messaging; results merge at collect.

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 end nodes 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_updates values -> 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 end node's state_updates runs with plain lenient interpolation. There is no scoped {{output}} because there is no node-body output to scope. After state_updates apply, the end node's own output template 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:

  1. Script _next override: script nodes can set "_next": "node_id" in their stdout JSON to dynamically choose the next node.
  2. Internal routing: approval routes via its routes map (or on_other when the answer matches no listed option).
  3. Default next edge: the next field 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. Failures without fallback halt the graph. Yes (success path; failure with fallback routes to single target)
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

Inside a map's branch subgraph the "needs next" column relaxes: a chain node with no next (and no script _next) is the chain's last step, not an error. Fan-out is never allowed inside a branch. See Branch subgraph rules.

Failure-handling contract

Node type Success Failure with fallback Failure without fallback
llm / agent Routes via next Routes via fallback Graph fails at this node (use fallback: to recover)
script Routes via next / _next Routes via fallback; last_script_error holds the error Graph fails at this node (use fallback: to recover)
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, agent and script node failures without a fallback halt the graph with a clear error message. This prevents downstream nodes from running against garbage state when an upstream call fails (HTTP 4xx/5xx, timeout, structured-extraction error, script exit code, 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 map node 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        # entry node of the per-item chain
  output_key: finding
  collect_into: research_results  # array, in input order
  next: rank

The map executor spawns one sub-branch per subjects item. Each sub-branch runs a per-item chain on its own fork of the state: it starts at research_subject with {{subject}} bound to the item and follows that node's next: / script _next / fallback: edges until a node with no onward route ends the chain. The chain writes its result to output_key (finding); those values are collected into research_results in input order regardless of which sub-branch finished first, and every other write the chain made is discarded with the fork.

A branch node may therefore have next:. A single node with no next: is just the shortest chain. What a branch chain may not do is fan out, contain another map, or end in an end node; see Branch subgraph rules and Retrying inside a branch.

Narrowing a child's fan-out. When a branch delegates to a graph agent that has its own map, the fan-outs multiply: 4 items × a 4-wide child is 16 concurrent sub-agents. A templated max_concurrency sizes this graph's fan-out at run time; to size the child's, give the child a templated cap (max_concurrency: "{{max_parallel}}" with a default in its initial_state) and pass the width from the branch's agent node with inputs: {max_parallel: "{{inner_width}}"}. A lone {{key}} input keeps the parent's number a number, so the child's cap resolves to an integer. See Passing state into a child graph agent.

Teammate messaging in fan-outs

By default, concurrent sub-agents in a fan-out are mutually invisible: each reports only through its own branch output. Setting teammates: true on an agent node turns the concurrent siblings into a communicating team:

review_file:
  id: review_file
  type: agent
  agent: file-reviewer
  prompt: "Review {{file}}"
  teammates: true

Mechanics:

  • Scope = one fan-out. A team forms per map node (its branches) or per super-step (the teammates: true agent nodes in that frontier), and requires at least two members. Each fan-out gets its own registry: a flagged map fan-out and a flagged agent node in the same super-step form two separate teams and cannot message each other.
  • In a map, the identity is the item, not the node. A map provisions one identity and one inbox per item when any agent node in its branch subgraph has teammates: true (and there are at least two items). That identity lives for the item's whole chain: it is handed to each teammates: true agent step in the chain (a second flagged agent step speaks as the same teammate and drains the same inbox), it is never handed to unflagged agent steps or to llm/rag/script steps, and it is retired only when the chain ends -- on success, on error, and when an agent step times out. Messages sent to an item while it is between agent steps (running a gate script, say) wait in its inbox until its next flagged agent step checks it.
  • Accepted-then-unread window. Because the identity outlives the last agent step, a message delivered after an item's last teammates: true agent step but before its chain ends is accepted ("Message delivered to teammate") and never read. With a single-node branch that window is microseconds; with a chain it is the whole tail of the chain (gate scripts, llm steps). Design teams so cross-talk happens during the agent steps, not after them.
  • Pre-provisioned inboxes. Every member's (id, inbox) pair is created before any branch starts, so a fast sibling can message one that has not started yet -- the message simply waits in its inbox.
  • Roster injection. Each member's prompt gains a roster block naming its teammates (collision-safe graph_agent_* ids plus human labels: branch[idx] for map branches, the node id for super-step nodes). Members can also rediscover peers at runtime via agent__list_running, which lists them in a dedicated peers section.
  • Messaging only. The team registry carries no lifecycle semantics: peers are not collectible, do not count against max_concurrent_agents, and outputs still flow exclusively through collect_into / state reducers. Once a member finishes, sending to it returns an error -- its results travel through the graph's collected outputs, not its inbox.
  • The registry dies with the fan-out. If the node is done, so is the team.

Upward fan-in needs no flag: every branch already shares the graph agent's own inbox, so children can agent__send_message --id parent at any time and a downstream llm node can drain those findings with agent__check_inbox.

When to use it: research or implementation lanes where one branch's discovery should redirect a sibling's work mid-flight. Leave it off for independent-verdict lanes (e.g. parallel reviewers), where cross-talk invites anchoring bias.

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.

The load-time check covers declared writes only. The runtime applies the same contention rule to every key a branch actually wrote (writes are extracted by diffing branch state; see Branch isolation), including keys the load-time validator does not model (for example, keys a script emits in its JSON that no state_updates declare). One key that never joins on its own is the implicit output key:

The implicit output key (gotcha)

{{output}} in a state_updates template refers to the node's completion (the raw text, or the extracted object when output_schema is set). The binding is transient: the executor sets output to the completion, renders every state_updates template, then puts output back to whatever it was before the node ran, or removes it again if the key did not exist. An LLM/agent/rag node therefore never persists its completion under output on its own, and never writes output at all unless its state_updates name it. If you want the completion to survive, name a key:

state_updates:
  finding: "{{output}}"

Writing output explicitly also works (state_updates: { output: "{{output}}" } leaves output equal to the completion), but a named key is the recommended pattern, especially for map branches (pair it with the map's output_key, see map nodes).

Consequences of the restore step:

  • A node with no state_updates writes nothing at all, and a node whose state_updates name other keys writes only those. A map branch chain that never writes the map's output_key (the key is removed from each item's fork before the chain starts, so nothing is inherited) therefore fails the map at run time with did not write output_key, and the validator warns about it at load time. This is yet another reason to always declare a named output_key and write it explicitly.
  • Parallel LLM/agent/rag branches whose state_updates name distinct keys do not contend on output at the join: output is restored to exactly what it was (or removed again), so the state diff sees no write to it. No reducer for output is needed.
  • A later node that interpolates {{output}} after such a step hits the strict missing-key error instead of receiving the string "null"; a script node reading state["output"] finds the key absent (read it with a default, e.g. state.get("output")). log_state_snapshots shows no output key after such nodes.

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 state during the super-step without stepping on siblings. But every key a branch wrote participates in the join, so any key written by 2+ branches needs a declared reducer. The transient output binding is restored (or removed again) before the diff, so it never counts as a write on its own (see the implicit output key)
  • 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_output instead 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, state_updates values, and agent-node inputs 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 anywhere in a map's branch subgraph). 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:

  1. The node body runs normally.
  2. 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.
  3. 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.
  4. 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).
  5. {{output}} (inside state_updates) resolves to the full parsed value.
  6. Explicit state_updates win 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 instructions is 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 fallback if 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_other target pointing to a non-existent node
  • Any cycle in declared static edges (cycles are always errors. The per-node max_loop_iterations is a runtime safety net for dynamically- routed loops, not a license for static cycles)
  • Graph has zero end nodes. Execution would never terminate
  • approval option without a matching routes entry
  • script file path does not exist relative to the agent's directory
  • agent node references an agent name that doesn't exist in the coyote agents directory, or that exists but has neither a config.yaml nor a graph.yaml
  • rag node with no documents (at least one knowledge source is required)
  • llm node referencing an unknown tool or mcp:<server> in its tools whitelist, or an unknown model. 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 the reducers: 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 an approval or input node. These would queue N concurrent user prompts, which is a UX disaster. So they're rejected at load time
  • Map branch subgraph violation: the validator checks every node reachable from a map node's branch: entry through next:/fallback: edges: (1) all must be llm, agent, rag, or script (no approval/input/end/another map -- fallback: end_* on a branch node is the usual culprit); (2) none may fan out with next: [...]; (3) none may also be reachable from start on the main flow. A missing declared writer for the map's output_key is a warning, not an error. See Branch subgraph rules
  • 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 empty state_updates: {} to acknowledge known writes, or add explicit declarations
  • max_concurrency set to 0: either settings.max_concurrency: 0 or MapNode.max_concurrency: 0 is 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, or state_updates value) 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 end node reachable from the start via declared static edges
  • approval routes entry without a matching option
  • rag node with no state_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 _next in 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}}:

  1. Top-level CLI: coyote -a my-graph-agent "user prompt here"
  2. REPL: When the active agent has a graph.yaml, every user message in the REPL runs the graph fresh; the message becomes {{initial_prompt}}
  3. 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 a config.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. --session on the CLI, a session name passed to .agent in the REPL, or running .session while inside a graph agent. Any app-level agent_session default is silently skipped for graph agents rather than applied.
  • RAG is per-node, not agent-wide. Graph agents do RAG via rag nodes (each with its own knowledge base); there is no agent-wide documents field at the graph.yaml top level.
  • A rag node's knowledge base is built once, at load time. Changing a rag node's documents does not rebuild it. Delete <agent-dir>/<node-id>.yaml to force a fresh build on next run.
  • on_other is required on every approval node because user__select always permits free-form responses (see the approval section).
  • validation on input nodes is length-only. The grammar is len(input) <op> <integer> with <op> in > >= < <= ==. No regex, no type coercion, no range checks. Use a follow-up script node for richer validation.
  • An input node's default is not re-validated. When the user submits an empty response and the default is substituted in, that substituted value is not checked against validation. Make sure any default you set would itself satisfy the validation predicate.
  • Tool whitelist is llm-only. agent nodes always use the child agent's full tool universe. They ignore any tools: field. This is by design: child agents own their tool surface.
  • {{output}}, {{choice}}, {{input}} are scoped to state_updates. Outside state_updates (e.g. in another node's prompt), these scoped variables are not available unless the previous node explicitly stored them via state_updates. end nodes do NOT get a scoped {{output}}. They have no node body output to scope.
  • Schema-hint auto-injection happens for llm nodes only, not agent nodes (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_iterations is a runtime safety net for cycles built via dynamic script._next routing, 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 as python3 and TypeScript requires npx tsx on 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_iterations doesn't count map sub-branches. The per-node visit cap fires for cycle / runaway-loop detection on graph edges. A map over a 1,000-element over: runs the branch 1,000 times but increments the branch's loop count by zero. The over: 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. sum declared 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 like reducer '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 (Continue for success, FellBack(target) for failure-with-fallback), so a fan-out LLM with fallback: matching one of its next: 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. Use input and approval nodes; they are the only supported pause-for-user points in a graph. The same restriction applies to custom tools invoked from llm nodes.

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), step-runner (orient -> staleness check -> implement via a coder agent node -> verify -> edge-case sweep -> handoff -> approval gate; a workflow-enforcement example combining agent nodes, approval gates, script routing, and bounded fix-loops). See Agents > Built-In Agents for descriptions.
  • Agents - non-graph agent system (config.yaml + LLM loop)
  • Custom Tools - building tools.sh / tools.py / tools.ts files for use in graph nodes
  • Roles - note that the built-in __structured_output__ role used by output_schema is intentionally internal and is not user-visible
  • MCP Servers - mcp:<server> shorthand inside an llm node's tools: whitelist
⚠️ **GitHub.com Fallback** ⚠️