A running ADK agent is easy to picture as a black box: a message goes in, an answer comes out. This article opens the box and watches the clock. We follow a single invocation from the instant a user message reaches the Runner to the moment the last event is persisted and control returns to the caller — the InvocationContext that is created to carry it, the agent.run_async generator that drives it, the model and tool steps that make it up, the events that are emitted and yielded one at a time, the state deltas that get committed to the session, and the teardown that ends it. Along the way we mark exactly where each callback fires — before and after the agent, before and after every model call, before and after every tool call — not as an API reference but as phase markers on the timeline. And we pin down three words that get used loosely and mean different things: invocation, turn, and step. Get those three straight and the whole event loop stops being mysterious.
Three clocks: invocation, turn, and step
The single most useful thing to fix before tracing anything is the vocabulary, because ADK’s runtime operates on three nested time scales and they are constantly confused. A turn is the conversational unit: one user message and the complete agent response to it. It is what a human sees in a chat transcript. An invocation is the runtime unit that services one turn: everything that happens inside a single runner.run_async(...) call, stamped with one invocation_id. A step is the innermost unit: one iteration of an LlmAgent’s reason-act loop — assemble a request, call the model once, run any tools it asked for.
The nesting is strict. One turn is served by one invocation; one invocation runs one or more steps; one step contains exactly one model call and zero or more tool calls. A trivial ‘what time is it?’ turn is one invocation of one step. A ‘refund my broken order’ turn is still one invocation, but it may run four or five steps — look up the order, check policy, issue the refund, summarize — and, if the agent transfers to a specialist, span more than one agent inside that same invocation. Keeping the three clocks separate is what lets you reason about where a callback fires and how many times.
The starting gun: a message reaches the Runner
Nothing in ADK happens until a message hits the Runner. The Runner is the entry point and the conductor: your API layer resolves a user_id and session_id, wraps the raw text as a types.Content, and calls the async entry point.
async for event in runner.run_async(
user_id="u_42",
session_id="s_9",
new_message=types.Content(role="user",
parts=[types.Part(text="refund order 88213")])):
# each event is streamed to you the instant it is produced
handle(event)The first thing the Runner does is load the session from the SessionService and append the user message as an event — the invocation’s first entry in the append-only log. Only then does it select the root agent and begin the invocation. Note the shape of the call: it is an async generator. The Runner does not compute an answer and return it; it yields a stream of events as they happen, and your loop consumes them. That streaming contract is the backbone of the entire lifecycle, and every later phase is just something that produces another event for this loop to pull.
InvocationContext: the object the whole run hangs on
Before it calls the agent, the Runner constructs an InvocationContext — the single object that travels with the invocation from start to finish and ties every phase together. It is created once per invocation and threaded down through the agent, its flow, its callbacks, and its tools.
What it carries is exactly what a phase needs to do its job: the invocation_id that stamps every event; the current agent (which changes if control transfers); the session and its live state; the user_content that started things; handles to the session, artifact, and memory services; and the run_config that carries flags like streaming mode and per-invocation limits. It also holds the control switches the loop watches — most importantly an end_invocation flag that any phase can set to bring the whole thing to an orderly stop. The context objects you actually touch in application code — CallbackContext in a model or agent hook, ToolContext in a tool hook — are thin, permission-shaped views onto this same InvocationContext. When your callback writes to state, it is writing to the state that lives here.
Waking the agent: the before_agent phase
With the context built, the Runner calls agent.run_async(ctx), and the very first thing that fires — before a single token is sent to any model — is the before_agent phase. This is the agent’s entry gate. It runs exactly once per agent per invocation, which makes it the natural home for one-time-per-turn concerns: authorization (‘may this caller talk to this agent about this order?’), precondition checks, and seeding working state that later steps will read.
The phase has veto power. If a before_agent_callback returns content instead of None, ADK uses that as the agent’s response and skips the agent body entirely — the model is never called, no tools run, and the invocation heads straight for teardown. That short-circuit is the mechanism behind an authorization denial: the gate closes and the timeline collapses to a single ‘access denied’ event. When the gate is open, the callback returns None and execution proceeds into the agent’s real work. We name these callbacks here as timeline markers only; the full contract for what they receive and how the short-circuit return is typed lives in the dedicated callbacks reference — here they matter as where and when, not how.
The event loop: yield, persist, resume
Here is the mechanism that drives everything, and it is simpler than it sounds. agent.run_async(ctx) is an async generator that yields Event objects. It does not return a result; it produces events and pauses. The Runner sits in a loop pulling from it. Each time the agent yields an event, control leaves the agent and returns to the Runner, which does two jobs before asking for the next event: it persists the event to the session (appending it to the log and applying any state changes it carries), and it re-yields the event to your calling code. Then it resumes the generator, which picks up exactly where it paused.
This yield-persist-resume rhythm is the heartbeat of the lifecycle. It is why state is durable at every observable point — by the time an event reaches you, its state delta is already committed — and why an agent can be long-running without blocking: between yields, the process is free. It also means the Runner is the only component that writes to the session. The agent, its steps, and its tools describe what happened by emitting events; the loop is what makes those descriptions real.
Inside the agent: the step loop
Zoom into an LlmAgent and you find a second loop nested inside the first: the step loop, the classic reason-act cycle. One step is a single pass: gather the instruction, the conversation history, and the tool declarations into a request; call the model once; and inspect the reply. If the reply is plain text, the step — and usually the invocation — is done. If the reply contains function calls, ADK runs the requested tools, feeds the results back into the context, and begins another step. The loop continues until the model answers without asking for a tool, or an internal limit stops it.
This is the crucial refinement of ‘invocation.’ A single user turn does not map to a single model call. It maps to as many steps as the model needs to reason its way to an answer, each step a fresh model call carrying the accumulated results of the ones before it. A three-tool refund is one invocation of perhaps four steps. Understanding the step boundary is what makes the next two phases legible: the model phase and the tool phase are the two halves of a single step, and their callbacks fire once per step, not once per invocation.
The model phase: assemble, hook, call
Every step begins by building an LlmRequest: the system instruction, the relevant slice of session history, and the declarations for the tools this agent may call — each derived from a tool’s name, docstring, and schema so the model knows what it can invoke. Immediately before the request leaves for the model, the before_model phase fires. This hook sees the fully assembled request and can rewrite it (trim context, inject a directive), consult a cache and return a stored response to skip the call, or block outright with a canned reply. If it returns nothing, the request goes to the model.
The model call is the one genuinely non-deterministic point on the timeline — Gemini natively, or another provider through LiteLLM. When the response arrives, the after_model phase fires with it in hand, the place to redact, rewrite, or record the raw completion before it flows onward. The model’s reply is then turned into one or more events: streamed text may arrive as several partial events, and function calls become their own events. Because before_model and after_model wrap each step’s call, a five-step invocation fires each of them five times — a fact worth remembering when a per-call guardrail’s cost shows up in your latency budget.
The tool phase: from a function call to a result
When a step’s model reply contains a function call, the invocation enters the tool phase. ADK parses the call — a tool name and a dictionary of arguments the model chose — and, before executing anything, fires the before_tool phase. This hook receives the tool and its parsed arguments and can validate them, enforce a policy on the specific action, or return a result dictionary to short-circuit execution entirely (a mock, a cached value, or a policy denial). If it declines to intervene, the tool’s Python function runs.
When the tool returns, the after_tool phase fires with the raw result, the moment to cap its size, strip internal fields, or reshape it before it re-enters the model’s context. The final result becomes a function_response event. Then the loop does something important: it feeds that result back and starts a new step, so the model can read what the tool returned and decide what to do next. A tool call is never the end of the story — it is the pivot between one step and the next. Several tools requested in a single reply run within the same step before the next model call; a tool that triggers further reasoning starts a fresh step. This alternation of model phase and tool phase, step after step, is the substance of the invocation.
Events: the heartbeat that is emitted and yielded
Every phase we have named produces the same currency: an Event. The user message, each chunk of model text, each function call, each function response, each state change, each control signal — all are immutable events with an author, an invocation_id, a timestamp, and an optional actions payload. ‘Emitted’ and ‘yielded’ describe the same event at two moments: a phase emits it into the generator, and the Runner yields it to you after persisting it.
| Phase | Typical event it emits |
|---|---|
| Runner intake | user message |
| Model phase | partial text, then final text and/or function calls |
| Tool phase | function response, with state delta in actions |
| Transfer / escalate | control event carrying an action signal |
Because the event stream is the record, the lifecycle is fully observable and fully replayable. A recorded invocation is a list of events you can rerun against a new prompt version and diff. There is no hidden state that lives only in a variable somewhere — if it mattered, it is an event, and if it is an event, it went through the loop.
State deltas: how a write becomes durable
State does not change by assignment landing directly in a shared dictionary. It changes through the event loop, and the mechanism is the state delta. When a tool or a callback writes to context.state[...], ADK stages that write into the state_delta of the actions attached to the event the current phase is about to emit. The change is a description of a change, riding along with the event.
It becomes durable when the Runner persists that event: the SessionService applies the delta to session.state as part of appending the event. The two happen together, which is what makes state and history consistent — you can never observe an event whose state change has not yet been applied, because the same commit does both. Scope prefixes decide how far a key reaches: a bare key is session-scoped, user: spans that user’s sessions, app: is global, and temp: lives only for the current invocation and is never persisted. This is why a before_agent hook can stash temp:authorized = True and a later step can trust it: it rode an event through the loop and landed in the same state object the whole invocation shares.
Knowing when to stop: partial vs final responses
How does the loop know the invocation is over? Not every event is an answer. Streaming produces a run of partial events — incremental text chunks with a partial flag — that let a UI render tokens as they arrive but do not signify completion. Function-call and function-response events are mid-invocation machinery, not conclusions. ADK gives events an is_final_response() test precisely so callers can tell the difference.
A final response is the model’s complete, user-facing reply for the turn: text, not partial, with no outstanding function call to service. Reaching it is the normal way the step loop ends — the model answered without asking for another tool. But there are other exits: an agent can escalate (signalling a parent to take over, which ends a LoopAgent iteration), a callback or the flow can set end_invocation, or an internal step limit trips to stop a runaway. Your consuming loop typically watches for event.is_final_response() to know the assistant’s turn has produced its answer, even though earlier events were already streamed and already persisted along the way.
When one invocation spans many agents
A subtlety that trips people up: an invocation is not tied to a single agent. When an LlmAgent decides another agent is better suited, it emits a transfer — a control event whose actions name the target. The Runner re-roots the invocation on that agent, which now runs its own before_agent phase, its own steps, its own tools — all under the same invocation_id. The turn has not ended; the work simply moved.
Workflow agents make this composition explicit rather than model-driven. A SequentialAgent runs its children one after another within the invocation; a ParallelAgent runs them concurrently, their events interleaving in the same stream; a LoopAgent repeats a child until one of them escalates. Each child is invoked with a context derived from the parent’s, sharing the session and state but carrying its own branch so events can be attributed correctly. The lesson for the timeline: before_agent and after_agent can fire several times in one invocation — once per agent that participates — while the invocation-level identity, the session, and the invocation_id stay constant throughout. Counting agent-hook firings by assuming ‘one per turn’ is the classic off-by-N mistake.
The final response and teardown
The invocation ends the way it ran: quietly, through the generator. When the root agent’s run_async has nothing left to yield — the step loop hit a final response, or a control signal ended it — the generator is exhausted. Just before an agent finishes, its after_agent phase fires, the last chance to finalize: write summary state, emit an audit record, clean up. Because the loop already committed every earlier event, there is no buffer to flush and no partial write to reconcile; teardown is mostly the absence of more work.
Control returns to your async for, which sees the stream end. The session now holds the complete, ordered record of the turn — user message, every model and tool event, every state delta, the final response — ready to serve as the history for the next invocation on the same session, or as a fixture for evaluation. Nothing about this shape changes between a laptop running adk web with an in-memory session service and a production deployment on a managed backend; the same yield-persist-resume loop, the same InvocationContext, the same phases fire in the same order. That invariance — dev and prod running the identical lifecycle — is what makes a recorded session a trustworthy thing to reason about and to test against.
Runner receives a user message, appends it as an event, builds one InvocationContext, and calls agent.run_async — an async generator that emits events the Runner persists and re-yields one at a time. Inside, an LlmAgent runs a step loop: assemble a request, call the model, run any tools, feed results back, repeat until a final response. Keep the three clocks distinct — a turn is the conversation, an invocation is one run_async, a step is one model call plus its tools — and the callback firing counts fall out for free: before/after_agent once per participating agent, before/after_model and before/after_tool once per step and per tool. State never changes by direct assignment; it rides an event as a delta and is committed when that event is persisted. Everything observable is an event, and every event went through the loop.