Production-ready agents, Part 5: The router, let code choose the path
Users talk to one agent. This part explains ADK graph workflows from the ground up and builds the router as one: a Flash model tidies up the question, plain Python chooses the specialist, both specialists can run in parallel, and code checks every figure the writer model quotes.
We now have two well-built specialists. The marketing agent reads BigQuery, and the supply agent reads Databricks as the user who asked. Each has narrow tools, guards enforced in code and its own skill.
But people at Acme Consumer Brands should not have to pick an agent before they ask a question. Maria in marketing types “What did those stockouts cost us?” She does not know, and should not need to know, that penalties belong to the supply team. She needs one agent that sends the question to the right specialist and gives her back a single, clear answer.
That agent is the router. It is the only agent users see, and the only one we publish to Gemini Enterprise in Part 12. This part builds it around the design rule from Part 1:
Models read and write language. Code makes the decisions.
If you prefer to watch first, here is the heart of this part as a short animated explainer.
Video: how to build an agent router where code, not a model, chooses the path, in three minutes. The narration is a synthetic voice; the script and animation are mine.
Where are we in the architecture?
This part turns the first agent into the router: a graph Workflow that sends each question to the right specialist. Part 1.25 walks through the whole picture, layer by layer.
where Part 5 sits in the Acme architecture: highlighted boxes are the subject of this part, solid boxes come from earlier parts, faded boxes arrive later
What is a graph workflow?
A graph workflow is a flowchart you write in code, and ADK walks it for you: the arrows are fixed by you, not chosen by a model. Some boxes call a model, most do not.
Start with something you already know: a restaurant ticket. A waiter writes the order, the ticket goes to the pass, and a simple rule on the wall says starters to the cold station, mains to the grill. If a table orders both, the two stations cook at the same time and the pass waits until both plates are ready before anything leaves the kitchen. Nobody asks a chef to decide, each night, where tickets should go.
Each part of that kitchen has a name in ADK. The rule on the wall is an edge, each station is a node, the station name written on the ticket is the route, and the pass waiting for both plates is a JoinNode. A common misreading is that “workflow” means a model plans the steps as it goes; in an ADK graph the steps and arrows are fixed in code, and a model only fills in the boxes you give it.
The router is an ADK graph workflow. In plain words, it is a flowchart that ADK runs for you: boxes for the steps and arrows for what comes next. These are the terms you need:
- Graph workflow. A
Workflowin ADK 2.0: an agent defined as a directed graph (steps joined by one-way arrows). ADK runs the steps in the order the arrows set out. - Node. One step, one box in the flowchart. It receives the previous step’s output as
node_inputand returns anEvent. - Function node. A Python function used as a node. It never calls a model, so it uses no tokens and always gives the same result.
- LLM node. An ADK
Agentused as a node, which calls a model. - Edge. An arrow: a tuple that says what runs next.
("START", a, b)means runa, thenb. - Route. A value a node sets on its
Event, such as"supply". A dict edge after that node maps each value to the next node. - JoinNode. A node that waits for several branches running in parallel to finish, then passes all their outputs on together.
How does this apply to Acme? The ticket is Maria’s question, the rule on the wall is the routing function from Part 1, and the two stations are the marketing and supply specialists. Here are those terms on one real question. Maria has just asked about the Acme Pure Refill Launch, and now types “What did those stockouts cost us?”:
front (function node) not a command, so it returns Event(route="continue")
extract (LLM node) node_input is her raw text; it returns one clear line:
"What did the Acme Pure Refill Launch stockouts in Western Europe cost us?"
route (function node) node_input is that line; it returns
Event(route="supply", state={"route_label": "delegate", ...})
dict edge {"marketing": ..., "supply": supply_remote, "both": ...}
only the "supply" key matches, so only supply_remote runs
writer (LLM node) turns supply's findings into the answer
Compare a command: if she types /whoami, front returns route="reply", the reply node answers,
and the turn ends there with no model call at all.
the parts of an ADK graph workflow: function nodes run your code, LLM nodes call a model, remote agent nodes call another agent, and a JoinNode waits for parallel branches; edges connect them
ADK Python 2.0 has been generally available (GA), meaning fully supported for production, since 19 May 2026, and graph workflows are its main new feature. The router in this series runs on ADK Python 2.9 (as of October 2026, the latest release is 2.12).
Why not let a model choose the route?
Because routing is a rule, not a judgement: a rule written in code is free, gives the same answer every time and can be tested, while a model choosing the route costs a call and can change its mind.
Think of a post office. Every letter carries a postcode, and the sorting office sends it to a bin by reading that postcode against a fixed table. It would work, after a fashion, to hand each letter to a clerk and ask “where do you think this should go?”, but you would pay the clerk for every letter, two clerks might disagree, and when a letter went astray nobody could point to the rule that sent it. The table is cheaper, faster and auditable, and the clerk is kept for letters whose address is unreadable.
How does this apply to Acme? The letter is the question, the postcode is the metric it asks about, and the sorting table is the ownership rule from Part 1. The clerk is a coordinator model.
There is a simpler way to build a router: give a coordinator agent the two specialists as
sub_agents and let the model decide which one gets the question. ADK supports this pattern, and it is
fine for a demo. For this system it is the wrong choice:
letting a model choose the route versus letting code choose it: a model costs a call on every question and can choose differently on different days; a Python function costs nothing, always gives the same answer and can be unit tested
- It adds a model call to every question. The coordinator reads the question and every specialist’s description before deciding, so each new specialist makes every question more expensive.
- It is not deterministic. The same question can go to marketing on Monday and to supply on Tuesday. That will be rare, but a rule such as “only supply may compute penalties” needs a guarantee.
- It is hard to test. To check a model’s routing you have to run many questions many times and measure the result. A function can be checked with a single unit test.
- It is hard to audit. When an auditor asks why a question went to supply, “the model decided” is not an acceptable answer.
Here is the single unit test, for the question that must always go to supply:
d = classify("What did the Acme Pure stockouts in Western Europe cost us?", home="marketing")
assert d.label == "delegate" and d.target == "supply"
It runs in milliseconds with no model, and gives the same result every time. The decision’s reason is
saved in state as route_reason, so when an auditor asks why that question went to supply, the trace
holds a sentence written by code, not a guess about what a model was thinking.
My view: routing is a business decision. Part 1 wrote the ownership rule in Python, and the router calls that function directly.
The trap I see most often is treating the route as a language problem because the input is language. The reading is a language problem, which is why a model tidies the question first (Step 1). The decision is not. When you are choosing how to route, I would ask these questions in order:
1. Can a lookup or an if-statement on known names decide it? -> code, as here
2. Can a small model turn messy text into those known names? -> model reads, code decides (this router)
3. Is the choice open-ended, with no rule anyone could write? -> only then let a model choose
The rule reads its facts from the semantic registry, the shared file that lists each metric with its owner. These two entries are why OTIF can be read by marketing but a question about penalties always goes to supply:
otif_pct:
label: OTIF
owner: supply
published_formula: true # supply shares how to compute it
service_penalty_eur:
label: service penalties
owner: supply
objects: [] # no table anyone else can reach
published_formula: false # the owner's agent is the only way in
Part 1 explains the four cases of the ownership rule and the Python function that applies them.
What does the router look like?
Five steps, and only two of them call a model: one to understand the question and one to write the answer. Everything in between, including who answers, is plain code. In the kitchen picture, the waiter (extract) and the plating (writer) need skill; the rule on the wall (route) does not.
The diagram shows the whole router, following one question that needs both teams:
the router as a graph: front and route are code, extract and writer call a model; a question that needs both teams goes down the both branch, splits into two parallel calls, joins, and reaches the writer
- front (code) deals with requests that need no model: commands such as
/whoami, feedback clicks, “how did you get that?” and questions that have nothing to do with the business. These go to reply and the turn ends. - extract (model, Flash) rewrites the question as one line that makes sense on its own.
- route (code) decides who answers: marketing, supply, both or neither.
- The specialists answer. They are remote agents reached over A2A, which Part 6 covers.
- writer (model, Pro) turns the findings into one answer. Code checks every figure in it before the user sees it.
So each question costs two model calls, one to understand the question and one to write the answer. Every step in between is plain code.
In code, the graph is a list of edges:
the router's real Workflow: each edge is a tuple, a dict after a node maps its route value to the next node, and all three data paths end at the same writer
- Each tuple is an edge.
(extract, route)means run route after extract. - A dict after a node creates a branch.
routereturns a route value and ADK runs only the node that matches it. bothis itself aWorkflow, because a graph can sit inside a larger graph as a node.- All three data paths end at the same
writer, so every answer goes through the same check.
Step 1: how does extract tidy up the question?
Extract turns a question that only makes sense mid-conversation into one line that makes sense on its own, so that code can read it. It is the one place where understanding language really matters.
A simple example first. A regular walks into a café and says “the usual, but make it a large”. The barista knows the usual is a flat white, so they write “large flat white” on the cup. The person at the machine never needs to know about the conversation; the cup tells them everything. The barista rewrote the order, but did not make the coffee, choose the beans or take payment.
How does this apply to Acme? The regular’s words are Maria’s question, the barista is the extract node, and the cup is the self-contained line that the route function reads.
People rarely write questions that make sense on their own. They write “What did those stockouts cost us?”, where those points back to the campaign in the previous answer. A routing function cannot work out what those means, but a model can, so the first model call does exactly one job:
extract = Agent(
name="extract",
model=Gemini(model=MODEL), # a Flash model: fast and cheap
instruction=(
"Rewrite the user's latest question as ONE self-contained line, resolving "
"'those', 'it', 'that campaign' from the conversation. Keep every number, "
"brand, campaign, region, week and measure word exactly. "
"Do not answer it. Output only the rewritten question."
),
output_schema=str, # one string back, nothing else
generate_content_config=types.GenerateContentConfig(
thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW),
),
)
This is the real node, shortened. The full instruction also handles regions the user has asked the agent to remember, which Part 8 covers.
- One job only. The node rewrites the question. It does not answer, choose a team or fetch data. A narrow job suits a small model, so this node uses a Flash model with a low thinking budget (how much reasoning the model does before it replies).
- Keep every important word. The routing rule matches brands, regions and measures, so the rewrite must not turn “Western Europe” into “Europe”.
- A fixed output.
output_schema=strmeans the node returns one string, which the next node receives asnode_input.
So after a question about unserved demand, “What did those stockouts cost us?” becomes “What did the Acme Pure Refill Launch stockouts in Western Europe cost us?”, which code can read.
Just as the barista who writes “coffee” instead of “large flat white” breaks the order, a rewrite that drops a word breaks the route. That is why the instruction insists on keeping every brand, region and measure word exactly, and why a wrong route is often worth tracing back to the rewrite before blaming the routing rule.
Step 2: how does route decide, with no tokens at all?
Route is an ordinary Python function: same question and same team in, same destination out, with no model call. It is the post office’s sorting table from earlier, written as code.
Back at the sorting office, each letter is handled in three moves: read the postcode, look it up in the
table, and drop the letter in a bin with a note of why (“SW1 goes to London West”). The route node
makes the same three moves: read the tidied question, apply the ownership rule, and return a destination
plus a reason.
How does this apply to Acme? The route node is a function node. It calls the same routing rule
from Part 1 and puts the result on the event:
def route(ctx, node_input: str):
"""Deterministic: same question + same home team -> same route. Zero model tokens."""
home = home_team_for(ctx.session.user_id, ctx.state)
d = classify(node_input, home=home, previous=ctx.state.get("last_plan"))
return Event(
output=node_input,
route=d.target, # "marketing" | "supply" | "both"
state={
"question": node_input,
"route_label": d.label, # local | federated | delegate | mixed | ...
"route_reason": d.reason, # why, in words, for the trace
"last_plan": d.plan, # lets "those stockouts" work next turn
},
)
This is shortened from the real node, which also sends off-topic questions to decline.
home_team_forworks out which team the user belongs to. For Maria this is marketing, so marketing data is local to her.classifyapplies the ownership rule and returns a decision made up of a label, a target and a reason, with no model involved.route=d.targetis the value the dict edge looks at, and ADK runs the node whose key matches.state=writes values into session state. The writer readsquestionlater, and the trace in Part 11 reads the label and the reason.
The label is how the ownership rule classifies the question, and the target is where that sends it:
how the routing rule turns a label into a destination: local, federated and cannot stay with the user's home team, delegate goes to the other team, mixed goes to both, and off-topic questions are declined without a model call
Maria’s follow-up asks about penalties. Penalties belong to supply and supply has not published the formula, so the label is delegate and the target is supply. If she asks “Compare the Acme Pure Refill Launch spend with what the stockouts cost us”, spend is local and penalties are delegated, so the label is mixed and the target is both.
One question for each of the other labels, again from Maria on the marketing team:
- “What was Verde’s CTR in Western Europe last month?” CTR is a marketing measure: local, target marketing.
- “Which campaigns drove demand we couldn’t serve?” Unserved demand belongs to supply, but supply published the formula, so marketing can read the shared table itself: federated, target marketing.
- “What was Acme Pure’s brand sentiment?” No team publishes that measure: cannot. The answer says so, and no specialist is called.
- “Write me a poem about Verde.” Not about the data at all: off-topic, so it is declined without a model call.
Step 3: how do both teams answer at once?
When a question needs both teams, the router asks them at the same time and waits for the slower one, not for the two added together.
Cooking is the easiest way to see it. Pasta takes 10 minutes and the sauce takes 8 (illustrative numbers). Cook them one after the other and dinner takes 18 minutes; put both on the hob at once and it takes 10, because you only wait for the slower pot. You still cannot plate until both are done, so someone has to wait at the end for both.
In ADK terms, putting both pots on at once is a fan-out and waiting to plate is the JoinNode. The one condition is that the two jobs must not depend on each other: you cannot start the sauce if it needs the pasta water.
How does this apply to Acme? When a question needs both teams, there is no reason to wait for one
before calling the other, because they query different systems. So the both branch calls them in
parallel:
both = Workflow(
name="both",
edges=[
(
START,
(make_marketing("marketing_remote_b"), make_supply("supply_remote_b")),
JoinNode(name="merge"),
)
],
)
- A tuple of nodes is a fan-out. A fan-out sends work to several steps at the same time.
(marketing, supply)inside the edge means run both at once. ADK’s documentation writes the same pattern as oneSTARTrow per branch; the tuple form is part of ADK’s graph syntax in the version used here. JoinNodeis the fan-in. The fan-in collects the results. It waits until every branch has finished, then passes their outputs on together, keyed by node name.- Separate nodes. The branch builds its own pair of remote agent nodes with their own names, so the trace shows which path ran.
why the both branch runs in parallel: one after the other, the user waits for the sum of the two specialists; with a fan-out and a JoinNode, the user waits only for the slower one
So the user waits only as long as the slower specialist, instead of the two added together. If marketing took 4 seconds and supply took 6 (again illustrative), Maria would wait about 6 seconds rather than 10. That waiting time is the latency Part 11 measures, and Part 6.5 sets this fan-out and gather pattern beside the other multi-agent topologies.
Step 4: how do we stop the writer making up a number?
Tell the writer to copy figures exactly, then have code check every figure anyway; if one cannot be found in the findings, the whole answer is replaced. The instruction makes mistakes rare; the check makes them impossible to show.
Picture a newspaper. A reporter writes a story from their notebook, and before it is printed a sub-editor reads it with the notebook open beside them. Every number in the story must appear in the notes. If the story says “12,000 fans” and the notes say “11,850”, the sub-editor does not quietly fix it; they pull the story and print the plain facts instead. The reporter is good, but usually right is not the standard for print.
The reporter is the writer model, the notebook is the specialists’ findings, and the sub-editor is an after-model callback. The common mistake is thinking a firmer instruction (“NEVER invent numbers”) is enough; it lowers the error rate but cannot bring it to zero.
How does this apply to Acme? The last model call writes the answer. It uses a Pro model, because
a good answer has to weigh two teams’ findings and explain what they mean for the business. Its
instruction pulls the findings straight
from session state, with lines such as Marketing findings: {marketing_findings?}. ADK fills in each
{key?} placeholder (a gap in the text) from state; the ? means leave it empty if the key is
missing, so a question that only went to marketing has empty supply findings. That only holds because
the route step resets both keys: its Event writes "marketing_findings": "" and
"supply_findings": "" into state on every question, so findings from the previous question can never
leak into this answer.
To see why the reset matters, take two questions in a row. The first, “Compare the Acme Pure Refill
Launch spend with what the stockouts cost us”, goes to both teams and leaves
supply_findings holding “… EUR 51,331 in penalties”. The second, “What was Verde’s CTR last
month?”, goes to marketing only. Without the reset, the writer’s instruction for the second answer
would still read “Supply findings: … EUR 51,331 in penalties”, and the writer might mention a
penalty nobody asked about. With the reset, it reads “Supply findings: “, empty. The Try it in
Part 6.5 shows this exact leak. The rules in the
instruction are strict: lead with the answer, copy every figure exactly as the findings give it, and never
add, re-round or work out a new number. This is the start of the real instruction:
instruction=(
"Write the final answer to the user's question using ONLY these findings.\n"
"Question: {question?}\n"
"Marketing findings: {marketing_findings?}\n"
"Supply findings: {supply_findings?}\n\n"
"Rules: lead with the answer, then the 'so what'. Keep every figure exactly as the findings "
"state it; never add, round differently or compute a new number. ..."
),
The model follows never compute a new number most of the time. Most of the time is not good enough when the number is a penalty that a retailer will invoice, so code does the check:
the fact-check: the writer drafts an answer from the findings; code in the after-model callback pulls out every number and looks for it in the findings; if one is missing, the whole answer is replaced before the user sees it
An after-model callback runs straight after the model replies and before ADK processes the reply (we met callbacks in Part 3). This one pulls every number out of the writer’s text and looks for each one in the specialists’ findings:
the writer's after-model callback, simplified: skip anything that is not the final text, collect the findings, check every number, and replace the whole answer if one is not found
- Final reply only. Streaming (sending the answer in pieces as it is written) produces partial chunks first, so the check waits for the final reply.
- The evidence is the data the specialists returned, kept in session state.
ungrounded_numberspulls out every figure in the answer, such as51,331,87.4%orEUR 35.6k, and looks for a match in the findings or the question. It allows for the small differences that rounding for display introduces, and it handles percentages stored as fractions. It ignores small whole numbers and years.- Replace the answer, do not patch it. If even one number cannot be matched, the whole answer is replaced with “I couldn’t verify every figure” followed by the raw findings. The user gets a less polished reply, but never a made-up figure.
- Return the reply. Returning a value from an after-model callback replaces the model’s output;
returning
Nonekeeps it.
The real callback also attaches the answer card and catches its own errors, so a bug in the check cannot break an answer.
This is a principle worth taking with you: if code can check something, do not pay a second model to
judge it. An LLM judge (a second model asked to grade the first one’s answer) adds a model call per
answer and is sometimes wrong. A number check costs nothing in model calls and is never wrong about
whether 52,000 appears in the findings.
The same order of questions as for routing applies when you decide how to check an answer:
1. Can code check it (a number, a date, an ID, a format)? -> code, as here
2. Can code narrow it (does the answer cite only real rows)? -> code first, model for the rest
3. Is it a matter of judgement (tone, helpfulness)? -> only then an LLM judge, as in Part 10
Try it
Build a small router with two stand-in specialists, routing in code and a checked writer. Function nodes play the specialists and return made-up figures:
import asyncio
import re
from google.adk import Agent, Event, Workflow
from google.adk.apps import App
from google.adk.models.llm_response import LlmResponse
from google.adk.runners import InMemoryRunner
from google.adk.workflow import JoinNode
from google.genai import types
def get_spend(node_input: str):
text = "Acme Pure spend: EUR 283,590"
return Event(output=text, state={"spend_findings": text})
def get_stock(node_input: str):
text = "Acme Pure unserved demand: 15,011 units"
return Event(output=text, state={"stock_findings": text})
def route(node_input: str):
"""Plain Python. No model, no tokens, same answer every time."""
q = node_input.lower()
spend = "spend" in q
stock = "stock" in q or "demand" in q
target = "both" if spend and stock else ("stock" if stock else "spend")
return Event(output=node_input, route=target, state={"question": node_input})
NUMBER = re.compile(r"\d[\d,.]*\d|\d")
def check_numbers(callback_context, llm_response):
"""after_model_callback: every number in the answer must appear in the findings."""
if not llm_response.content or not llm_response.content.parts:
return None
state = callback_context.state
text = "".join(p.text or "" for p in llm_response.content.parts)
findings = " ".join(state.get(k, "") for k in ("spend_findings", "stock_findings"))
allowed = set(NUMBER.findall(findings + " " + state.get("question", "")))
if set(NUMBER.findall(text)) <= allowed:
return None # every number found: keep the answer
return LlmResponse(content=types.Content(role="model", parts=[
types.Part(text="I could not verify every figure. The data says: " + findings)]))
extract = Agent(
name="extract",
model="gemini-flash-latest",
instruction="Rewrite the user's question as one clear line. Keep every brand and "
"number. Do not answer it.",
)
writer = Agent(
name="writer",
model="gemini-flash-latest",
instruction=(
"Answer using ONLY these findings. Copy every figure exactly.\n"
"Question: {question?}\nSpend: {spend_findings?}\nStock: {stock_findings?}"
),
after_model_callback=check_numbers,
)
both = Workflow(name="both", edges=[("START", (get_spend, get_stock), JoinNode(name="merge"))])
root_agent = Workflow(
name="toy_router",
edges=[
("START", extract, route),
(route, {"spend": get_spend, "stock": get_stock, "both": both}),
(get_spend, writer),
(get_stock, writer),
(both, writer),
],
)
app = App(name="toy_router", root_agent=root_agent)
async def main():
runner = InMemoryRunner(app=app)
await runner.run_debug("How much did we spend on Acme Pure, and how much demand did we miss?")
asyncio.run(main())
Install ADK 2 (pip install "google-adk>=2.0") and set up Gemini access as described in Part 1. Then:
- Run it. The events show
extract, then both specialists, thenwriter. The route was chosen without a model call. - Change the question to “How much did we spend on Acme Pure?” Only
get_spendruns. - Change the writer’s instruction to “Round every figure to the nearest thousand.” The answer now says 284,000, the check finds no matching number, and the replacement message comes back instead.
What can go wrong
- Giving the extract model more to do. Once it starts answering or choosing a team, a model is making decisions again. Keep it to rewriting, with a one-line output.
- An extract step that changes words. If the rewrite turns “Western Europe” into “Europe”, the route changes. Tell the model to keep names exactly and test it with realistic follow-up questions.
- A route value with no matching edge. If
routereturns a value that has no key in the dict edge, ADK logs a warning and the branch stops, so the user gets no answer. Add aDEFAULT_ROUTEbranch (fromgoogle.adk.workflow) as a fallback and unit-test every value. - Checking numbers with an LLM judge instead of code. A second model asked “is this grounded?” adds a model call and can be wrong. Keep LLM judges for things code cannot check, such as tone; Part 10 covers how to calibrate one.
- Patching an answer that failed the check. Removing the one wrong number can leave a misleading sentence. Replace the whole answer.
- Putting the check on the wrong callback. An after-agent callback runs too late to change the answer. ADK’s documentation says to use an after-model callback to change a model’s reply.
What code did we write in this part?
Every part ends with the same map of Acme’s build: which files exist so far, and the code this part added, explained line by line. The map grows with each part.
This part built the router as an ADK graph workflow: front handles commands in code, extract (a Flash model) rewrites the question as one clear line, route (code, no tokens) applies the ownership rule, the specialists answer, alone or in parallel, and the writer (a Pro model) turns their findings into one answer. An after-model callback on the writer checks every number against the findings and replaces the answer if any figure cannot be matched.
Where does it live?
router/ the router agent: extract, route, write
+ app/agent.py P5 the graph: front, extract, route, both, decline, edges
+ app/writer.py P5 writer agent and its after-model number check
marketing/ the marketing specialist, on BigQuery
app/agent.py P1,2,3,4 marketing agent with its skill in a SkillToolset
app/skills/marketing-kpis/SKILL.md P4 marketing know-how: which tool, definitions, story checks
app/skills/marketing-kpis/references/tables.md P4 tables, grain, measures and the join rule, read on demand
app/tools.py P1,2 three narrow tools: measures, unserved demand, SQL fallback
tests/unit/test_marketing.py P2 unit tests for the tools, with a fake context and database
supply/ the supply specialist, on Databricks
app/agent.py P3 the supply Agent, Databricks specialist, behind sql_guard
app/skills/supply-catalog/SKILL.md P4 supply know-how, given only to the supply agent
app/tools.py P3 supply tools such as query_supply_measures, run as the user
shared/ code all three services share
adk_common.py P3 sql_guard callback and structured, redacted _log
engines.py P3 run_bigquery and run_databricks: guard again, caps, as the user
+ factcheck.py P5 ungrounded_numbers: finds figures not in the findings
registry.py P2 reads semantic.yaml: measure names, computable measures
routing.py P1 classify: picks the specialist in code, no model
semantic.yaml P1,2 registry of measures, owners and published formulas
skill_source.py P4 loads a skill from disk or a pinned registry revision
sqlguard.py P3 guard_sql read-only allow-list; redact for log lines
+ added in this part ~ changed in this part Pn = the parts that touched the file
The code, line by line
router/app/agent.py: the whole router as a list of edges: each tuple says what runs next.
from google.adk import Workflow
from google.adk.workflow import START
root_agent = Workflow(
name="acb_demo_router",
edges=[
(START, front),
(front, {"continue": extract, "reply": reply}),
(extract, route),
(route, {
"marketing": marketing_remote,
"supply": supply_remote,
"both": both,
"decline": decline,
}),
(marketing_remote, writer),
(supply_remote, writer),
(both, writer),
],
)
(START, front),: START is the entry point; front runs first and deals with commands such as /whoami without any model call.(front, {"continue": extract, "reply": reply}),: a dict edge: front returns a route value, and ADK runs only the node whose key matches it.(extract, route),: after extract has tidied up the question, route decides who answers it."both": both,: both is itself a small Workflow that calls marketing and supply in parallel; a graph can sit inside a graph."decline": decline,: off-topic questions go to a code node that declines politely, with no model call.(both, writer),: all three data paths end at the same writer, so every answer goes through the same number check.
router/app/agent.py: the route node, a plain function that decides who answers with no model and no tokens.
def route(ctx, node_input: str):
"""Deterministic: same question + same home team -> same route. Zero model tokens."""
home = home_team_for(ctx.session.user_id, ctx.state)
d = classify(node_input, home=home, previous=ctx.state.get("last_plan"))
return Event(
output=node_input,
route=d.target, # "marketing" | "supply" | "both"
state={
"question": node_input,
"route_label": d.label, # local | federated | delegate | mixed | ...
"route_reason": d.reason, # why, in words, for the trace
"last_plan": d.plan, # lets "those stockouts" work next turn
},
)
home = home_team_for(ctx.session.user_id, ctx.state): works out which team the user belongs to; for Maria it is marketing, so marketing data is local to her.d = classify(node_input, home=home, previous=ctx.state.get("last_plan")): applies the ownership rule from Part 1 and returns a label, a target and a reason, the same every time for the same question.route=d.target,: the value the dict edge looks at, so a question about penalties always goes to supply."route_reason": d.reason,: saves why the question went where it did, so the trace holds a sentence written by code for an auditor."last_plan": d.plan,: remembers this turn’s plan, so a follow-up such as “those stockouts” can be routed next turn.
router/app/agent.py: the first model call, which only rewrites the question, and the branch that asks both teams at once.
extract = Agent(
name="extract",
model=Gemini(model=MODEL), # a Flash model: fast and cheap
instruction=(
"Rewrite the user's latest question as ONE self-contained line, resolving "
"'those', 'it', 'that campaign' from the conversation. Keep every number, "
"brand, campaign, region, week and measure word exactly. "
"Do not answer it. Output only the rewritten question."
),
output_schema=str, # one string back, nothing else
...
)
both = Workflow(
name="both",
edges=[
(
START,
(make_marketing("marketing_remote_b"), make_supply("supply_remote_b")),
JoinNode(name="merge"),
)
],
)
model=Gemini(model=MODEL), # a Flash model: fast and cheap: a narrow job suits a small, cheap model.Keep every number, " "brand, campaign, region, week and measure word exactly.: the routing rule matches these words, so the rewrite must not turn “Western Europe” into “Europe”.output_schema=str,: the node returns exactly one string, which route then receives as node_input.(make_marketing("marketing_remote_b"), make_supply("supply_remote_b")),: a tuple of nodes is a fan-out: both specialists run at the same time, each with its own name in the trace.JoinNode(name="merge"),: the fan-in: waits for both branches, then passes their outputs on together, so Maria waits only for the slower one.
router/app/writer.py: the writer’s after-model callback, simplified: code checks every figure before the user sees it.
def writer_after_model(callback_context, llm_response):
content = llm_response.content
if not content or not content.parts or llm_response.partial:
return None
st = callback_context.state
text = "".join(p.text for p in content.parts
if p.text and not p.thought)
evidence = [str(st[k]) for k in FINDINGS_KEYS if st.get(k)]
missing = ungrounded_numbers(text, evidence, st.get("question", ""))
if missing:
st["temp:fact_check_failed"] = missing
text = ("I couldn't verify every figure against the data, "
"so I won't guess. Here is what the specialists "
"reported:\n\n" + "\n\n".join(evidence))
content.parts = [types.Part(text=text)]
return llm_response
if not content or not content.parts or llm_response.partial:: skips empty replies and the partial chunks of a streamed answer, so only the final text is checked.evidence = [str(st[k]) for k in FINDINGS_KEYS if st.get(k)]: collects what the specialists actually returned, kept in session state.missing = ungrounded_numbers(text, evidence, st.get("question", "")): pulls out every figure in the draft and looks for each one in the findings or the question, allowing for display rounding.text = ("I couldn't verify every figure against the data, ": if even one number is missing, the whole answer is replaced by the raw findings: less polished, but never a made-up figure.return llm_response: returning a value from an after-model callback replaces what the model wrote; returning None keeps it.
The router reaches the specialists through make_marketing and make_supply, remote agent handles that Part 6 builds over A2A.
What did we learn?
- The router is the only agent users see, and it is built as an ADK graph workflow: nodes for the steps and edges for what runs next.
- Code chooses the route, because a function costs no tokens, gives the same answer every time and can be unit tested and audited.
- Each question costs two model calls: a Flash model rewrites the question so it stands on its own, and a Pro model writes the answer.
- When a question needs both teams, a fan-out runs both specialists in parallel and a
JoinNodecollects their results, so the user waits only for the slower one. - An after-model callback checks every number in the writer’s answer against the specialists’ findings and replaces the whole answer if one cannot be matched.
- If code can check something, there is no need to pay a second model to judge it.
What comes next?
The router now picks the right specialist, but the specialists run somewhere else as separate services.
Part 6: Agents talking to agents
connects them. It covers the A2A (Agent2Agent) protocol, the agent card each specialist publishes,
the RemoteA2aAgent the router uses to call them, and how the user’s identity travels in request metadata
without ever entering a prompt.
Sources
- ADK: graph-based agent workflows
- ADK: build graph routes for workflow agents
- ADK: data handling for agent workflows
- Welcome to ADK 2.0 (GA dates for each language)
- ADK: agent collaboration modes and coordinator agents
- ADK: LLM agents (output_schema, output_key)
- ADK: state, including
{key?}placeholders - ADK: types of callbacks
- ADK Python source: the workflow graph syntax
Opinions are my own. Every fact in this post comes from the public documentation linked above. Acme Consumer Brands and its data are fictional.