# Graph-based agent definition (full-featured reference) # Location: /agents//graph.yaml # # A graph agent is defined by this file alone. An agent directory contains # either a config.yaml (a normal LLM-loop agent) or a graph.yaml (a graph # agent), never both. The presence of graph.yaml is what makes the agent # a graph agent. # # This file is a reference: it documents every available field, themed # around a deep web research workflow with parallel retrieval. It is not # a runnable agent as-is. The `agent:`, `script:`, and `documents:` values # point at things that would need to exist for a real agent. For a real, # runnable deep-research graph agent, see assets/agents/deep-research/. # # Full documentation: # https://github.com/Dark-Alex-17/coyote/wiki/Graph-Agents # --------------------------------------------------------------------------- # Identity # --------------------------------------------------------------------------- name: deep-research-example # Agent name (should match the directory name) description: | # Free-form prose describing the workflow A reference workflow: triage a research request, retrieve local context, branch on a script decision, run either a sub-agent or an LLM research step, then gate the result behind human approval. version: "1.0" # Graph schema version. Only "1.0" is accepted. # --------------------------------------------------------------------------- # Agent-level config (all optional) # The same knobs a normal agent's config.yaml carries. In a graph agent they # live here instead of in a config.yaml. # --------------------------------------------------------------------------- model: claude:claude-sonnet-4-6 # Default model for `llm` nodes that don't override it temperature: 0.0 # Default sampling temperature for `llm` nodes top_p: null # Default sampling top-p for `llm` nodes reasoning_effort: null # Default reasoning effort for `llm` nodes that don't override it. # Only valid when the model declares reasoning_levels. max_concurrent_jobs: 5 # Max background jobs (`job__*` tools) running at once across the # whole graph run: every `llm` node (including parallel branches) # draws from this one pool, so the budget is graph-wide. There is # no per-node override. Jobs themselves are node-local: the node # that starts a job must collect or cancel it before it ends, and # anything left running at node exit is cancelled. Overrides the # global setting; 0 disables background jobs for this graph agent. can_spawn_agents: true # Whether `llm` nodes may spawn dynamic sub-agents via the # agent__* tools. Defaults to true iff the graph has an `agent` # node; an explicit value overrides that. Set it true on a graph # with only `llm` nodes to build an orchestrator graph. Setting # it false on a graph with `agent` nodes still runs those nodes, # only dynamic spawning from `llm` nodes is disabled. max_concurrent_agents: 4 # Max dynamically spawned sub-agents running at once. Explicit # value overrides the default of 4. max_agent_depth: 3 # Max agent nesting depth (agents spawning agents). Explicit # value overrides the default of 3. global_tools: # Tool universe an `llm` node's `tools:` whitelist draws from - web_search_coyote.sh - fetch_url_via_curl.sh mcp_servers: # MCP servers an `llm` node may reference via `mcp:` - ddg-search mcp_tools: # Optional per-server tool allowlists (globs: * and ?) applied to ddg-search: # every node that uses `mcp:`; intersects with the other - search # allowlist layers (global config, agent, mcp.json `allowedTools`). # --------------------------------------------------------------------------- # Skills policy (optional) # Skills only attach to `llm` nodes inside a graph. Both fields are optional. # # skills_enabled: master switch for skills across every `llm` node in the # graph. false here turns skills off entirely, regardless of # per-node settings. Omitting it inherits the agent / global # cascade (default true). # enabled_skills: the *universe* of skill names any `llm` node in this graph # may reference in its own `enabled_skills`. The validator # rejects per-node entries outside this list at load time. # Omit to inherit the agent / global cascade. # # Per-node usage is documented on the `triage` llm node below. There is no # auto-load: the model uses `skill__list` / `skill__load` / `skill__unload` to # bring skills in as it needs them, exactly like in normal-agent contexts. # --------------------------------------------------------------------------- skills_enabled: true enabled_skills: - code-review - git-master - ai-slop-remover 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 surfaced in the UI - "Research the current state of WebAssembly outside the browser" # --------------------------------------------------------------------------- # Agent variables (optional) # Declared the same way as a normal agent's config.yaml `variables:` block. # Each variable becomes available to: # - LLM nodes via the template form `{{name}}` once seeded into state # (see initial_state below). # - Script nodes via the env var `LLM_AGENT_VAR_`. # Values may be overridden at runtime with # `coyote -a --agent-variable "..."`. # --------------------------------------------------------------------------- variables: - name: project_dir description: | Absolute path to the project directory. default: "." # --------------------------------------------------------------------------- # Execution settings (all optional) # --------------------------------------------------------------------------- settings: max_loop_iterations: 100 # Per-node visit cap. If one node id is entered more # than this many times, execution aborts. Default 100; 0 = no cap. timeout: 600 # Optional wall-clock cap (seconds) on the whole run, # checked between node transitions. 0 = no bound. log_state_snapshots: true # Log state before each node (debug/trace). Default true. validate_before_run: true # Run the graph validator at startup. Default true. max_concurrency: 4 # Cap on simultaneously running branches in any # super-step (static fan-out OR a `map` node). # Default 4. Per-`map` overrides this. See Parallel # Execution below. # --------------------------------------------------------------------------- # Reducers (optional, required whenever two parallel branches write the same # state key in the same super-step; otherwise the validator errors at load). # # A reducer says how two values for the same key get merged. Built-ins: # append list += [value] (single value appended to a list) # extend list += value (a list) (list-of-lists flattened by one level) # concat "a\nb" (string join with newline separator) # sum a + b (numeric add; ints stay ints) # max max(a, b) # min min(a, b) # merge {**a, **b} (dict union, RHS wins on key collision) # overwrite last-write-wins (explicit opt-in; B's value replaces A's) # # Keys not listed here have an implicit "single writer per super-step" rule: # the validator rejects any graph where two parallel branches both write a # key with no reducer. # --------------------------------------------------------------------------- reducers: sources: append # The diamond below writes `sources` from both # branches; append accumulates them into a list. context: concat # Each branch contributes prose; concat joins them. # --------------------------------------------------------------------------- # Seed state (optional) # Values placed into graph state before any node runs; reference anywhere via # {{key}}. # # Note: `initial_prompt` is seeded automatically by Coyote with the # caller's prompt. So there's no need to set it here. # --------------------------------------------------------------------------- initial_state: audience: "general reader" # Seed an empty default for any key that a strict field (a node prompt / # instructions / question / End output) references but that is only set on # some paths. `refinement` is set only if the `refine` input node runs; # seeding it "" keeps `finalize`'s strict prompt from failing on the # approve-directly path. refinement: "" # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- start: triage # ID of the first node to run (must exist in `nodes`) # --------------------------------------------------------------------------- # Nodes # Each node is keyed by its id. The `id:` inside a node must match its key # (it may also be omitted and thus Coyote fills it in from the key). # # Node types: agent | script | approval | input | llm | rag | map | end # --------------------------------------------------------------------------- nodes: # --- llm node ----------------------------------------------------------- # A one-shot LLM call (with an optional bounded tool-call loop). Runs in a # fresh isolated context. Tools are strictly opt-in (see `tools`). triage: id: triage type: llm description: Classify the research request and extract its topic. instructions: | # Optional system prompt (templated against state) You triage research requests for a {{audience}} audience. prompt: | # Required user prompt (templated against state) Classify this request and extract the core research topic: {{initial_prompt}} tools: [] # Tool whitelist. Omitted or [] = no tools at all. # A list narrows to exactly those entries. # --- Skills on llm nodes (optional) ------------------------------------ # `enabled_skills` narrows what this node's model can see / load via the # built-in `skill__list` / `skill__load` / `skill__unload` meta-tools. # Must be a subset of the graph-level `enabled_skills` (the validator # catches violations at load time). `skills_enabled: false` would # disable skills entirely for this node (no meta-tools exposed). # Nothing is auto-loaded: the model decides when to load a skill. skills_enabled: true # Whether skills are enabled on this llm node; defaults to 'true' enabled_skills: - ai-slop-remover inject_skill_instructions: true # Override skill-hint injection for just this node. Falls back to # agent/graph/global default when omitted. skill_instructions: null # Per-node skill-hint text override; uses the built-in default when omitted. output_schema: # Optional JSON Schema. The output is parsed to JSON type: object # and its top-level object keys auto-merge into state properties: # (so `topic` / `needs_deep_dive` become {{topic}} etc). topic: { type: string } needs_deep_dive: { type: boolean } required: [topic, needs_deep_dive] state_updates: # {{output}} = this node's result (here, the parsed object) triage_result: "{{output}}" # --- Polymorphic `next` ----------------------------------------------- # A single string runs the next node sequentially (e.g. `next: retrieve`). # A list runs all listed nodes in parallel as one BSP super-step # (for more info on BSP, see https://en.wikipedia.org/wiki/Bulk_synchronous_parallel). # Their writes are merged via `reducers:` at the join. Branches converge # implicitly when they all route to the same downstream node (here, # `synthesize`). See the diamond: # # triage # / \ # retrieve web_search (run concurrently) # \ / # synthesize (join; fires once after both finish) next: [retrieve, web_search] # --- rag node (parallel branch 1 of the diamond) ------------------------ # Hybrid (vector + keyword) retrieval against a per-node knowledge base. # The knowledge base is built once, at agent load time, into # /retrieve.yaml (named after this node's id). retrieve: id: retrieve type: rag documents: # Required. Files, directories, URLs, loader paths. - ./knowledge/ # relative paths resolve against the agent directory - https://example.com/reference query: "{{topic}}" # Retrieval query (templated). Default: {{initial_prompt}}. top_k: 5 # Chunks to retrieve. Default = the KB's own top_k. timeout: 120 # Retrieval timeout in seconds. Default 120; 0 = no bound. # Knowledge-base build config (optional; used only when the KB is first # built). When embedding_model + chunk_size + chunk_overlap are all set, # the KB builds with no interactive prompts (works in non-interactive runs). embedding_model: openai:text-embedding-3-small chunk_size: 1000 chunk_overlap: 100 reranker_model: null # Optional reranker for hybrid-search results extractor_model: null # Optional chat model for graph-based entity/relationship extraction; enables graph RAG signal when set extractor_prompt: null # Optional custom extraction prompt; must contain __CHUNK__ placeholder; uses built-in prompt when null graph_hops: 1 # Graph expansion depth at query time (0 = seed nodes only; 1 = direct neighbors; increase for denser knowledge graphs) batch_size: 100 # Optional embedding-request batch size state_updates: # {{output}} = { context: , sources: [, ...] } context: "{{output.context}}" # writes `context` -> `reducers.context = concat` sources: "{{output.sources}}" # writes `sources` -> `reducers.sources = append` next: synthesize # Joins with web_search at `synthesize`. # --- llm node (parallel branch 2 of the diamond) ------------------------ # Runs concurrently with `retrieve`. Both branches write `context` and # `sources`; the validator confirms both keys have a reducer declared, and # the BSP scheduler merges them at the join. web_search: id: web_search type: llm instructions: "You are a web researcher. Cite every claim." prompt: "Web research: {{topic}}. Return findings and sources." tools: - web_search_coyote - mcp:ddg-search output_schema: type: object properties: context: { type: string } sources: type: array items: { type: string } required: [context, sources] # When `output_schema` is set, top-level keys auto-merge into state, so # `context` and `sources` are produced without needing `state_updates`. next: synthesize # Joins with retrieve at `synthesize`. # --- script node (the diamond's join; also dispatches) ----------------- # Runs a .sh / .py / .ts script. The script receives state via the # GRAPH_STATE env var (inline JSON) or GRAPH_STATE_FILE (path to a JSON # file, used when state exceeds 32 KiB). Exactly one is set. It must print # a single JSON object on stdout: keys merge into state, and the reserved # `_next` key (if present) overrides routing. # # The script also receives these env vars (parity with bash tools called # from normal agents): # GRAPH_STATE / GRAPH_STATE_FILE state payload (one of the two is set) # LLM_ROOT_DIR coyote config dir # LLM_PROMPT_UTILS_FILE path to .shared/prompt-utils.sh # LLM_AGENT_DATA_DIR this agent's data directory # LLM_AGENT_VAR_ one per declared `variables:` entry # PATH with coyote's functions bin dir prepended # CLICOLOR_FORCE / FORCE_COLOR so child tools emit ANSI colors # The script's working directory is coyote's invocation CWD (not the agent # directory), matching the behavior of bash tools. # # This node fires once: after both `retrieve` and `web_search` finish. # The BSP scheduler dedups the two incoming edges into a single frontier # entry, applies the staged branch writes through the reducers, then runs # this node against the merged state. Inside the script, `context` is the # concatenated text of both branches and `sources` is the combined list. synthesize: id: synthesize type: script script: scripts/synthesize.py # Path relative to the agent directory timeout: 30 # Seconds. Default 30; 0 = no bound. state_updates: # Applied after the stdout JSON is merged decided_for: "{{topic}}" next: summarize # Default route if the script emits no `_next` fallback: summarize # Route taken if the script fails (crash / bad JSON) # This script is expected to emit `_next: deep_dive` (or `_next: subjects_map` # to demonstrate the map node below), or no `_next` (then `next` is used). # Targets reached only via the script's dynamic `_next` get an # "unreachable" warning from the validator. This is expected for `_next`-routed # targets. # --- agent node --------------------------------------------------------- # Spawns a full Coyote sub-agent and waits for it. The child uses its own # tool stack. Agent nodes have no `tools:` field. No schema hint is # injected even when `output_schema` is set (unlike llm nodes). deep_dive: id: deep_dive type: agent agent: deep-research # Name of an existing Coyote agent to spawn prompt: | # User message sent to the child (templated) Research {{topic}} in depth. Existing context: {{context}} # inputs: # Optional. Only valid when `agent` is a GRAPH agent # max_parallel: "{{n}}" # (has a graph.yaml; deep-research is one). Each value # context: "Repo: {{r}}" # is a template against THIS graph's state. The resolved # # map is overlaid on the child's `initial_state` before # # its graph starts, so the child can read {{max_parallel}} # # and {{context}} from its first node on. A lone {{key}} # # keeps the value's JSON type (numbers stay numbers, lists # # stay lists, null overlays the child's default); mixed text # # becomes a string. `initial_prompt` is reserved (always # # seeded from `prompt:`) and is rejected as an input key. # # `inputs:` on a config-only agent is a load-time error, # # and `inputs` values count as reads for the cross-branch # # read check. See the wiki: Graph Agents -> "Passing state # # into a child graph agent (inputs)". timeout: 600 # Optional wall-clock cap, seconds. Default 300; 0 = no bound. teammates: false # Optional. When true and this node runs concurrently # with other flagged agent nodes (map fan-out branches # or the same super-step), the siblings can message # each other via agent__send_message / agent__check_inbox. # Each fan-out gets its OWN registry: a `teammates` map # fan-out and a `teammates` agent node in the same # super-step frontier form two separate teams and cannot # message each other. Peers only span a single map # node's branches or a single super-step's flagged # agent nodes. Inside a map branch chain the identity # belongs to the ITEM, not the node: it lives for the # item's whole chain, is handed to each `teammates: true` # agent step in that chain (unflagged steps never see it), # and is retired when the chain ends. A message delivered # after the chain's last flagged agent step is accepted # but never read. output_schema: # Optional. Same extraction as llm nodes type: object properties: summary: { type: string } findings: type: array items: { type: string } required: [summary, findings] state_updates: research: "{{output}}" next: review # Required for agent nodes # --- map node (Dynamic fan-out. Think: LangGraph's `Send` API) ---------------- # Spawns one parallel sub-branch per item in `over`. Each sub-branch runs a # per-item CHAIN of nodes on a private fork of the state: it starts at the # node referenced by `branch:` (with the item bound to `as:`) and follows # that node's `next:` / script `_next` / `fallback` edges until it reaches a # node with no `next` and no `_next`. When every chain has finished, the # value of `output_key` from each item's final fork state is collected into # the array named by `collect_into:`, preserving input order. Everything # else the chain wrote is fork-local scratch and is discarded at collect. # # Not supported inside a branch chain (validator errors): fan-out `next:` # lists, another `map`, `end`, `approval`, `input`. A branch node must not # also be reachable from `start` on the main flow. Per-item loops are script # `_next` loops (bounded by settings.max_loop_iterations on the fork; `0` = unbounded); a # declared `next`/`fallback` edge back to an earlier chain node is a cycle. # # Reach via `synthesize`'s `_next: subjects_map`. The producer is expected # to have written a list at `subjects` (e.g. an upstream LLM node with an # `output_schema` returning {"subjects": ["a", "b", "c"]}). subjects_map: id: subjects_map type: map over: "{{subjects}}" # Required. List expression resolved from state. # Empty list is allowed. It means no branches spawn, # and thus `collect_into` is written as []. as: subject # Required. Per-branch state key holding the # current item. Read with {{subject}} from any # node in the branch chain. branch: research_subject # Required. ENTRY node of the per-item chain. # Every node reachable from it via `next:` / # `fallback:` is part of the chain and must be # an llm | agent | rag | script node. Chain # nodes may use `next:`, `state_updates:` # (fork-local scratch such as a draft or a # retry counter), `output_schema:`, and # `fallback:` (honored per item; it must point # at a node inside the chain, never at an # `end_*` node). A script's `_next` must also # stay inside the chain; routing out of it # fails the item. collect_into: subject_findings # Required. State key for the array of # per-branch outputs, in input order # (not spawn-finish order). max_concurrency: 3 # Optional cap on items in flight (one slot is # held for an item's WHOLE chain). Defaults to # settings.max_concurrency above. Accepts an # integer or a "{{key}}" template that must # resolve to a positive integer at run time, # e.g. `max_concurrency: "{{research_budget}}"`. output_key: subject_summary # Optional. State key whose value in the item's # FINAL fork state becomes the item's result. # Default "output". The key is removed from the # fork before the chain starts, so the chain # must write it (via state_updates, an # output_schema property, or a script's JSON); # an item that never writes it is an error, and # a chain that reads it before writing it sees # it absent (prompts fail, state_updates render # "", scripts get no key) - seed per-item input # through `as:` instead. Prefer a named key: with # the default, an llm/agent/rag step whose # state_updates do not name `output` leaves # `output: null` and null is what gets collected. next: aggregate_subjects # Where to go after all sub-branches finish. # Entry node of subjects_map's per-item chain. Each item gets its own copy # of this node with a different `subject` in state. With no `next:` it is # also the LAST chain node, so the chain is a single step; give it `next:` # (or emit `_next` from a script step) to extend the chain, e.g. # research -> verify -> gate-script that re-emits `_next: research_subject` # until the draft passes or a retry counter in fork state runs out. research_subject: id: research_subject type: llm instructions: "Research one subject deeply for a {{audience}} audience." prompt: "Research {{subject}}: pull the key facts and one citation." tools: - web_search_coyote # Recommended pattern: write the completion to a named key via # `state_updates` and point the map's `output_key` at that same key. # `{{output}}` is bound only while these templates render. state_updates: subject_summary: "{{output}}" # Aggregator that runs after the map joins. Reads the collected list. aggregate_subjects: id: aggregate_subjects type: llm instructions: "Combine N per-subject reports into one cohesive summary." prompt: | Per-subject reports (in original input order): {{subject_findings}} state_updates: research: "{{output}}" next: review # --- llm node with a narrowed tool whitelist ---------------------------- summarize: id: summarize type: llm instructions: "You write concise research summaries for a {{audience}} audience." prompt: "Summarize the topic {{topic}}, using your tools as needed." tools: # Narrow whitelist: exactly these entries, nothing else - web_search_coyote # an exact global-tool / custom-tool name - mcp:ddg-search # `mcp:` includes that server's functions mcp_tools: # Optional per-node narrowing of MCP tools (globs: * and ?) ddg-search: # keys must be servers this graph enables; intersects with - search # the graph-level mcp_tools above and every other layer model: claude:claude-haiku-4-5 # Optional per-node model override temperature: 0.3 # Optional per-node sampling override reasoning_effort: null # Optional per-node reasoning effort override (e.g. low, medium, high) max_attempts: 2 # Retry count on transient errors only. Default 1. max_iterations: 10 # Tool-call-loop turn cap. Default 10; 0 = no cap. fallback: review # Route here if all attempts fail timeout: 300 # Optional node wall-clock cap, seconds (unset or 0 = no timeout) state_updates: research: "{{output}}" next: review # Required for llm nodes: the success route # --- approval node ------------------------------------------------------ # Human-in-the-loop checkpoint. `user__ask` always offers a free-form # "type your own answer" option, so `on_other` is required. review: id: review type: approval question: | Proposed research result for {{topic}}: {{research}} Approve? options: # The listed choices shown to the user - "yes" - "no" routes: # Map each listed option to its next node "yes": finalize "no": rejected_end on_other: refine # Required: route for ANY answer not in `routes` state_updates: decision: "{{choice}}" # {{choice}} = the chosen option or the free-form text # --- input node --------------------------------------------------------- # Collects a free-form string from the user. refine: id: refine type: input question: "What should be changed about the research result?" default: "tighten the summary" # Optional: used if the user submits empty input. # Note: a substituted default is not re-validated, # so make sure it would satisfy `validation`. validation: "len(input) > 0" # Optional length predicate: len(input) N, # in > >= < <= == . Length only -- no regex. state_updates: refinement: "{{input}}" # {{input}} = the user's text next: finalize # Required for input nodes: the success route # --- llm node (final synthesis) ----------------------------------------- finalize: id: finalize type: llm prompt: | Produce the final research report for {{topic}}. Result so far: {{research}} Requested refinement (if any): {{refinement}} state_updates: final_report: "{{output}}" next: done # --- end nodes ---------------------------------------------------------- # Terminate the graph. `output` (templated, lenient interpolation) becomes # the graph's final result. A graph needs at least one `end` node. done: id: done type: end state_updates: # Optional: applied before `output` is rendered status: "completed" output: | [{{status}}] {{final_report}} Sources: {{sources}} rejected_end: id: rejected_end type: end output: "Research on {{topic}} was not approved."