Skip to content
abhinavbhatia
Sheet 02 / 05 — Blogs← Archive

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.

ACME ARCHITECTURE · PART 5 LOCAL FIRST: ADK WEB · AGENT RUNTIME IN PART 11 each agent is its own engine with its own identity Shopper Acme's customers P11.75 GE for CX customer chat, voice, reps P11.75 Legal edition industry skills, connectors P11.75 User Maria in marketing P1 Gemini Enterprise where people use agents P12 Other front doors own app (AG-UI), Chat, Slack P12.5 Plugins + Model Armor · checks around every step P9 Router agent Workflow: extract, route, write P1 Supply agent Databricks specialist P3 Marketing agent BigQuery specialist P2 Code sandbox runs generated code P3.5 Skills versioned in Agent Registry P4 Sessions + Memory Bank what it remembers about each user P8 AGENT GATEWAY · EGRESS (FORWARD PROXY) · P12 Gemini models the reasoning engine P1 Other models Model Garden P13 Knowledge RAG and search P14 BigQuery marketing data P2 Databricks supply data P3 Market watch managed agent, public web P1.75 Agent Identity one per agent P7 Auth manager acts for the user P7 Secret Manager no secrets in code P11 Human approval for the one write P9 Cloud Build CI on every change P11 Eval gate blocks a worse agent P10 Cloud Trace one per question P11 Cloud Logging redacted events P11 Agent Analytics BigQuery plugin P9 IDENTITY AND SAFETY SHIPPING MODELS, KNOWLEDGE, DATA OBSERVABILITY deploy this part earlier parts later parts Pn = the part that adds it

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 Workflow in 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_input and returns an Event.
  • 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 Agent used as a node, which calls a model.
  • Edge. An arrow: a tuple that says what runs next. ("START", a, b) means run a, then b.
  • 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.

NODES: THE STEPS Function node plain Python no model call 0 tokens same input, same output here: route, decline LLM node an Agent one model call or more costs tokens reads and writes language here: extract, writer Remote agent node another agent called over A2A runs elsewhere has its own tools here: marketing, supply JoinNode a barrier waits for every branch merges their outputs then passes them on here: merge EDGES: THE ORDER ("START", a, b) run a, then b (route, {"x": b, "y": c}) run b or c, by the route value ("START", (b, c), join) run b and c in parallel, then join A Workflow is itself a node, so a branch can be a small graph of its own.

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:

A model chooses coordinator Agent + sub_agents model calls to route 1 or more per question tokens to route grow with every agent added same question, same route? usually how you test it evals, many runs why it chose ask the model Code chooses route() function node model calls to route 0 tokens to route 0 same question, same route? always how you test it unit tests, one run why it chose a reason string in state Routing is a decision. It must give the same result every time, so it belongs in code.

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:

START front code reply code extract model Flash route code 0 tokens marketing remote agent supply remote agent both a small Workflow mkt sup join decline code, no model writer model, Pro + code check commands then a dict edge calls a model your code another agent (Part 6) Two model calls in the router per question. Every decision between them is code.

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

  1. 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.
  2. extract (model, Flash) rewrites the question as one line that makes sense on its own.
  3. route (code) decides who answers: marketing, supply, both or neither.
  4. The specialists answer. They are remote agents reached over A2A, which Part 6 covers.
  5. 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 EDGES, FROM AGENT.PY 1 from google.adk import Workflow 2 from google.adk.workflow import START 3 4 root_agent = Workflow( 5 name="acb_demo_router", 6 edges=[ 7 (START, front), 8 (front, {"continue": extract, "reply": reply}), 9 (extract, route), 10 (route, { 11 "marketing": marketing_remote, 12 "supply": supply_remote, 13 "both": both, 14 "decline": decline, 15 }), 16 (marketing_remote, writer), 17 (supply_remote, writer), 18 (both, writer), 19 ], 20 ) 1 1 Start here START is the entry point. front handles commands. 2 2 Dict edge front returns a route value: "continue" or "reply". 3 3 The decision route returns marketing, supply, both or decline. 4 4 A graph in a graph both is a Workflow that calls the two in parallel. 5 5 One writer Every data path ends at the same checked writer.

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. route returns a route value and ADK runs only the node that matches it.
  • both is itself a Workflow, 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=str means the node returns one string, which the next node receives as node_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_for works out which team the user belongs to. For Maria this is marketing, so marketing data is local to her.
  • classify applies the ownership rule and returns a decision made up of a label, a target and a reason, with no model involved.
  • route=d.target is the value the dict edge looks at, and ADK runs the node whose key matches.
  • state= writes values into session state. The writer reads question later, 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:

LABEL WHAT IT MEANS GOES TO local the home team reads its own table home team federated home team reads the other team's table, no copy home team cannot no source anyone may read; explain why home team delegate only the owner may compute it (e.g. penalties) the other team mixed some numbers local, some delegated both off-topic no business words, nothing to follow up decline (code) The labels come from the ownership rule in Part 1. Same question, same team: same destination.

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 one START row per branch; the tuple form is part of ADK’s graph syntax in the version used here.
  • JoinNode is 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.
ONE AFTER THE OTHER marketing supply slow FAN-OUT + JOINNODE marketing supply join faster time Bar lengths are illustrative. The join waits for the slower branch, not the sum of both.

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:

Findings in session state marketing_findings supply_findings writer model, Pro drafts the answer after_model code pull every number find each one all found send it any missing replace it the evidence, read again by code EXAMPLE draft: "... EUR 51,331 in penalties" 51,331 is in supply's findings: sent draft: "... about EUR 52,000" 52,000 is in no finding: replaced If code can check it, do not pay a second model to judge it.

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:

WRITER.PY, SIMPLIFIED 1 def writer_after_model(callback_context, llm_response): 2 content = llm_response.content 3 if not content or not content.parts or llm_response.partial: 4 return None 5 st = callback_context.state 6 text = "".join(p.text for p in content.parts 7 if p.text and not p.thought) 8 evidence = [str(st[k]) for k in FINDINGS_KEYS if st.get(k)] 9 missing = ungrounded_numbers(text, evidence, st.get("question", "")) 10 if missing: 11 st["temp:fact_check_failed"] = missing 12 text = ("I couldn't verify every figure against the data, " 13 "so I won't guess. Here is what the specialists " 14 "reported:\n\n" + "\n\n".join(evidence)) 15 content.parts = [types.Part(text=text)] 16 return llm_response 1 1 Final text only Skip partial chunks and empty responses. 2 2 The evidence What the specialists actually returned. 3 3 Every number Each figure must match a finding or the question. 4 4 Replace, do not patch No guessing: show what the data said instead. 5 5 Return the response A returned value replaces what the model wrote.

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_numbers pulls out every figure in the answer, such as 51,331, 87.4% or EUR 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 None keeps 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, then writer. The route was chosen without a model call.
  • Change the question to “How much did we spend on Acme Pure?” Only get_spend runs.
  • 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 route returns 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 a DEFAULT_ROUTE branch (from google.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 JoinNode collects 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

Opinions are my own. Every fact in this post comes from the public documentation linked above. Acme Consumer Brands and its data are fictional.