What we’re building
An online store — Acme Store — needs a support assistant. Three obvious jobs, three separate agents: someone to check order status, someone to handle returns, someone to answer product questions. A real support team would never ask one person to do all three well — the order desk doesn’t know the return policy by heart, and the product expert doesn’t have access to shipment tracking. An AI agent is no different: give it too much to know at once, and it starts guessing at the parts it’s weakest on. So each job gets its own agent, with only the tools and data that job actually needs. We’ll also add one members-only agent, visible only to verified VIP customers.Step 1 — Create a project folder
agents.yml
Step 2 — Set a default model
agents.yml
Step 3 — Declare the tool orders needs
agents.yml
get_order_status to already exist in this list, or the reference fails validation. Tools and MCP servers get declared first; the agents that use them come later.
Step 4 — Declare the MCP server catalog needs
agents.yml
This URL is illustrative — it points at Acme’s own internal catalog server, which doesn’t exist outside this example. In your real deployment, this is where your actual product catalog or inventory system’s MCP endpoint goes. The point of this step is that connecting one is exactly this simple: a name and a URL, no client code.
Step 5 — Build the orchestrators
We have three worker agents to build (Step 6) plus one VIP agent — but before any of them exist, something needs to decide which one a message goes to. That’s what an orchestrator is: not a worker, just a router. It reads the message, reads each option’sdescription, and picks one.
Looking at our tree from the top: main_router has to choose between two things — “regular store support” or “the VIP desk”. It does not need to know that orders_agent, returns_agent, or catalog_agent exist individually — those are one level down, someone else’s problem. That’s why store_router exists: it’s the one that actually chooses between orders, returns, and catalog. Splitting it this way keeps every single routing decision small — nobody ever has to pick from four or five options at once.
So we declare both, top to bottom, matching the tree:
agents.yml
Step 6 — Add the agents
agents.yml
orders_agentgets one tool — real order data, looked up, not guessed.returns_agentgets no tool at all. The return policy is fixed text, not something to look up per request, so a tool would just be unused ceremony — the prompt alone is the right amount of machinery here.catalog_agentgets one MCP server — live product data, and nothing else.vip_agentisprotected: true— more on that in Step 8.
orders_agent has no path to the catalog; catalog_agent has no path to order data. That separation — not prompt wording — is what keeps each one from guessing at a job it wasn’t given.
The full YAML is done. Nothing else gets added to agents.yml from here — the rest is generating and writing the code and prompts it points to.
Step 7 — Generate the plugin stubs
Before writing a single prompt file or line of Python, rungenerate. It reads the YAML and scaffolds everything it references, so you know exactly what’s left to fill in.
plugins/__init__.py and plugins/tools/__init__.py — empty package markers
plugins/__init__.py and plugins/tools/__init__.py — empty package markers
These make
plugins and plugins/tools proper Python packages so the engine can import your code. You never need to open or edit them.plugins/plugins.toml — the manifest
plugins/plugins.toml — the manifest
Maps
get_order_status to plugins/tools/get_order_status.py. generate writes and updates this automatically every time you run it — never edit it by hand.plugins/tools/get_order_status.py — your tool, unimplemented
plugins/tools/get_order_status.py — your tool, unimplemented
plugins/tools/get_order_status.py
input is a dict of whatever arguments the model passes when it calls this tool, and the return value is a plain string the model reads back. raise NotImplementedError is a deliberate placeholder, not a bug — it doesn’t stop generate or validate from succeeding. If the model calls this before you replace the body, you get a loud, clear failure instead of silent wrong behavior. Step 10 replaces this with real code.plugins/access.py — generated because vip_agent is protected
plugins/access.py — generated because vip_agent is protected
plugins/access.py
generate scaffolds this automatically the moment any node in your spec has protected: true — you didn’t have to ask for it separately. Right now it blocks every protected node, since it always raises. Step 8 fills in the real check.catalog_agent and returns_agent have no files here at all — catalog_agent’s capability comes from an MCP server, which needs no local code, and returns_agent has no tool to generate a stub for.
Step 8 — Fill in VIP access control
Replace the generatedplugins/access.py stub with a real check:
plugins/access.py
vip_agent is invisible to main_router entirely — not refused after the fact, just never offered as an option. That’s a stronger guarantee than anything you could write into a prompt.
Step 9 — Write the prompt files
The YAML from Step 6 references six prompt files that still don’t exist on disk.generate only scaffolds plugin code — it never writes prompt files for you, and validate fails if a referenced one is missing. So we write them now, before validating anything.
Each one lives in its own subdirectory, so create those first:
prompts/main_router/orchestrator.md
prompts/store_router/orchestrator.md
prompts/orders/system.md
prompts/returns/system.md
prompts/catalog/system.md
prompts/vip/system.md
orders_agent and catalog_agent are both told to defer to their tool’s real data rather than reason their way to an answer — and that instruction only works because each one has nothing else available to reach for.
Step 10 — Implement the tool
Real stores don’t expose their order database as a public API, so we simulate one with an in-memory lookup — in production, swap this for your real database or order-service call. Remember the actual signature from Step 7: oneinput dict in, a string back out.
plugins/tools/get_order_status.py
requirements.txt at the project root — a plain pip file, not TOML, and a different mechanism entirely from plugins.toml.
Step 11 — Validate
plugins/access.py is importable since a protected node exists. Exits non-zero with a specific error location if anything’s wrong.
Step 12 — Run it
Rather than passing-e ANTHROPIC_API_KEY=sk-... on every command, put it in a .env file once:
.env
--env-file instead of -e:
main_router → store_router → orders_agent → get_order_status → a real, looked-up answer: shipped, tracking 1Z999AA10123456784.
returns_agent — no tool call, just the prompt’s own policy text, answered instantly.
catalog_agent. Since catalog_mcp points at an illustrative URL that doesn’t really exist, the engine logs a warning and connects with no tools bound — it’ll answer from the prompt alone, without a real catalog lookup. Point catalog_mcp’s url at your real internal server and this same call starts returning real inventory data, with no other change to the spec.
Step 13 — Run it as a server
run above executes one message and exits — good for testing, but not something else can talk to. serve starts the same spec as a long-lived HTTP API instead:
agent-manager) on top of the same YAML.
What you just built
- A two-level routing hierarchy where every orchestrator’s decision stays narrow
- Four agents with completely separate tool/MCP access — the real mechanism behind not guessing outside your lane
- One protected node, gated by a real access plugin instead of prompt wording
- A working local tool, an MCP declaration ready for a real server, and an agent that needed no tool at all
Next steps
Add the chat widget
Turn this into a live chat you can embed on a website.
MCP auth
Add real credentials to catalog_mcp with a hook.
Full YAML reference
Every field you can declare, explained in depth.
Execution limits
Cap tool calls and loops before they run away.