agents.yml defines your entire agent system: every agent, what it can do, and how requests route between them. Extra validates it and compiles it into a running system.You still write the prompt files it references and implement any tools or resolvers you declare — the YAML alone isn’t the whole system.Every top-level key, in the order you’d typically write them:
How everything above connects — the routing topology
orchestrators and agents are each individually optional, but graph must have a root, and that root has to be declared in one of them. In practice: a single-agent system needs only agents; anything routing between multiple agents needs at least one orchestrator too.
Extra validates the whole file before running anything — bad references, missing prompt files, or broken graph structure fail immediately with a clear error, not at request time.Every example below builds toward one running system: a customer support bot for an online store, with separate agents for orders, returns, and catalog questions.
Set a fast, cheap model as the default (e.g. Haiku) and override to a stronger model only where it matters. Orchestrators that just route work well with a fast model — save the strong model for agents doing real reasoning.
For Bedrock, region can be omitted if AWS_REGION or AWS_DEFAULT_REGION is set. Credentials follow the standard AWS chain — profile, environment variables, or an IAM role.
A node-level model is a full replacement, not a merge with defaults.model. If you override the model on an agent, specify every field — provider, name, and any others you need. Partial overrides won’t inherit missing fields from defaults.
Never put API keys in YAML. Extra reads provider credentials from environment variables at runtime.
A tool is a Python function an agent can choose to call. You describe what it does; Extra generates the stub; you write the logic.
tools: get_order_status: description: "Get the current status and tracking info for an order by order ID." cancel_order: description: "Cancel an order that hasn't shipped. Requires order ID and reason." create_return_request: description: "Open a return request for a delivered order. Requires order ID and reason."
Field
Required
Description
description
Yes
What the model reads to decide whether to call this tool.
The description is the model’s only signal for choosing a tool. Write it like you’re briefing a new team member — clear inputs, clear purpose. “Get order status by order ID” beats “order_status_getter” every time.
generate creates one stub file per tool (see Quickstart). You fill in the real implementation:
plugins/tools/get_order_status.py
def get_order_status(order_id: str) -> dict: """Get the current status and tracking info for an order by order ID.""" return { "status": "shipped", "tracking_number": "1Z999AA10123456784", "estimated_delivery": "2026-07-05", }
Give an agent access by adding the tool id to its tools list — see agents below.
A resolver fills a {{variable}} in a prompt — always run before the agent starts, never chosen by the model, and free (no token cost).
resolvers: current_date: scope: shared # runs once, every agent that opts in gets it customer_name: scope: shared loyalty_tier: scope: agent # runs only for agents that declare it
scope
Runs on
Use it for
shared
Every agent that lists it
Context most agents need — date, user identity, locale
agent
Only the declaring agent
Context specific to one agent — its own lookups, its own logic
An orchestrator is a router. It reads the incoming message, looks at its children’s description fields, and decides which child should handle it. It never calls tools or MCP servers itself — routing is its only job.
orchestrators: router: name: "Support Router" description: "Routes customer requests to the right department." model: provider: anthropic name: claude-haiku-4-5 # routing only — a fast model is enough temperature: 0.0 prompts: orchestrator: "prompts/router/orchestrator.md" system: "prompts/router/system.md" # optional
Field
Required
Description
name
No
Display name. Defaults to the key.
description
No
What a parent orchestrator reads to decide when to route here.
prompts.orchestrator points to a Markdown file containing the actual instructions the router follows. This is the file’s full content — nothing more is needed:
prompts/router/orchestrator.md
You are a customer support router for an online store.Route the user to the most relevant department based on their message.If it's unclear which department applies, ask one clarifying question before routing.
The router sees this prompt plus every child’s description. It picks the best match — it doesn’t need to know how each child works internally.
An agent is an executor. Once the router sends it a request, it runs an LLM with its own prompt and can call whatever tools or MCP servers it’s been given access to — declared above under tools, mcps, and resolvers.
You are an orders specialist for Acme Store.Today's date is {{current_date}}.You are helping {{customer_name}}.You can:- Check order status and tracking- Cancel orders that haven't shipped yet- Escalate to a human agent for complex issuesYou cannot process refunds — direct those questions to the returns team.
{{current_date}} and {{customer_name}} are filled in automatically before the agent runs — see resolvers above.
A hook is trusted code that runs automatically at a fixed point in the request lifecycle — never exposed to the model, never chosen by it. The most common use is injecting auth headers before an MCP call:
The YAML only registers which hook runs where — plugin resolves to a class through plugins/plugins.toml, and method names the method to call. It carries no import path, no config block, and no secret. The hook code reads its token from the environment and attaches the Authorization header at request time, so the token never touches a prompt, a log line, or the YAML file itself.Full list of lifecycle points, audit hooks, and result-transform examples: Runtime Hooks.
graph is where routing topology is declared. Indentation defines parent/child relationships. There is exactly one root — the entry point every request hits first.Here’s the running example we’ve been building — one orchestrator routing to three agents:
User message │ ▼ router orchestrator — decides where to send the request ├── orders_agent handles order status, tracking ├── returns_agent handles refunds and returns └── catalog_agent handles product questions
Orchestrators can nest inside other orchestrators, letting you build multi-level routing:
graph: main_router: store_router: # sub-orchestrator — only sees store-related children orders_agent: returns_agent: catalog_agent: account_agent: # direct child of main_router vip_agent: # protected — only reachable by VIP customers
At runtime, main_router first decides between store_router, account_agent, and vip_agent. If it picks store_router, that orchestrator then makes its own routing decision among its three children. Each orchestrator only ever sees its own direct children — never the whole tree.Rules:
Exactly one root.
Every key must already be declared under orchestrators or agents.
Orchestrators can have children; agents are leaves.
Mark any node protected: true to gate it behind your own access logic. A protected node is invisible to the router until your access plugin approves it — the model never even knows it exists.
agents: vip_agent: description: "Exclusive offers and services for VIP members." protected: true prompts: system: "prompts/vip/system.md"
When any node in the spec is protected: true, Extra requires plugins/access.py: