· 14 min read

Google's ADK Is a Runtime, Not a Graph: Notes From Eleven Agents

Disclaimer: All opinions expressed in this post are my own and do not represent the views or positions of my employer. I work at Google; this is written from implementation, not advocacy. Where ADK is awkward I will say so.


I built eleven agents on Google’s Agent Development Kit over the last month — a hotel concierge, a deep-research travel planner, a voice support desk on Gemini Live, a computer-use food-delivery rep driving a real Chromium browser, a beauty advisor with persistent memory, a fintech HITL payout desk that suspends a turn for human approval, two federated agents talking to each other over HTTP, an eval harness, an MCP-only knowledge desk, and a live video card scanner. They share a single browser portal so I could watch them side by side. The whole bundle is open source.

What I expected to learn was how ADK compares as a framework — Python ergonomics, decorator quality, the usual. What I actually learned was that ADK is not really competing in the framework category. The right comparison for ADK is to a server runtime: it has opinions about events, state, and transport, and those opinions are what your code is shaped around. LangChain gives you composable pieces. LangGraph gives you a state machine. CrewAI gives you a metaphor. ADK gives you a runtime contract — and once you have built two or three agents that share that contract, the value is hard to give back.

This post is about that contract: what it looks like in practice, where it pays off, where it does not, and the heuristics I would use the next time I have to choose.


The contract: events in, events out, state in the middle

The shape of every ADK agent is the same. You declare a tree — an LlmAgent with tools, or a SequentialAgent containing a ParallelAgent, or a LoopAgent wrapping a critic — and you hand it to a Runner. The runner exposes one primary surface, an async generator of events:

async for event in runner.run_async(
    user_id=USER_ID, session_id=session.id, new_message=msg
):
    ...

For live, voice, and video, you swap run_async for run_live and feed a LiveRequestQueue instead of a new_message. The events keep coming.

Every event is a google.genai.Event carrying parts (text, function calls, function responses, inline audio, inline image), plus side-channel signals: partial, turn_complete, interrupted, usage_metadata, actions. The runner is what you write your server around. Tools mutate tool_context.state; sub-agents read it via output_key; sessions persist it; the Runner decides what to emit and when.

That is the entire model. There is no graph DSL, no chain object, no “executor”. Composition happens by containing one agent inside another:

root_agent = SequentialAgent(
    name="travel_planner",
    sub_agents=[planner, parallel_researchers, composer],
)

parallel_researchers = ParallelAgent(
    name="parallel_researchers",
    sub_agents=[flight_researcher, hotel_researcher, activity_researcher],
)

Three sub-agents fan out concurrently, each writes its brief to state under its own output_key, the composer reads all three and emits an itinerary. No edges, no transitions, no conditional routers. The contract is “state is the message bus, and the parent agent decides who gets to write next.”

When you first see it, this looks anaemic compared to a LangGraph diagram. The longer I spent in it, the more I think the absence is the point.

The reframe: state is the contract, not the call graph

Every agent framework I have used previously eventually forced me to write the same thing — a typed dictionary of partial results, smuggled between nodes via the framework’s preferred argument-passing convention. LangChain made me build it manually. LangGraph turned it into a first-class state object but kept the graph as the orchestration unit. CrewAI hid it inside crew context.

ADK takes the opposite bet. It treats tool_context.state (and its longer-lived sibling, the Session) as the only contract between agents, and it demotes the orchestration shape to a thin tree. There are no direct calls between sub-agents. The flight researcher does not “return” to the composer. It writes flights_brief to state and stops. The composer reads state on its turn.

This sounds like a stylistic choice. It is actually a transport choice. The moment you have a sub-agent in another process — A2A federation, an MCP server, a long-running tool waiting on a human — you are no longer making function calls. You are sending and receiving events, with state as the durable record between them. ADK’s contract is the same shape as the wire. Frameworks that orchestrate by call graph have to translate themselves into that shape under load. ADK does not.

The clearest example in the cookbook is the HITL payout desk. The agent drafts a payout, hits the ₹50,000 threshold, and calls a tool wrapped in LongRunningFunctionTool:

tools=[
    lookup_vendor,
    draft_payout,
    LongRunningFunctionTool(func=request_approval),
    check_approval,
    post_payout,
    generate_voucher,
    ...
]

request_approval returns a pending handle and the runner stops the turn. The session sits idle. A human clicks Approve in a separate browser, the portal hits POST /approve/{session} on the server, the server writes the decision into session state via append_event with a state_delta, and on the next user turn the agent’s check_approval tool reads it back. The agent then calls post_payout and generate_voucher. From the agent’s perspective, the human approver was just slow.

A graph framework can model this — every framework can — but the cost is that the graph leaks across the suspend boundary. You end up writing a “resume” node and a polling node and a state-shaped retry. ADK absorbs the suspend into the same contract everything else uses. The agent emits a function call. Some time later, a function response shows up. The runner resumes. There is no second machinery.

Live is the test case

The clearest place ADK’s runtime bias pays off is run_live. Bidirectional streaming over Gemini Live is unforgiving — twin coroutines pumping in and out of a LiveRequestQueue, audio chunked at 20ms, interruption events fired by the model when the user barges in over the agent’s reply, audio streams that have to be drained when that happens, sessions that resume across socket reconnects, context windows that compress when a long support call spans a hundred thousand tokens.

Here is the entire wiring on the server side of the payments voice agent:

queue = LiveRequestQueue()
await asyncio.gather(
    _forward_browser_to_model(ws, queue),
    _forward_model_to_browser(ws, session.id, queue),
    return_exceptions=True,
)

Two coroutines. One reads JSON from the browser WebSocket, decodes PCM16-at-16kHz, and pushes blobs into the queue. The other consumes runner.run_live(...) and forwards parts to the browser:

for part in (event.content.parts if event.content else []):
    if part.inline_data and part.inline_data.data:
        await ws.send_json({
            "kind": "audio",
            "data": base64.b64encode(part.inline_data.data).decode(),
        })
    if part.text:
        await ws.send_json({"kind": "transcript", "data": part.text})
    if part.function_call:
        await ws.send_json({"kind": "tool_call", "name": part.function_call.name, ...})
if getattr(event, "interrupted", False):
    await ws.send_json({"kind": "interrupted"})

Every signal you need to drive a real voice UI is on the event — the audio bytes, the transcript, the tool call mid-turn, the interruption flag, the turn-complete marker, the usage metadata. The runtime does not lecture you about what to do with them. It hands them over and gets out of the way.

The hard parts of voice are still hard. I had to fix four of them on the browser side, and the bugs were instructive. An audio worklet downsampling routine that dropped sample zero on every tick. A scheduled audio buffer that kept playing under the next reply because nothing was draining the AudioBufferSourceNode queue when the model fired interrupted. A playheadRef that went stale across reconnects because the new AudioContext started its clock at zero and the old playhead did not. A mic worklet posting at 375 messages per second because I had not coalesced the 128-sample render quantum into 20ms chunks before sending. Every one of those was a client-side bug, and every fix was small. The runtime side needed almost nothing.

The one server-side fix I will call out, because it is the kind of bug a graph framework hides from you: when the browser-to-model coroutine raises on a closed socket, the model-to-browser coroutine can hang forever inside run_live’s generator if the queue is not closed. The fix is one line in a finally block, idempotent on both sides:

finally:
    queue.close()

You can only write that line if the runtime exposes the queue as a first-class object. ADK does. LangGraph does not (it is hiding a different machinery), and that is fine for many workloads — but voice is not one of them.

The primitives that earned their keep

A short list, in the order they showed up in the cookbook, of the primitives I would not give back:

1. output_key and state as the message bus. The travel planner is a SequentialAgent containing a ParallelAgent. Each researcher writes a named brief into state. The composer reads all three. There is no plumbing. This is the part that scaled past one agent without effort.

2. LongRunningFunctionTool. The runner suspends on the function call and resumes on the function response — the same contract a normal tool uses, just stretched across human time. This is what makes HITL feel like a slow tool, not a state machine.

3. ParallelAgent for fan-out. The deep-research pipeline has three researchers running concurrently. Concurrency is declared by container, not by asyncio.gather in your tool code. That separation matters when you want to add a fourth researcher.

4. MCPToolset. The knowledge desk has zero hand-written tools. It points MCPToolset at the official @modelcontextprotocol/server-filesystem binary, scopes the root to the cookbook’s docs/ directory, allows read-only tools, and that is the agent. Swap the binary for @playwright/mcp or a Slack MCP server and you have a different agent. The constructor is identical.

5. Artifacts. The payout desk renders a PDF voucher to tool_context.save_artifact, the runner emits an artifact_delta on the event, the server picks it up and serves it from /artifact/{session}/{filename}. There is no separate file-handling contract. Artifacts are a kind of event.

6. The introspect surface. Every agent in the cookbook ships /introspect — a JSON dump of the agent tree, the tools, the model, and the planner. The portal renders it as a live diagram. This was a one-page helper, not a feature, and it is the reason the agents debug themselves.

7. Session resumption + context compression. A flaky network does not restart a Live call. A long support narration does not blow out the window. The two RunConfig knobs that turn this on are five lines combined.

A2A is anticlimactic, in the right way

The two-process loan desk in the cookbook is the example I expected to learn the most from. The loan officer runs on port 8007, the credit bureau on port 8017, and the officer’s request_credit_report tool calls the bureau over HTTP via httpx. Each side has its own /health, /metrics, /introspect, /session, and /chat/{session_id}.

What I learned is that A2A federation is not a primitive you reach for. It is what you get for free when both sides happen to be ADK agents. There is no wire protocol you need to read up on, no handshake to debug. The bureau is a FastAPI server with one extra endpoint (POST /score) that the officer’s tool posts to. The “federation” is two HTTP servers and an agreed JSON shape.

This is the right outcome. Multi-agent federation, in the wild, has to work across vendors and frameworks. It cannot be a special protocol that only works when both sides bought into the same framework. Treating A2A as “you have already shipped two agents — now make one call the other” is correct, even if it makes for a less impressive diagram.

Where ADK is awkward

Three places, in decreasing order of severity.

Metrics on streaming. ADK’s events are clean, but the usage_metadata they carry is cumulative over the turn, not delta — and on long-running streaming turns it shows up on partial events too. If you naively sum prompt_token_count across events, you will overcount by an order of magnitude. The fix is to gate on event.partial == False before recording usage, and to differentiate input/cached/tool-use (which Gemini sends as running totals) from output/thinking (which arrive in deltas). This is one of those documentation-level facts that you only learn by writing a metrics ribbon and watching the numbers go absurd.

Tokens-per-second is non-trivial. On a streaming turn, TPS is output_tokens / (turn_complete_at - first_token_at). On a non-streaming turn — like a SequentialAgent whose final composer event arrives in one shot — first_token_at ≈ turn_complete_at, the denominator is microseconds, and you get nonsense rates. Mine was hitting impossibly high TPS in a live demo before I added a 50ms threshold and a fallback to total turn duration. Trivial to fix once you see it.

Tool docstrings are the tool description. This is correct API design and also a footgun. The model reads the docstring as the tool spec. A lazily written docstring becomes a lazily described tool. A tool that takes amount_inr: float with no docstring will be selected for “send money” queries with cheerfully wrong unit assumptions. ADK does not tell you this because Python conventions imply it. The model has no way to know your docstring is a placeholder. Lint accordingly.

The first two are runtime artifacts. The third is a discipline problem you inherit when the framework respects Python idioms. I will take all three.

Where ADK fits, and where I would still reach for something else

The shape of the project matters more than the team’s framework preferences.

Reach for ADK when:

  • You expect to ship more than one agent. The contract is what compounds across them. A single-agent prototype does not exercise it.
  • You have a Live workload — voice, video, mid-turn tool calls, barge-in. Nothing else I have used handles this with as little ceremony.
  • You want HITL or long-running operations as first-class. The LongRunningFunctionTool + session state pattern is genuinely small.
  • You want to wire up MCP servers without writing custom tool adapters.
  • Your team is already on Vertex / Gemini for other reasons. The alignment is real — gemini-3.1-flash-lite-preview for tool-heavy hops and gemini-3-flash-preview for orchestrators is a tier you can actually reason about cost-wise.

Reach for something else when:

  • The agent is fundamentally a pipeline of deterministic transformations with one LLM step in the middle. A graph framework, or even plain Python, is a better fit. Do not over-frame.
  • You need a UI-rendering DSL more than a runtime — that is what Vercel’s AI SDK is for, and ADK has no opinion on the front end.
  • You are committed to OpenAI or Anthropic and unwilling to use Gemini for the orchestration tier. ADK is model-pluggable in principle, but the Live story and the cost story both lean Gemini.

These are not deep claims. They are the rules I have applied twice in the last month and not regretted.

The heuristics I would use again

A short list, derived from getting eleven of these out the door.

Compose by container, not by callback. Sub-agents talk through state. If you find yourself wiring up a callback between two LlmAgents, you have invented a new contract. Use output_key and let the parent SequentialAgent decide who runs next.

Make every agent ship /introspect and /metrics from day one. Not because you need them, but because they are the surface a debugger and a demo both need. The thirty lines you write once will save you days.

Keep the session boundary clean. The tool_context.state is the wire. Anything you put there is what your sub-agents can see. Anything you do not put there does not exist.

For Live, treat the queue as the load-bearing object. Open it, close it in finally, and remember that the model-to-browser side dying first is just as common as the browser-to-model side. Gather both, return exceptions, idempotent close.

Gate metrics on event.partial == False. Cumulative counters on streaming events are the single most common metrics bug you will write. Save yourself.

Pick a flash-tier model for the leaves and reserve pro-tier for the composer. The travel planner runs three concurrent researchers on gemini-3.1-flash-lite-preview and one composer on gemini-3-flash-preview. The latency and cost shape is dramatically better than running everything at the same tier, and the quality is indistinguishable on the leaves.


The part of ADK I had not anticipated, and that has stayed with me, is how much of agent engineering is not about the model. It is about events, state, transport, and where the suspend boundaries fall. The frameworks I used previously made me responsible for those boundaries while pretending I was responsible for a graph. ADK reverses that. It hands you the runtime and lets the graph emerge from how you compose.

Whether that bet ages well will depend on how Live, A2A, and MCP evolve. Those three are the bets ADK is built around, and they are the ones I would watch. The shape of the runtime, though, looks durable. It is the same shape the wire has, and that is rarely the wrong shape to write your code in.


The eleven agents are open source at github.com/vmishra/Google-ADK-Cookbook. The browser portal renders all of them side by side, with live metrics, a trace-a-request animation, and an introspect-driven architecture diagram. Pull requests welcome — keep the editorial voice.