A single ADK agent degrades in measurable, boring ways: instruction-following accuracy falls as the instruction grows, tool selection gets worse as the tool list grows, and every turn re-sends the whole overgrown context so cost climbs while quality drops. Decomposition fixes all three at once — a billing agent with six tools and a one-paragraph charter picks tools nearly perfectly and can be tested like a unit. But decomposition immediately raises a harder question: once you have several agents, how do they compose? ADK gives two genuinely different answers — transfer, which moves control to a specialist, and AgentTool, which borrows an answer and keeps control — layered over a hierarchy, a shared session state, and a set of deterministic orchestration primitives. This article is that composition layer: how the tree is wired, what a handoff actually does, which state each agent can see, and which topology to reach for when.
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.
Loops, escalation, and the exit door
LoopAgent repeats its children until a maximum iteration count is hit or a child signals escalation. That pairing is the point: the structure guarantees termination while the model supplies the judgment about when the work is good enough. The canonical body is a critic and a reviser — the critic scores the draft against a rubric and escalates when it passes.
from google.adk.agents import LoopAgent
from google.adk.tools import ToolContext
def mark_approved(tool_context: ToolContext) -> dict:
"""Call this only when the draft satisfies every rubric item."""
tool_context.actions.escalate = True
return {"status": "approved"}
refine = LoopAgent(name="refine", max_iterations=3,
sub_agents=[critic, reviser])Escalation is also the idiom for the boundary of the system. An agent that cannot help — low confidence, a policy-restricted request, a refund above its limit — escalates, bubbling control up to the enclosing agent or out to a human handoff path. Write a summary into state before escalating, so whoever picks the task up inherits the context instead of starting cold. And always set max_iterations: an unbounded refinement loop is the most expensive bug in multi-agent systems, because it fails as a cost curve rather than an exception.
How agent teams actually fail
The failure modes are recognizable enough to check for by name.
Transfer ping-pong. Two peers each decide the request belongs to the other and the conversation bounces. Cause: overlapping descriptions plus an unconstrained mesh. Fix: disjoint scopes, explicit ‘not this’ clauses, and disallowing peer transfer so misroutes go back to the coordinator instead of sideways. The captured conversation. A specialist reached by transfer never hands back, so a user who has moved on is still talking to the billing agent. Fix: tell every specialist, in its instruction, when to transfer back — and ask whether it should have been an AgentTool.
The switchboard root. A coordinator that relays every specialist message word for word means you used AgentTool where you wanted transfer; it doubles cost and latency for nothing. Key collisions. Two agents written months apart both write result; namespace keys by producer. Over-decomposition. Nine agents where three would do — every hop is a model call, so cost and latency scale with the tree while quality does not. The corrective habit is measuring routing accuracy and cost per completed task, because a wrong architecture shows up as a cost curve long before it shows up as a complaint.
Choosing a topology
The decisions above collapse into a short table you apply once per node of the tree: ask ‘what is the shape of this sub-problem?’ and drop in the cheapest composition that expresses it.
| The shape of the sub-problem | Compose it as |
|---|---|
| Which specialist should own this conversation? | Coordinator with sub_agents (transfer) |
| I need a specialist’s answer and will keep working | AgentTool |
| Fixed ordered stages passing data along | SequentialAgent + output_key |
| Independent subtasks, then a merge | ParallelAgent with namespaced keys |
| Refine until a rubric passes | LoopAgent + escalation |
| This is beyond the system’s authority | Escalate with a state summary |
| The specialist belongs to another team or service | Expose it remotely (see the A2A article) |
The systems that hold up in production are mostly coded structure with small LlmAgents at the leaves: deterministic pipelines wherever the shape is known, model judgment confined to the decisions that need it, and every seam documented by a state key or a description string. Test them the way they are built — assert the trajectory, not just the final answer: that the fan-out ran three branches, that the loop exited by escalation inside two iterations, that no draft reached the publish gate without a passing critique.