Where the seams go, and what moves across them
The architecture question is never whether to decompose — it is where the seams go and who decides the routing. A seam is right when the two sides want different tools, instructions, or permissions, and when the payload between them is small and nameable rather than the whole conversation. Billing and shipping are a real seam; step one and step two of one paragraph of reasoning are not.
Routing then splits in two. Judgment calls — ‘refund, shipping complaint, or scam attempt?’ — want a model deciding, and transfer exists for that. Everything else is structure: ‘after research, write; then review; retry until the validator passes.’ Burning a model call to rediscover a fixed pipeline on every request is waste.
The second axis is control versus data, and nearly every multi-agent bug is those two confused. Transfer moves control, so the specialist owns the conversation and talks to the user. AgentTool moves data, so the parent delegates, gets a result, and keeps reasoning — the user never meets the sub-agent.
The tree: sub_agents, one parent, one name
Every ADK agent derives from BaseAgent, which is why any agent can be a child of any other. You build the hierarchy by passing sub_agents, and the framework wires the back-pointer for you.
from google.adk.agents import LlmAgent
billing = LlmAgent(
name="billing", model="gemini-2.0-flash",
description="Refunds, invoices, payment failures, subscription charges.",
instruction="Resolve the billing issue. Do not discuss shipping.",
tools=[lookup_invoice, issue_refund],
)
shipping = LlmAgent(
name="shipping", model="gemini-2.0-flash",
description="Order tracking, delivery dates, lost parcels.",
tools=[track_order],
)
root = LlmAgent(
name="support_root", model="gemini-2.0-flash",
instruction="Route the user to the right specialist.",
sub_agents=[billing, shipping],
)
assert billing.parent_agent is rootTwo structural rules bite early. Names must be unique across the tree, because transfer addresses agents by name — two agents called reviewer in different branches is an ambiguity you do not want to debug. And an agent instance has exactly one parent: appending the same object to two parents’ sub_agents is not reuse, it is a conflict. To put the same specialist in two places, build it from a factory so each tree gets its own instance — or wrap it as an AgentTool, which composes without claiming parentage at all.
LLM transfer: what a handoff actually does
Declaring sub_agents on an LlmAgent does something specific: the framework makes a transfer function available to that agent’s model and injects the names and descriptions of its transfer targets into the system instruction. When the model calls it — transfer_to_agent(agent_name="billing") — the framework records the transfer as an action on the emitted event and re-roots the invocation at the named agent, which produces the actual reply.
The important word is sticky. A transfer is not a one-shot detour: the specialist now owns the conversation, and subsequent user turns go to it, not the root, until it transfers back up or sideways. That is what makes it a handoff rather than a call. It also inherits the conversation — it sees the history that got the user here, which is what you want for a genuine takeover and what you do not want for a one-off subtask.
The tree is bidirectional by default: a child may transfer back to its parent and across to its peers. For a one-way door — a leaf that must always return to its coordinator rather than bounce the user sideways — LlmAgent exposes flags to disallow transferring to the parent and to peers. Constrain the graph deliberately; an unconstrained mesh of mutually-transferring agents is where ping-pong comes from.
Descriptions are the routing API
Here is the part teams consistently underinvest in. A routing model does not read your specialists’ instructions — it reads their description fields. Those short strings are the routing interface, and routing accuracy is mostly a function of how well they are written.
The distinction to internalize: instruction is for the agent itself (how to behave once it has the conversation); description is for whoever is choosing among agents (when to send work here). ‘Handles billing’ is a coin flip. ‘Refunds, invoices, payment failures, and subscription charges; not shipping or account access’ is routable, because it states both the positive scope and the boundary.
| Field | Read by | Should answer |
|---|---|---|
name | The transfer mechanism | What is this agent addressed as? |
description | The parent’s model | When should work be sent here? |
instruction | This agent’s own model | How should it behave once it has the work? |
A practical test: show a colleague only the names and descriptions, read them ten real user messages, and see how they route. If a human cannot route reliably from the descriptions alone, neither will the model — and the fix is a text edit, not a bigger model.
AgentTool: delegation that keeps the wheel
The other composition move wraps an agent so a parent can call it like a function: the parent’s model emits a normal function call, the wrapped agent runs to completion in a nested invocation, and its final answer comes back as the function response. Control never leaves the parent.
from google.adk.tools.agent_tool import AgentTool
translator = LlmAgent(
name="translator", model="gemini-2.0-flash",
description="Translate text into a requested target language.",
instruction="Translate faithfully. Return only the translation.",
)
reply_writer = LlmAgent(
name="reply_writer", model="gemini-2.0-flash",
instruction="Draft the reply; call the translator tool if needed.",
tools=[AgentTool(agent=translator)],
)Two properties make this the right default for most delegation. Context isolation: the wrapped agent does not inherit the parent’s conversation. It sees the argument it was passed, works in its own nested invocation, and returns one result — so its reasoning and tool chatter never bloat the parent’s context window. Reusability: an AgentTool does not become anyone’s child, so the same specialist can be a tool of three parents without tripping the one-parent rule.
The costs are real too. Each call is a full nested agent run — its own model calls and latency, inline in the parent’s turn — and the parent will usually paraphrase the returned text rather than pass it through verbatim, which matters if the output was carefully formatted.
Transfer or AgentTool — deciding in one question
The two look similar in a diagram and behave nothing alike at runtime. The deciding question is: after this sub-agent finishes, who should be talking to the user? If the answer is ‘the sub-agent, for a while’, transfer. If it is ‘the parent, immediately’, AgentTool.
LLM transfer (sub_agents) | AgentTool | |
|---|---|---|
| What moves | Control | Data |
| After it runs | Specialist owns the conversation | Parent resumes mid-turn |
| Sees history | Yes — inherits the conversation | No — only its arguments |
| Parent context cost | Handed off; parent stops paying | One result added to the parent |
| Tree position | Must be a child (one parent only) | Not a child; reusable anywhere |
| Natural fit | Triage, escalation, domain takeover | Translate, summarize, score, look up |
A useful heuristic: if the sub-agent needs to ask the user a follow-up question, it needs the conversation, so it needs transfer. If it can do its job from one well-formed argument, make it a tool. The two are not exclusive — an agent can be a transfer target in one tree and an AgentTool in another, which is how a critic gets reused across a content pipeline and a support bot with no duplication.
The coordinator/dispatcher pattern
The most common ADK topology is a thin coordinator: a root LlmAgent with a short instruction, no tools of its own, and a set of well-described specialists as sub_agents. Its whole job is to decide where a request belongs and get out of the way — and because it carries almost no instruction text and no tool list, it routes well and stays cheap.
The trade-offs are worth naming. You pay an extra model call on the first turn of every conversation, producing no user-visible content. You inherit a misroute rate, which is a number to measure, not a theoretical concern. And you need a fallback — a general-purpose agent or a clarifying question — because a router with no default will pick the least-wrong specialist and confidently answer the wrong question.
Two rules keep coordinators healthy. Keep the root thin: the moment it grows tools and business logic it stops being a router and becomes the monolith you decomposed to avoid. And keep specialist scopes disjoint — overlapping charters are the largest single source of misroutes, and the fix is usually to merge two specialists or sharpen both descriptions with an explicit ‘not this’ clause.
Pipelines and fan-out: state keys are the interface
When the order is known in advance, encode it. A SequentialAgent runs its children strictly in order, each seeing the session as its predecessor left it. The wiring between stages is output_key: an agent declared with output_key="research_notes" has its final response written into session state under that key, and a downstream agent templates it into its own instruction.
from google.adk.agents import SequentialAgent
researcher = LlmAgent(
name="researcher", model="gemini-2.0-flash",
instruction="Research the topic; list key findings with sources.",
tools=[web_search], output_key="research_notes",
)
writer = LlmAgent(
name="writer", model="gemini-2.0-flash",
instruction="Write a 400-word brief from these notes:\n\n{research_notes}",
output_key="draft",
)
pipeline = SequentialAgent(name="brief", sub_agents=[researcher, writer])The writer is guaranteed that research_notes exists, because the sequence enforces the dependency — no synchronization code, no ‘did the previous step finish?’ check. Note what the coupling really is: the two agents never reference each other, only a shared key. Those key names are the interfaces of your system — treat renaming one like changing a function signature.
ParallelAgent runs its children concurrently and returns when all finish, so the stage costs the slowest child rather than the sum — the one primitive that reduces latency. Each child runs in its own branch, so events stay distinguishable, but they share one session state. Hence the hardest rule here: parallel siblings must never write the same state key. Namespace them (findings_a, findings_b) and follow the fan-out with a merge stage. Two children sharing an output_key is a race that passes your tests before it fails in production. And if child B needs child A’s output, they were never parallel.
Shared versus isolated: what each agent can actually see
‘Do these agents share memory?’ has different answers depending on how they were composed, and knowing which is which prevents a lot of confusion.
Within one invocation tree — workflow agents and their children, and agents reached by transfer — session.state is one shared dictionary. Everyone reads and writes the same keys, which is what makes output_key pipelines work at all. Conversation history is shared too on a transfer: the specialist inherits the events that preceded it. Across an AgentTool boundary the picture changes: the wrapped agent runs as a nested invocation seeded from the parent’s state, does not receive the parent’s conversation, and its state writes are merged back when it returns. Isolation of context, continuity of state.
Two levers refine this. State key prefixes scope how long a value lives: an unprefixed key is session-scoped, user: spans that user’s sessions, app: is global, and temp: is unpersisted scratch — right for intermediates a pipeline needs but nobody should read tomorrow. On the context side, an LlmAgent can be configured to skip conversation contents entirely, turning it into a pure state-in/state-out function: the cheapest, most testable node in any tree.