Google ADK Workflows: From ReAct Loops to Graph Execution
How one ADK agent's think-act-observe loop generalizes into Sequential, Parallel and Loop agents, session state, and ADK 2.0 graph workflows.
Published on • October 16, 2026
AI Assistant

Every Google ADK project starts the same way: you define one Agent with an instruction and a few tools, and the runtime drives it through a think-act-observe cycle until the model stops asking for tools. That loop is flexible, but it gives you no fixed order, no hard budget, and no easy way to explain to a reviewer what happened. The moment you need “fetch first, then summarize, always”, you move one step up the ladder: sub-agents, then template workflows, then an explicit graph. This post walks that ladder with runnable ADK code and ends with a decision guide for when the declarative structure is worth the lost flexibility.
The loop you start with
ADK’s runtime is built around an event loop between a Runner and your execution logic. The agent runs until it has something to report, yields an Event, pauses while the Runner commits that event through the SessionService, then resumes on the committed state. In practice a tool-using LlmAgent produces this sequence:
- The model returns a
FunctionCall, the agent yields it as an event and pauses. - The
Runnerrecords it, the agent resumes and executes the tool. - The agent yields a
FunctionResponseevent carrying the result (and anystate_deltathe tool produced). - The model is called again with the observation, and the cycle repeats until a text response is produced and marked as the final response.
That is ReAct, expressed as infrastructure rather than a prompt. A minimal agent looks like this:
from google.adk import Agent
def lookup_inventory(sku: str) -> dict:
"""Return stock for one SKU."""
return {"sku": sku, "in_stock": 42}
root_agent = Agent(
name="ops_agent",
model="gemini-2.5-flash",
instruction="Answer inventory questions using the tool. Be brief.",
tools=[lookup_inventory],
)
Run it with adk run <folder> or adk web <folder> and you get the full loop for free. What you do not get is any guarantee about how many loops you get: the model decides when to call tools and when to stop, and every extra round trip costs tokens and latency.
Session state is the dataflow medium
Before you compose multiple agents, you need the channel they pass data through. Inside a Session, state is a serializable key-value scratchpad, and its key prefixes decide scope:
- no prefix: current session only
user:: shared across all sessions of that user within the appapp:: shared across every user of the apptemp:: scoped to one invocation, discarded when it ends
There are two sanctioned ways to write to it. The easy one is output_key, which stores an agent’s final text response into a state key:
researcher = Agent(
name="researcher",
model="gemini-2.5-flash",
instruction="Summarize the topic in two sentences.",
output_key="research_summary",
)
The standard one is EventActions.state_delta, applied when the event carrying it is appended. Instructions can read state back with {key} templating ({research_summary}, or {topic?} when the key may be missing), so the next agent’s prompt is assembled from the previous agent’s output instead of from chat history.
One rule matters more than the rest: update state through the context object so the change is tracked as an event delta. Mutating a Session object you fetched from the SessionService bypasses event tracking and can silently lose data.
Template workflows: fixed control flow around agents
ADK’s template workflows are agents whose only job is to decide when sub-agents run. The docs are explicit that their execution is “not controlled by an AI model, and is deterministic”. Three shapes cover most needs.
SequentialAgent: strict order
SequentialAgent runs sub_agents in list order, passing the same InvocationContext to each, so all steps share the same session state including the temp: namespace. Hand data between steps with output_key:
from google.adk.agents import SequentialAgent, Agent
code_writer = Agent(
name="CodeWriter",
model="gemini-2.5-flash",
instruction="Write Python code for the spec. Output only a code block.",
output_key="generated_code",
)
code_reviewer = Agent(
name="CodeReviewer",
model="gemini-2.5-flash",
instruction="Review this code:\n{generated_code}\nOutput bullet points.",
output_key="review_comments",
)
code_refactorer = Agent(
name="CodeRefactorer",
model="gemini-2.5-flash",
instruction="Refactor {generated_code} using {review_comments}.",
output_key="refactored_code",
)
root_agent = SequentialAgent(
name="CodePipeline",
sub_agents=[code_writer, code_reviewer, code_refactorer],
)
Write, then review, then refactor - always, in that order, with no model vote on the sequence.
ParallelAgent: independent branches
ParallelAgent starts every sub-agent concurrently. The critical caveat from the docs: each branch runs in its own execution context, with “no automatic sharing of conversation history or state between these branches” during execution, and result order is not deterministic. The standard pattern is fan-out into distinct output_keys, then fan-in with a merger agent inside a SequentialAgent.
LoopAgent: bounded repetition
LoopAgent re-runs its sub-agents until you stop it. It does not decide when to stop by itself: you must provide termination, either max_iterations or a signal from inside the loop.
from google.adk.agents import LoopAgent, Agent
from google.adk.tools.tool_context import ToolContext
def exit_loop(tool_context: ToolContext):
"""Call only when the critique says the draft is good enough."""
tool_context.actions.escalate = True
tool_context.actions.skip_summarization = True
return {}
critic = Agent(
name="Critic",
model="gemini-2.5-flash",
instruction="Critique {current_document}. Reply exactly 'No major issues found.' when done.",
output_key="criticism",
)
refiner = Agent(
name="Refiner",
model="gemini-2.5-flash",
instruction="If {criticism} is the completion phrase, call exit_loop. Else refine {current_document}.",
tools=[exit_loop],
output_key="current_document",
)
root_agent = LoopAgent(
name="RefinementLoop",
sub_agents=[critic, refiner],
max_iterations=5,
)
Two termination paths are in play: the hardcoded ceiling (guaranteed) and the model calling exit_loop (probabilistic). Keep both.
Where does a free-running orchestrator fit? ADK’s runtime lists AgentTool alongside FunctionTool, meaning an agent can be exposed as a callable tool. That is the flexible end of the spectrum: one model decides which specialist to invoke and when, which is exactly what you lose when you move to templates.
ADK 2.0 graph workflows: the declarative version
Starting with ADK 2.0 for Python and Go, the docs position template workflows as superseded by graph-based and dynamic workflows. A Workflow is a graph of nodes and edges where each node can be an AI agent, a tool, a plain function, or another workflow:
from google.adk import Agent, Workflow
city_generator = Agent(
name="city_generator",
model="gemini-flash-latest",
instruction="Return a random city name only.",
)
def lookup_time(node_input: str) -> dict:
"""Deterministic code, no model call."""
return {"city": node_input.strip(), "time_info": "10:10 AM"}
city_report = Agent(
name="city_report",
model="gemini-flash-latest",
instruction="Output: It is {time_info} in {city} right now.",
)
root_agent = Workflow(
name="city_workflow",
edges=[("START", city_generator, lookup_time, city_report)],
)
Branching is explicit too: a router function returns an Event(route=[...]) and a second edge maps each route value to a handler node.
from google.adk import Workflow, Event
process_message = Agent(
name="process_message",
model="gemini-flash-latest",
instruction='Classify the message as "BUG", "SUPPORT" or "LOGISTICS".',
)
def router(node_input: str):
routes = [r.strip() for r in node_input.split(",")]
return Event(route=routes)
root_agent = Workflow(
name="routing_workflow",
edges=[
("START", process_message, router),
(router, {
"BUG": handle_bug,
"SUPPORT": handle_support,
"LOGISTICS": handle_logistics,
}),
],
)
The README lists what the graph engine supports: routing, fan-out/fan-in, loops, retry, state management, dynamic nodes, human-in-the-loop, and nested workflows. The documented advantages are equally concrete: precise routing logic in code, chains of functions that never invoke a model, and better predictability than long prompts. There is a real cost, noted on the graph page: graph workflows are not compatible with some third-party integrations.
When declarative beats a free-running loop
Use a single looping agent when the path genuinely depends on what the model discovers mid-run, and you can tolerate variance. Reach for structure when any of these bite:
- Determinism. A pipeline that must run step B after step A every time is a
SequentialAgentor an edge list, not a sentence in an instruction. - Cost ceilings. A free-running loop has no natural bound.
LoopAgent(max_iterations=5)and graph retry limits turn spend into a config value you can put in a budget review. - Auditability. A fixed structure gives reviewers a map: node names, routes, and state keys line up with what the trace shows. A free-running loop only shows what happened to happen.
- Parallelizable work. Independent retrieval tasks belong in
ParallelAgent, where the win is wall-clock time, not elegance.
The trade-off is flexibility. Every edge you declare is a decision the model no longer gets to make, so a graph will not rescue you when the task needs creative branching that you did not anticipate. The pragmatic middle is the pattern ADK itself demonstrates: deterministic scaffolding around small, powerful agents, with a LoopAgent or model-driven routing only where exploration pays for itself.
Key Takeaways
- ADK’s ReAct loop is an event loop: the agent yields
FunctionCallandFunctionResponseevents, pauses while theRunnercommits them, and resumes on committed state. - Session state is the dataflow medium between steps:
output_keywrites it,{key}templating reads it, and theuser:/app:/temp:prefixes set its scope. - Template workflows (
SequentialAgent,ParallelAgent,LoopAgent) give deterministic control flow around agents; parallel branches do not share state automatically. - Loops always need an explicit termination:
max_iterationsas the hard ceiling, a tool likeexit_loop(actions.escalate = True) as the soft one. - ADK 2.0 adds
Workflowgraphs with edges, routes, fan-out/fan-in, retry, and human-in-the-loop nodes; the trade-off is less flexibility and some integrations are unsupported. - Pick structure for determinism, cost ceilings, and auditability; keep the free-running loop where the model genuinely needs to choose its own path.
References:
- https://github.com/google/adk-python
- https://adk.dev/workflows/
- https://adk.dev/graphs/
- https://adk.dev/runtime/event-loop/
- https://adk.dev/sessions/state/
- https://adk.dev/agents/workflow-agents/
- https://adk.dev/agents/workflow-agents/sequential-agents/
- https://adk.dev/agents/workflow-agents/loop-agents/
- https://adk.dev/agents/workflow-agents/parallel-agents/