Part II · agent design — module 2.04 · ~45 min
The melchizedek protocol
How a whole agent network becomes one file
specimen: config/agents/syndicateSchema.yaml
by the end you can:
- Name what each key in a syndicate definition decides
- Explain how a sub-agent becomes a tool, and why the runner loops
- State the leaf constraint: an agent with an output schema is a leaf, never a delegator
- Describe when plan-dispatch replaces delegation: an orchestrator that only routes leaves the reply path
- Trace one model call through the registry: the prefix of the model string picks the provider
You arrive here having built single agents, run them on open weights, debugged their prompts, and wired your first topologies in module 2.03: an orchestrator whose instruction said who to consult, choosing by reading each specialist’s one-line description. You ran two of those systems, the Council and the Style Council, from files called council.yaml and style_council.yaml, and read them in excerpts.
Now we read the whole file, because the file is the frame for all of Part 2. Melchizedek is an open-source multi-agent framework built on the Google Agent Development Kit, the ADK, and in it an entire network is one readable YAML document called a syndicate definition: the hierarchy of agents, each agent’s instruction, its tools, its model, the memory tier the team shares, and the shape of what each agent returns. One file describes a whole team. Hold that sentence; every section here is one part of that file, read as a decision.
Why does that matter beyond tidiness? When architecture lives in configuration rather than code, you can read a whole system on one page, diff it in version control, and change which model an agent uses by editing a string. By the end you will be able to open any syndicate file, say what each key decides, and say which of two orchestration methods it is running and why.
one file, one running graph
Here is the whole path before any key is explained.
The framework loads your syndicate file. It reads the top-level agent, the orchestrator, and builds it: its instruction, its tools, its model. Then it reads each sub-agent listed underneath and builds those too.
Here is the step that makes the whole thing work. Each sub-agent gets wrapped as a tool and attached to the orchestrator. From the orchestrator’s point of view, “ask the Researcher” is the same kind of operation as “call the search API”: it emits a structured request naming the agent and the task.
A request arrives. The orchestrator’s model reads its instruction and the description line of every attached sub-agent, then emits one or more tool calls. The runner, the loop around the model, executes each call. If the call names a sub-agent, the runner runs that agent’s own complete loop and returns its output as the result. That result goes back into the orchestrator’s context as an observation, and the orchestrator runs again.
That loop continues until the orchestrator stops emitting tool calls and writes an answer instead.
Every model call in that sequence goes through the registry from module 1.02, the small piece of code that reads a model string’s prefix and hands the request to the matching adapter. Each agent names its own model, so different agents in one graph can run on entirely different providers.
why one agent stops working
Module 2.03 gave you the discipline of staying solo: do not add an agent until a single agent with tools has actually failed at the boundary of specialization. Here we pick up after that failure, so it is worth being precise about what failing looks like.
Load one prompt with a dozen tools and several jobs and you get three specific problems, all of which you can now explain mechanically. Instructions for one job compete with instructions for another, and every instruction in the window conditions every token that follows, so the conflict resolves toward whichever text conditions the output most strongly rather than toward what you intended. Tool selection degrades as the menu grows, because the description of the right tool is one signal among many; picture a menu of twenty tools, and you will feel the degradation well before you get there. And a long instruction spends the one budget everything shares. Module 1.01 showed the system instruction, the retrieved documents, and every tool result competing for the same context window, and what happens when it overflows: a raw API call over the limit comes back an error, while a framework holding rolling history trims the oldest tokens out of scope without saying so. A prompt that starts long leaves less room for the observations that were the reason for the tools.
Declarative frameworks answer that with two rules.
Narrow the roles. Each sub-agent holds one responsibility, a restricted tool set, and its own instruction. A research agent only searches and cites; an analyst only evaluates numbers; a critic only checks against a schema, the same critic you built in module 2.02.
Declare the coordination. The relationships between agents are configuration data, not application logic. That is what lets you inspect a whole system on one page and change one agent’s model without touching code.
model: line at a time.delegation is function calling
This is the mechanism to carry out of this module, because everything else follows from it.
Go back to module 2.01’s action surface. The model emits a structured request naming a tool and its arguments; your code executes it; the result comes back as text and is appended to the conversation as an observation. That is the whole protocol an agent has for acting on anything outside its own window. Now ask what the framework had to add so that one agent could use another. The answer is: one more executable request. “Run this agent on this task and give me its answer” is registered as a tool, exactly like fetch_order_history, and the sub-agent’s returned text lands in the orchestrator’s window the way a database row would.
Nothing new was invented to make agents talk to each other:
Delegation is function calling: sub-agent delegation runs on the same mechanism a tool call runs on. An orchestrator delegates by invoking a sub-agent exactly as it would an external tool, and reads the returned payload as a tool observation.
Two consequences are worth holding.
The orchestrator chooses by reading description fields. That is the delegation contract from module 2.03, now with a mechanism under it: a description is the tool description of a tool that happens to be an agent, so it is an interface rather than documentation. A vague description misroutes, and the misrouting is painful to debug because every individual agent behaves correctly in isolation.
And the sub-agent runs its own complete loop, its own instruction, its own tools, its own turns, before returning. Its context window opens with its own instruction and the task text the orchestrator wrote, so the orchestrator’s reasoning conditions nothing the specialist generates. That isolation is what buys the independent context window, and it is also why anything the specialist needs must be in the task it was handed.
Watch it happen. The orchestrator reads each specialist’s description, hands off both tasks, and owns only the assembly:
Read the trace back to that mechanism. Each hand-off is a tool call, transfer_to_agent(...), and each specialist’s report comes back as the call’s result. The orchestrator’s closing turn does what a delegating orchestrator’s closing turn is for: it composes, passing one report through and leading the other with the correction. Keep that closing turn in mind. Further down we ask what happens when it has nothing to compose.
the schema, key by key
Every key decides one system boundary. Read them as design decisions rather than settings, and read them on the specimen: the Global Synthesis Council, one orchestrator, one research sub-agent, one tool, whose full file is in the downloads at the end. Its top three keys:
syndicate_name: "Global Synthesis Council"
memory_system: "session-only"
variables:
headline_count: 5
Read that as: this team is named, it keeps transcripts for the length of a session and no longer, and one number is declared once and substituted into any prompt that mentions it. Now each key in turn.
syndicate_name is the root namespace for memory and session logging. It is what keeps one syndicate’s stored facts from being visible to another.
memory_system picks the persistence tier, and module 2.05 builds all three:
internal-only: context resets after each turn. The Council of module 2.03 ran this way.session-only: transcripts persist across a multi-turn session. The Style Council and the specimen above run this way.long-term: conversations are distilled into durable facts in a vector store.
memory_extraction_rules tells the distiller what is worth remembering in your domain. The prompt that turns a transcript into stored facts is shared by every long-term syndicate, so domain judgment, which values go stale on their own and which commitments must never be dropped, is declared here, per syndicate, instead of being edited into the shared prompt. Module 2.05 shows the mechanism this feeds.
variables binds runtime placeholders into prompts at initialization. In the specimen, {{headline_count}} in the researcher’s instruction becomes 5; the framework also supplies values such as {{current_date}}. Small surface, and it prevents a specific failure: an agent that reasons about “today” with no idea what today is.
description is the delegation interface discussed above.
generateContentConfig sets execution parameters per agent: output cap, thinking budget, response format, and — on providers that expose it — the sampling dials. Per agent is the point. Here is the specimen’s orchestrator:
generateContentConfig:
maxOutputTokens: 2048
thinkingConfig:
thinkingBudget: 1024
includeThoughts: true
Read that as: the synthesizing agent is allowed 2,048 output tokens and may spend up to 1,024 tokens thinking before it writes. The researcher underneath it declares neither, because a fetch-and-summarize job needs neither the deliberation nor the room. That is the general move — spend the budget where the judgment happens, not on the retrieval — and one global setting cannot serve both ends of it.
temperature lives in this block too, and the Council of module 2.03 uses it, 0.7 for its specialists and 0.5 for its Moderator, because all three of those agents are local ollama/ models. Current Gemini models take no temperature at all: they are tuned for their own sampling, so on Gemini the per-role dials are the thinking budget and the output cap. Read the provider off the model: prefix before you reach for a knob.
outputSchema forces an agent to return JSON matching an exact shape. This is how module 2.02’s critic turned quality into a number a loop could gate on, and it is what makes an agent’s output safe for code to consume rather than something you have to parse hopefully.
Module 2.02 also handed you a constraint it had found through testing and asked you to take on trust until here. Here is the mechanism. An agent with a schema is promising the engine one exact JSON object as its final answer; an agent with sub-agents is holding tools whose calls the runner has to execute mid-turn. Ask the ADK engine to do both in one agent and it deadlocks: the run hangs with the turn never finishing, the schema side waiting for one final object while the runner waits to execute tool calls mid-turn.
An agent with an output schema is a leaf: an agent holding an
outputSchemacan have no transfer and no sub-agents. Combining a structured output schema with delegation powers deadlocks the underlying ADK engine.
That constraint shapes real designs, so plan for it: the agent that returns structured data is always at the edge of your graph, and the agent that delegates is always above it.
dispatch switches the syndicate’s whole orchestration method, from delegation to plan-dispatch. It is the subject of the next section; its one required key, default_route, names the specialist that answers when routing fails.
yaml_reference mounts an external syndicate file as a sub-agent, which is how graphs nest. A whole team becomes one specialist inside a larger team.
mcp_server_url connects an agent to tools discovered at runtime over a protocol. In module 2.06 you will point an agent at a server it has never seen and watch it pick up that server’s tools.
when the orchestrator only chooses
Watch the delegation loop again, from its last turn backwards. The specialist’s answer arrives as a tool observation, and the orchestrator closes the turn by writing a reply of its own. When the orchestrator is composing, consulting several specialists, reconciling their findings, adding the synthesis only it can see, that closing turn is where its real work happens. The trace above is that case.
But one production shape is simpler than that. Every request goes to exactly one specialist, each specialist’s answer is complete in itself, and the orchestrator’s instruction ends the way the Style Council’s router did in module 2.03: never paraphrase, trim, or edit a specialist’s answer.
Ask what the closing turn is doing in that shape. It is a full model call whose only job is copying text it is forbidden to edit, which is to say a relay. And a relay is still a model turn, so it can still fail. In production, the relay behind a deployed assistant failed twice in two days: one turn finished with zero output tokens and sent the user a blank reply, and the next day it emitted the bare tool name FinancialAnalyst where a 2,599-character answer should have been. The instruction said to return the answer unedited. A prompt can forbid editing; it cannot guarantee copying, because the copying is itself an act of generation.
Melchizedek’s answer is a second orchestration method, switched on in the definition. A syndicate that declares a dispatch: block stops delegating and starts dispatching:
dispatch:
default_route: "Generalist" # the fail-static target: a declared
# specialist that can handle ANY message
orchestrator:
name: "Triage"
instruction: |
Name exactly ONE specialist for this message. ...
outputSchema: # a leaf holding a schema — no sub-agent tools
type: "OBJECT"
properties:
route: { type: "STRING", description: "Exact specialist name" }
reason: { type: "STRING", description: "≤8 plain words for the waiting user" }
required: ["route"]
Read that as: the orchestrator’s whole output is now one JSON object with a required route naming a specialist and an optional reason for the user to read while they wait; and if that object is unusable for any reason, the request goes to Generalist.
The orchestrator now holds no sub-agent tools at all. It is a pure classifier: it reads the message and emits about fifteen tokens of JSON naming one route. Code reads that name, runs the matching specialist directly, and the specialist’s own output is the answer the user receives. There is no relay turn, because there is no turn left for a relay to live in. Notice that this is a different kind of fix from the ones you built in module 2.02, where a failing step was debugged, gated, or retried until it behaved. Here the step that failed no longer exists.
| delegation (the default) | plan-dispatch (dispatch: declared) | |
|---|---|---|
| sub-agents are | tools on the orchestrator | plain definitions code selects from |
| the routing decision is | implicit in which tool the model calls | an explicit value in code |
| the final answer comes from | the orchestrator, re-emitting it | the specialist itself |
| model calls per request | classify + specialist + relay | classify + specialist |
The leaf constraint from the last section is now working for you instead of constraining you. Recall what it said: an agent holding an outputSchema must be a leaf, with no sub-agents and no transfer powers, or the engine deadlocks. The moment the router gained a schema, it lost the ability to hold its specialists as tools, so the hand-off had nowhere left to live except code. That is the right place for it. A routing decision held in code is a value, and everything code can do with a value it can now do with the route: log it, trace it, test it, and stream it to the waiting user as progress (“Routed to Analyst — chart question”). In delegation mode the same decision is visible only as a tool call buried in a trace.
Holding the decision in code is also what makes the failure handling absolute. Walk the failures. The classifier returns malformed JSON; the code cannot read a route, so it uses default_route. It names a specialist that does not exist; the code cannot find it, so default_route. It returns nothing, or errors outright; default_route. Every branch ends at a declared specialist, and that is the rule:
Every routing failure resolves to the default route: whatever goes wrong,
default_routeanswers and the user still gets a reply. Malformed JSON, an unknown specialist name, an empty reply, a classifier that errors outright: all of them dispatch to the one specialist declared able to handle anything. Routing is an optimization; answering is the contract.
You cannot write that guarantee in a prompt, because a prompt only shapes the model’s behavior on the turns where the model behaves. You can write it in a small pure function, and test it offline against every kind of garbage a model could emit. The framework does exactly that; the resolver never returns anything but the name of a declared specialist.
What does that guarantee leave open? The quality of the route. It guarantees an answer, and the answer may come from the Generalist when a specialist would have done better; a misroute that names a real specialist is not a failure the resolver can see at all. That is the same class of error as a misread description in delegation mode, and it is fixed the same way, by the classifier’s instruction and by reading the logged routes.
The method has a cost, and it lands on the systems delegation was giving you for free. In delegation mode the whole exchange is one graph in one session, and the transcript threads itself. In dispatch mode nothing threads itself, and the bookkeeping you have to do by hand is subtler than it first looks.
Start with the obvious half. The classifier’s output is JSON verdicts, and if those verdicts landed in the shared session the next specialist would read them as conversation. So the classifier stays out of it, and every specialist writes into one shared session. Point every route at one transcript and you would expect continuity to follow.
It does not, and the reason is worth more than the fix. An engine decides how to render a stored turn back into a prompt by asking who said it — comparing the turn’s recorded author against the agent running now. In delegation mode that question has one answer all day, because one orchestrator owns the session. In dispatch mode the answer changes every time the route does. So the moment a second specialist takes a turn, the first one’s answer is no longer its speech; it is some other agent’s, and the engine quotes it into the prompt as though the user had said it. Do that to a whole thread and the specialist receives the entire conversation as one undifferentiated block of user text, with nothing in it marked as having been said by the assistant at all.
This was measured, not theorized. A live four-turn thread that had passed through three specialists was replayed through the engine’s own prompt builder, and the specialist answering the fourth turn received fifteen messages, a hundred and eighteen thousand bytes, every single one of them labeled as the user speaking. The history was all there. None of it was a conversation. And two things had been swept in with it: the previous specialist’s private reasoning, which the quoting step did not recognize and so copied through verbatim as user speech, and every raw tool result those specialists had collected, one of them twenty-three thousand characters of JSON, handed to a small conversational agent that had no tools and a four-hundred-character reply budget.
Sharing a session is not sharing a conversation. Continuity across agents needs a stored turn to arrive as a turn: attributed, in the assistant’s voice, without the reasoning and tool traffic that produced it. Storage alone gives you none of that.
The fix is a projection, applied when the session is read rather than when it is written. Each past turn is re-attributed to the agent about to run, so the engine keeps it as assistant speech; the specialist who actually spoke survives as a visible label, so a route can still tell whose answer it is reading; and reasoning, tool calls, and tool results are dropped, because the next specialist cannot re-enter another agent’s tool loop and the answer is the only part that was ever the interface. Writes still go to the real session untouched, which is what module 2.05’s long-term store ingests. The same thread, projected: eight messages, under eight thousand bytes, alternating user and assistant.
The classifier needs the opposite treatment, and getting it wrong has a signature of its own. It must not write to the shared session, yet it must still read it, or it cannot tell a follow-up from an opening remark. Deny it that view and the failure is quiet and specific: a user who pushes back on an answer (“you’re neglecting where the world is going”) is classified as small talk, because in the classifier’s own view nothing precedes the pushback. Its rules about follow-ups are perfectly written and completely unusable. So it receives history as input, a compact digest of the recent exchange with both sides of it, above a marker naming the one message it must classify.
The honest summary: dispatching buys reliability in the hand-off and pays for it in session bookkeeping that delegation never needed. The bill is larger than “keep the classifier’s verdicts out of the transcript”, which is only the part that is easy to see.
One limitation to plan around: plan-dispatch runs only in the served, Agent-to-Agent mode described below. The local CLI runner compiles every syndicate the delegation way, so this is the one mechanism in this module you cannot yet watch on your own machine.
So the choice between the two methods is one question about the orchestrator’s closing turn:
Dispatch routes; delegation composes: dispatch is for choosing a specialist, delegation for combining several. An orchestrator that adds nothing to a specialist’s answer should not be in the reply path at all, and an orchestrator that genuinely composes is not a relay, so delegation remains exactly right for it.
one graph, several providers
The registry from module 1.02, the small piece of code that reads a model string’s prefix and routes to the matching adapter, is what makes the next part possible. Each agent names its own model, so routing is a per-agent decision:
gemini-*→ Google’s APIclaude-*→ the Anthropic adaptergpt-*→ the OpenAI adapterollama/*→ your local machine, no network call
Read that as: the prefix of one string, on one agent, decides where that agent’s arithmetic runs, and no other agent in the file has to agree with it.
So a single syndicate can run an open-weight local model over private records and a cloud frontier model for the synthesis that never touches them. That is exactly the job the registry was built to do: module 1.02’s “run both,” resolved to one line per agent, and the practical answer to “we cannot send this data anywhere.”
serving the graph
Any syndicate can be exposed as a network service. npm run start:a2a serves it as an Agent-to-Agent endpoint: other applications and remote agent networks send it structured requests over ordinary HTTP, and it publishes an Agent Card, a short machine-readable description of the team, so callers can find out what it does before they ask.
equip yourself with the materials
- The reference syndicate definition: global-synthesis-council.md.
- The annotated schema in the repository at
config/agents/syndicateSchema.yaml. - A free Gemini key from aistudio.google.com, then
npm run chat:syndicate.
Here is the whole run of a syndicate on one card, in the vocabulary you now own:
1. LOAD
The framework reads the syndicate definition: orchestrator,
sub-agents, each one's instruction, tools, model, config.
2. WRAP
Each sub-agent becomes a tool on the orchestrator. Its
description is the tool's description.
(dispatch: declared → skip this; sub-agents stay plain
definitions that code will select from)
3. TURN
The orchestrator's model reads its instruction and every
description, and emits tool calls, or an answer.
4. RUN
The runner executes each call. A sub-agent call runs that
agent's whole loop (its own turns, its own tools) and returns
its text as the observation. Every model call goes through the
registry, by prefix.
5. LOOP
Back to 3 while calls continue. When the orchestrator writes
an answer instead, the turn ends.
(dispatch: the classifier's JSON names one route; code runs
that specialist; its output is the answer; any failure →
default_route)
Leaves hold schemas. Delegators hold sub-agents. Never both.
what you can do with this today
Read a system before you run it. A declarative definition means the whole architecture is one file. Make reading it the first thing you do with any syndicate, including your own after a month away, and read it key by key: what does each one decide?
Put the schema on a leaf. Whenever you want structured output, check that the agent producing it has no delegation powers. The deadlock is silent and confusing, and the leaf constraint is easy to plan around once you know it.
Tune hyperparameters per role, not per system. The critic and the drafter want different budgets, and generateContentConfig lets you say so, one agent at a time — thinking budget and output cap on every provider, sampling dials on the ones that still expose them.
Ask what the orchestrator adds. If its reply would be a copy of one specialist’s answer, it is a relay, and a relay is a failure mode waiting for its turn. Declare dispatch: and take it out of the reply path. If it genuinely composes, leave delegation exactly as it is.
Ask where each agent’s arithmetic runs. Read the model: prefixes down the file. Any agent that touches records you cannot send anywhere should carry ollama/, and nothing else in the file has to change for it to.
Next we engineer memory: session state that survives a restart, and long-term recall that turns a conversation into facts an agent can cite weeks later. You will build all three tiers that memory_system names. Open Module 2.05 to continue.