AI · Python · Architecture

Google ADK, drawn out: how agent systems actually work

Abstract cover: a coordinator node fanning out to three scouts that meet at a join, a loop back from a budget check, and an approval gate before booking

Give an AI agent one real job: plan a team offsite in Lisbon, six people, under $5,000.

Watch what happens inside. One agent becomes a team. Three scouts search flights, hotels and weather at the same time. A critic checks the numbers and throws the first plan out, because it's $600 over budget; plan two fits. Then, before a single dollar moves, the whole system stops and waits for a human to approve.

One job: an agent fans out to three scouts in parallel, a critic rejects plan one at $5,600 and accepts plan two at $4,700, then the system waits for human approval before booking
One job becomes a team: three scouts search at once, a critic throws out the plan that is $600 over, and nothing is booked until a human approves.

That isn't a chatbot. It's an agent system, and Google's Agent Development Kit (ADK) is a kit for building systems like it. This post walks through ADK from the first agent to a deployed graph: what each piece is, where the data lives, and how it moves. Along the way, Priya, who is organizing that offsite, and her teammate Kenji make the examples concrete.

Hexcursion Studio Premieres soon

Google ADK Explained: Agents, State, Workflows and Guardrails (ADK 2)

--days
--hours
--min
--sec
Prefer video? The walkthrough plays right here the moment it goes live.

How the code was run. Every output below is from a real run of google-adk 2.11.0 on Python 3.12. The only stand-in is the model: a small scripted class (a real ADK BaseLlm subclass) replays fixed answers in place of Gemini, so every run is repeatable and free. The runner, the events, the state, the tools and the workflow engine are all real ADK.

What ADK is

ADK is Google's open-source framework for building agents and teams of agents. It's Apache 2.0 and free. Google announced it at Cloud Next in April 2025 as the framework behind agents in its own products. Today the Python package is at 2.11 (October 2026). TypeScript and Go are also on version 2, and Java and Kotlin are on version 1.

It's built for Gemini but not locked to it. Claude, open models and hundreds of others plug in through a model class or the LiteLLM connector. It runs anywhere Python runs.

The shape: five layers

Before any code, picture five layers:

  1. Your app (a chat UI, an API, a Slack bot) sends the user's message in.
  2. The Runner runs the agent and commits every change to the session as an event.
  3. Agents and workflows decide what happens.
  4. Tools do the real work: your functions, other agents, outside services.
  5. Services (sessions, memory, files) hold everything that has to outlive one request.

Keep that picture in mind; every section below is one of those layers.

ADK as five layers: your app, the Runner, agents and workflows, tools, and services
The five layers of an ADK system, and the one job each layer does.

The agent

An agent is a small object. For a first agent, five fields matter:

trip_desk = Agent(
    name="trip_desk",
    model=model(...),        # Gemini in production
    description="Plans team trips.",
    instruction="You help plan team trips. "
                "Use tools for facts; never guess the weather.",
    tools=[get_weather],
)

The name lets other agents find it. The instruction is its job. The description is what other agents read when they decide who should handle a request, so write it like a job title. There's no hidden prompt chain: the instruction and the tools go to the model on every turn.

Tools: your docstring is a prompt

A tool is a Python function. ADK reads it and builds the description the model sees:

def get_weather(city: str) -> dict:
    """Returns today's forecast for a city.

    Args:
        city: The city name, for example "Lisbon".
    """
    return {"city": city, "forecast": "sunny", "high_c": 24}
schema the model sees: {'name': 'get_weather', 'parameters_json_schema': {'properties': {'city': {'title': 'City', 'type': 'string'}}, 'required': ['city'], ...}}
description: Returns today's forecast for a city.

The function name becomes the tool name, the type hint becomes the parameter type, and the docstring becomes the description. A vague docstring means a model that guesses when to call it.

The model never runs anything itself. Asked about the weather, it returns a request to call get_weather, ADK runs your function, and the model writes the reply. One question left four events in the session:

trip_desk    call get_weather(city='Lisbon')
trip_desk    result get_weather -> {'city': 'Lisbon', 'forecast': 'sunny', 'high_c': 24}
trip_desk    text 'Lisbon looks great: sunny, 24°C. Pack light.'
events stored in the session: 4 | authors: ['user', 'trip_desk', 'trip_desk', 'trip_desk']
One tool call: Priya asks, the model requests get_weather, ADK runs the Python function, the model replies; four events land in the session
The model never runs your code. It asks; ADK runs the function; the model answers. Four events land in the session.

Tools don't have to be your own functions. There are built-ins (Google Search, code execution), whole MCP servers, any REST API from its OpenAPI spec, long-running tools that report back later, and other agents wrapped as tools. A tool that asks for a tool_context gets a window into the session: it can read and write state, save files and search memory.

Events: the part that makes everything else make sense

This is the one to learn first. The Runner starts the agent. The agent works until it has something to report (a tool call, a result, a reply), yields it as an event, and pauses. The Runner commits the event: any state change on it is written to the session right then, and the event goes up to your app. Then the agent resumes, now seeing the committed state.

Yield, commit, resume. That's the whole engine.

The event loop: the agent yields an event and pauses, the Runner commits it to the session and passes it to your app, then the agent resumes
The event loop: yield, commit, resume. State changes are written when the Runner commits the event, not before.

An event carries its author, its content, and its actions: state_delta (the state changes it carries), artifact_delta (files saved), transfer_to_agent, and escalate. The rule that follows: every change in ADK travels on an event. If it's not on an event, it didn't happen.

State: the prefix is the lifetime

A session is one conversation: the list of events, plus state, a small dictionary of facts. State has four scopes, and the key's prefix picks one:

PrefixLives forStored
nonethis sessionyes, if the service persists
user:every session this user opensyes
app:every user of the appyes
temp:this one turnnever
State scopes over time: a plain key lives for one chat, user: follows that user into every chat, app: is shared by every user, temp: lasts one turn and is never saved
The same four keys across three chats: the prefix decides how long each one lives and who can see it.

We had one tool write one key of each kind, then opened a new session for Priya and one for Kenji:

state_delta={'city': 'Lisbon', 'user:diet': 'vegetarian', 'app:currency': 'EUR'}
priya, same session : {'city': 'Lisbon', 'app:currency': 'EUR', 'user:diet': 'vegetarian'}
priya, new session  : {'app:currency': 'EUR', 'user:diet': 'vegetarian'}
kenji, new session  : {'app:currency': 'EUR'}

The temp: key never even made it into the state delta. Priya's new chat lost the city but kept her diet. Kenji got only the app key. Same storage, same code: the prefix did all of it.

State is also how agents pass data. output_key="hotel" saves an agent's final answer into state["hotel"], and {hotel} in another agent's instruction is filled in before its model sees it. Here's what the writer's model actually received:

Write day 1 of the itinerary. Hotel: Hotel Alfama, $140/night, 0.4 km from venue. Diet: vegetarian
Data flow through state: the scout's answer is saved under hotel by output_key, and the writer's instruction reads it with {hotel} before its model sees it
output_key writes an agent's answer into state; in the next instruction reads it back before the model sees it.

The trap. Fetch a session and set its budget directly on the object, and the write is lost:

1) direct write  -> {'budget_usd': 5000}
   via an event -> {'budget_usd': 4500}

Inside ADK you rarely hit this, because tool_context.state and output_key make the event for you. In your own code, always go through an event.

Memory and files

A session is one conversation. Memory is the searchable archive of past ones. While planning the trip, Priya mentioned once: "We're all vegetarian." When the trip is done, the app adds that whole session to the memory service, and a month later a brand new conversation can search it:

a month later, search 'vegetarian': ["We're all vegetarian.", 'Noted: the whole team eats vegetarian.']
Memory across conversations: the trip session is added to the memory service, and a new chat a month later finds what Priya said by searching for vegetarian
A session is one conversation; memory is the searchable archive of all of them.

In development that's keyword matching. In production it's Memory Bank on Google's Agent Platform, which extracts facts and searches by meaning.

Artifacts are files, versioned on every save:

versions of itinerary.md: [0, 1]
latest: # Lisbon v2, 3-star hotel

And whether any of it survives a restart depends on the service. We wrote a session in one process and read it from a brand new one:

process 2, in-memory: session not found
process 2, sqlite   : found 1 event(s), state {'hotel': 'Hotel Alfama', 'user:diet': 'vegetarian'}

In-memory is for your laptop. Anything real needs a database or the managed service. (Good news for local work: adk web now saves sessions to .adk/session.db by default.)

Teams: transfer versus AgentTool

One agent juggling too many tools and jobs starts making mistakes, so you split the work: a coordinator with specialists as sub-agents. The coordinator routes on each specialist's description. Kenji asks whether he needs a visa, and there are two ways to send it to the visa expert:

A) transfer (sub_agents)
  coordinator  call transfer_to_agent(agent_name='visa_expert')
  visa_expert  text 'Kenji needs no visa for 90 days.'
B) AgentTool
  coordinator  call visa_expert(request='Does a Japanese citizen need a visa?')
  coordinator  result visa_expert -> {'result': 'No visa for stays up to 90 days.'}
  coordinator  text 'Good news: Kenji can travel visa-free. Booking continues.'

Transfer is a receptionist putting you through: the specialist takes over the conversation. AgentTool is asking a colleague and answering the customer yourself: control comes back. ADK 2 adds a sub-agent mode: chat keeps talking to the user, task returns on its own when done, single_turn answers once and returns.

Transfer hands the conversation to the visa expert; AgentTool calls the expert like a function and the coordinator keeps control
Transfer hands the conversation away. AgentTool brings the answer back and keeps the coordinator in charge.

Workflows: what ADK 2 changed

ADK 2 has two building blocks: Agent and Workflow. An agent decides its own next step. A workflow follows a graph you draw, where a node is an agent, a tool, or a plain Python function, and each node's output is the next node's input.

Fan-out and join. Three scouts start together and meet at a join, which hands the next node one dict keyed by scout name:

gather = JoinNode(name="gather")
trip = Workflow(name="trip", edges=[
    ("START", flights_scout, gather),
    ("START", hotels_scout, gather),
    ("START", weather_scout, gather),
    (gather, merge),
])
  hotels_scout   done at 0.2s
  weather_scout  done at 0.4s
  flights_scout  done at 0.6s
total wall time: 0.6s (the waits add up to 1.2s) · model calls: 0
Fan-out and join: three scouts start together and finish at 0.2, 0.4 and 0.6 seconds; the join waits for all three and passes one dict on; total 0.6 seconds, zero model calls
Fan-out and join, with the real timings: the three waits overlap, and the join hands on one dict keyed by node name.

Why a join and not shared state? With ADK 1's ParallelAgent, two scouts writing the same key raced, and the slower one silently won. A join keeps each output under its own name.

Routing. A function returns Event(route="CHANGE"), and only that branch runs. Use a model when you need to understand free text; when the input is already structured, like a status or an amount, an if is cheaper and never guesses.

Routing: an agent labels the request CHANGE, a plain router function returns that route, and only change_booking runs
A model labels the request; a plain function routes it. Only the matching branch runs.

Loops are back-edges. The docs warn that a graph cycle isn't bounded automatically, so our budget check also stops after five rounds:

refine@1/writer@1          Draft 1: 4-star hotel, total $5,600
refine@1/budget_check@1    route='REVISE'
refine@1/writer@2          Draft 2: 3-star hotel, total $4,700
refine@1/budget_check@2    route='DONE'
A loop is a back-edge: the writer drafts, budget_check routes REVISE back while over budget and DONE when it fits; rounds are counted because cycles are not bounded automatically
A loop is an edge pointing backwards. Count the rounds: graph cycles are not bounded for you.

Coming from ADK 1? SequentialAgent, ParallelAgent and LoopAgent still run in 2.11, but each prints: "deprecated in favor of Workflow and will be removed in a future version." A sequential agent is a chain of edges, a parallel agent is a fan-out into a join, and a loop agent is a back-edge with a route. One catch from the same warning: a Workflow can't yet be a sub-agent of an LlmAgent, so put the graph at the root.

The whole trip desk as one graph: classify, route, fan out to the scouts, join, loop the writer and budget check, pause for Priya, then book.

trip_desk@1/plan_trip@1/approve@1   PAUSED: Draft 2: 3-star hotel, total $4,700. Approve the $840 hotel deposit?
-- run paused. booked so far: []. Priya taps Approve --
trip_desk@1/plan_trip@1/book@1      Booked. Priya said: Approve
booked: [840] | model calls: 3
The whole trip desk as one graph: classify and route; questions go to the visa expert; plans fan out to three scouts, join, loop between writer and budget check, pause for approval, then book. Three model calls in total
The whole trip desk as one ADK 2 graph. Three model calls; plain functions do everything else.

Three model calls in total. Functions did the routing, the budget math and the fan-out.

Guardrails: return a value to replace the step

Callbacks hook into fixed points: before and after the agent, each model call, and each tool. The rule: return None and ADK carries on; return a value and ADK uses it instead of the real step.

def budget_guard(tool, args, tool_context):
    if tool.name == "book_hotel" and args["total_usd"] > tool_context.state.get("budget_usd", 0):
        return {"error": f"Blocked: ${args['total_usd']} is over the ${tool_context.state['budget_usd']} budget."}
    return None
> My card is 4111 1111 1111 1111, book anything
  trip_desk    text "Please don't paste card numbers here."
> Book Palácio Luxe
  trip_desk    result book_hotel -> {'error': 'Blocked: $6200 is over the $5000 budget.'}
model calls made: 2 | hotels actually booked: []
Callbacks run before and after the agent, each model call and each tool. Return None and ADK carries on; return a value and it replaces the step: a card number never reaches the model, and an over-budget booking never runs
Callbacks wrap every step. Return None to carry on; return a value to replace the step.

The before_model_callback answered the card-number message itself, so the model never saw the number. The before_tool_callback blocked the palace, so the booking function never ran.

Some steps need a person, not a rule. Mark a tool require_confirmation=True and the run pauses until the app sends back an approval; only then does the deposit go through, once (still experimental). Register callbacks once as a plugin to cover every agent, model call and tool. And set RunConfig(max_llm_calls=...): a model that never stopped calling tools was cut off after five calls when we set it to five. The default is 500.

Shipping it

What it costs, and where it bites

ADK is free; you pay for model tokens and hosting. On Agent Runtime the first 50 vCPU-hours a month are free, then about $0.085 per vCPU-hour, and sessions and Memory Bank have been billed since September 2026 (prices as of October 2026). The cost that grows fastest is model calls: every agent node in a loop that runs five times costs five times.

The sharp edges: in-memory sessions vanish on restart, parallel branches writing one key overwrite each other, transfers can bounce between agents, tool confirmation is experimental, built-in code execution can't share an agent with other tools, and state is saved as JSON, so keep it small and plain.

Is it worth using?

Use ADK when you're building more than one agent, when you want sessions, memory, evaluation and deployment from one kit, and especially on Gemini or Google Cloud.

Look elsewhere if your stack is already LangChain (ADK 2's graphs now overlap a lot with LangGraph), if you want to own every checkpoint yourself, or if it's one prompt and one tool. A function will do.

For multi-agent systems that have to ship, ADK is one of the most complete kits there is. Learn the event loop and the state prefixes first; almost every bug we reproduced came from one of them.


Versions: google-adk 2.11.0, Python 3.12, researched 7 October 2026. Sources: the ADK docs (event loop, state, memory, artifacts, graphs, callbacks, plugins, evaluation, deployment), the ADK 2.0 page, the adk-python changelog and source, the launch post, and Agent Platform pricing. Not verified: behavior with a real Gemini model (every run used a scripted model), Agent Runtime deployment, Memory Bank, A2A and live streaming.

All postsReply by email

Keep reading

Next posts