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.

Advertisement

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 root

Two 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.

FieldRead byShould answer
nameThe transfer mechanismWhat is this agent addressed as?
descriptionThe parent’s modelWhen should work be sent here?
instructionThis agent’s own modelHow 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 movesControlData
After it runsSpecialist owns the conversationParent resumes mid-turn
Sees historyYes — inherits the conversationNo — only its arguments
Parent context costHanded off; parent stops payingOne result added to the parent
Tree positionMust be a child (one parent only)Not a child; reusable anywhere
Natural fitTriage, escalation, domain takeoverTranslate, 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.

Advertisement

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-problemCompose it as
Which specialist should own this conversation?Coordinator with sub_agents (transfer)
I need a specialist’s answer and will keep workingAgentTool
Fixed ordered stages passing data alongSequentialAgent + output_key
Independent subtasks, then a mergeParallelAgent with namespaced keys
Refine until a rubric passesLoopAgent + escalation
This is beyond the system’s authorityEscalate with a state summary
The specialist belongs to another team or serviceExpose 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.

Composing agents in ADK comes down to two mechanisms. Transfer — declared with sub_agents — moves control: the specialist inherits the conversation and owns it until it hands back, which is what triage and domain takeover need. AgentTool moves data: the sub-agent runs isolated from the parent’s history, returns one result, and the parent keeps the wheel — the right default for translate, summarize, score, and look-up work, and the only one that reuses cleanly, since an agent instance can have exactly one parent. Around those, use deterministic orchestration wherever the shape is known: Sequential for ordered stages wired by output_key, Parallel for independent work with namespaced keys, Loop with a bounded iteration count and escalation for refine-until-good-enough. Session state is shared across the invocation tree and merged back across an AgentTool boundary, so state keys and description strings are the real interfaces of your system — name them like APIs. Keep the coordinator thin, keep specialist scopes disjoint, bound every loop, and measure routing accuracy and cost per completed task.