Skip to main content

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.
We’ll build this YAML section by section, explaining why each piece is there, then generate, implement, and run it for real.

Step 1 — Create a project folder

Everything from here — the spec, the prompt files, the generated plugin code — lives inside this one folder. Start the spec file with just the one thing every spec requires:
agents.yml
That’s already a valid, if useless, spec. Everything below adds to this same file, one block at a time.

Step 2 — Set a default model

agents.yml
Most of this system is routing and factual lookups, not creative writing — a fast, cheap, deterministic model is the right default everywhere until an agent proves it needs more.

Step 3 — Declare the tool orders needs

agents.yml
We declare this before any agent, because an agent is about to reference it by id — Extra needs 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’s description, 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
Two orchestrators, two narrow decisions. Neither one is doing the store’s actual work yet — that’s what the agents in the next step are for.

Step 6 — Add the agents

agents.yml
Each agent gets exactly what its job needs, nothing more:
  • orders_agent gets one tool — real order data, looked up, not guessed.
  • returns_agent gets 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_agent gets one MCP server — live product data, and nothing else.
  • vip_agent is protected: true — more on that in Step 8.
None of these agents can reach outside their own lane. 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, run generate. It reads the YAML and scaffolds everything it references, so you know exactly what’s left to fill in.
Go into the folder it created — here’s exactly what’s inside and why each piece exists:
These make plugins and plugins/tools proper Python packages so the engine can import your code. You never need to open or edit them.
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
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
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 generated plugins/access.py stub with a real check:
plugins/access.py
Until a request carries that header, 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
Notice 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: one input dict in, a string back out.
plugins/tools/get_order_status.py
No new dependency needed here — this uses nothing beyond the standard library. If yours does (an HTTP client, a database driver), that goes in requirements.txt at the project root — a plain pip file, not TOML, and a different mechanism entirely from plugins.toml.

Step 11 — Validate

Fully offline. It checks the YAML shape, that every tool/MCP id an agent references is actually declared, that all five prompt files exist on disk, and that 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
Then reference it with --env-file instead of -e:
The path: main_routerstore_routerorders_agentget_order_status → a real, looked-up answer: shipped, tracking 1Z999AA10123456784.
This one routes to returns_agent — no tool call, just the prompt’s own policy text, answered instantly.
This one routes to 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:
Now call it over HTTP instead of the CLI:
Same spec, same routing, same agents — just reachable over the network instead of one process per message. It’s stateless: each call sends the full conversation and gets one response back, no session or history is kept between requests. For that, see the widget step below, which runs a different mode (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.