Most ADK tutorials show you an agent and then, almost in passing, a line that runs it. That line is the part worth understanding. The Runner is where your process hands control to the agent runtime and where the runtime hands it back — and everything interesting about ADK in production happens in between: events are yielded one at a time, each persisted before you see it, state deltas commit atomically with the event that caused them, control transfers re-root the agent tree mid-turn, and eventually a final response falls out. Sibling articles cover what an agent is and which type to reach for; this one is about the machinery that makes any of them run. We walk the Runner from construction to teardown: the invocation lifecycle, the event stream and what rides on it, the contract with the session service, synchronous versus asynchronous execution, how a run is bounded and paused, and the handful of Runner-specific gotchas that only show up once real traffic is flowing.

What the Runner owns

An ADK Runner is constructed with three things that rarely change for the life of a process: an app name, a root agent, and a set of services — at minimum a SessionService, optionally an artifact service and a memory service. It is not a per-request object. You build it once at startup and call it many times, the way you would a database engine or an HTTP client.

from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService

session_service = InMemorySessionService()
runner = Runner(
    app_name="support_app",
    agent=root_agent,          # the tree entry point
    session_service=session_service,
)

What the Runner owns is the turn lifecycle. It resolves the session, appends the incoming user message as an event, constructs the invocation context the agent tree will read, drives the root agent, persists every event the tree yields, and streams those events back to you. What it deliberately does not own is any reasoning: it never decides which tool to call or which agent should answer. That separation is why the same Runner code works behind a CLI, a notebook, an SSE endpoint, and a managed deployment.

ADK runtimeexecution spineRunnerdrives turnsAgent treethe programLLM flowrequest loopModeltext + callsEvent streamappend-only logSession servicestate + historyTool executionfunctions + MCPCallbacksboundary hooksArtifact + memoryfiles + recallDeploymentlocal / Cloud Run / Agent EngineOpsevals + tracing + guardrails
The Runner drives the agent tree through an event loop; sessions, tools, callbacks, and services hang off it.
Advertisement

One call is one invocation

The primary entry point is run_async, and its signature encodes the whole model: you identify who is talking (user_id), which conversation they are in (session_id), and what they said (new_message, a google.genai types.Content). Everything else — history, state, tool definitions — the Runner reconstructs for you.

from google.genai import types

session = await session_service.create_session(
    app_name="support_app", user_id="u_42",
)
message = types.Content(
    role="user",
    parts=[types.Part(text="refund order 88213, it arrived broken")],
)

async for event in runner.run_async(
    user_id="u_42",
    session_id=session.id,
    new_message=message,
):
    print(event.author, event.content)

That single call is one invocation: the complete unit of work triggered by one user message. An invocation is emphatically not one model call. It may contain a dozen model calls, several tool executions, a transfer to a sub-agent, and a stack of callbacks — all sharing one invocation_id that stamps every event produced along the way. In a trace or a session log, that id is the thread tying a user complaint to the twenty machine steps it caused.

InvocationContext: what a run carries

Before the root agent runs, the Runner assembles an InvocationContext and passes it down the tree. Every agent, and through derived context objects every callback and tool, reads from the same object. It carries the resolved session (and therefore session.state and the event history), the agent currently executing, the invocation_id, the original user_content, the active RunConfig, and handles to the artifact and memory services.

Two properties matter most. First, the context is shared: when a SequentialAgent runs three children, all three see the same session state, which is how one stage hands results to the next. Second, it is the run-level kill switch — setting ctx.end_invocation = True tells the runtime to stop after the current step rather than continue the loop. That is the mechanism behind a before_agent callback that refuses an unauthorized request. Custom agents receive this object directly; most code sees a narrower view of it through CallbackContext or ToolContext, which expose the same state with the right write permissions for that boundary.

The event is the unit of everything

An ADK Event is not a log line bolted on after the fact — it is the actual medium of execution. Every agent method in ADK is an async generator that yields events, and the Runner is the thing that drains those generators. An event carries the model-response payload plus routing metadata: who produced it and what it means for the run.

FieldWhat it tells you
authoruser, or the name of the agent that produced it
contentthe parts: text, function calls, function responses
invocation_id, idwhich run, and which step within it
actionsside effects: state deltas, artifact deltas, transfer, escalate
partiala streaming chunk rather than a complete message
branchposition in the agent tree, for parallel fan-out
error_code, error_messagea step that failed rather than produced output

Event also gives you accessors instead of forcing you to walk content.parts: event.get_function_calls() returns the tool calls a model turn requested, event.get_function_responses() the results coming back, and event.is_final_response() answers the one question every consumer actually asks — is this the message I show the user?

Partial events, streaming, and RunConfig

By default the Runner yields whole messages: you get an event when a model turn completes, not while it is being written. Flip on streaming through RunConfig and the same loop starts producing partial=True events carrying token chunks, followed by a consolidated non-partial event for the same message.

from google.adk.agents.run_config import RunConfig, StreamingMode

cfg = RunConfig(streaming_mode=StreamingMode.SSE)

async for event in runner.run_async(
    user_id="u_42", session_id=session.id,
    new_message=message, run_config=cfg,
):
    if event.partial:
        yield_chunk(event.content.parts[0].text)   # to the browser
    elif event.is_final_response():
        finish(event)

The rule that saves you from a duplicated UI: render partial events, but persist and act on complete ones. Partials are for perceived latency; the non-partial event is the one that is durable. RunConfig is also where you bound a run — it exposes a configurable cap on model calls per invocation (max_llm_calls), your defence against an agent that loops forever — along with response modalities and speech configuration for multimodal runs, and a switch to save input blobs as artifacts.

The Runner and SessionService contract

Here is the part people get wrong. The session service is not a place you write to; it is a place the Runner writes to, on your behalf, once per event. As each event comes out of the agent tree, the Runner calls the session service to append it — and appending an event is also what commits the state changes attached to that event. Both the history and the state move forward together, or neither does.

State changes ride on event.actions.state_delta, a plain dict merged into session.state at append time. You rarely construct one by hand: writing tool_context.state["x"] = 1 inside a tool, or setting output_key on an LlmAgent, produces the delta for you. Key prefixes control scope — a bare key is session-scoped, user: spans that user’s sessions, app: is global, and temp: lasts only for the current invocation.

The gotcha follows directly: mutating a session object’s state dictionary outside the event flow is not durable and not audited. It appears to work with an in-memory service and silently vanishes the moment you switch to a database-backed one. If a change matters, it must ride an event.

Control signals: transfer, escalate, and skipping

Not every event is content. Some are instructions to the runtime itself, carried in event.actions, and reading them is how you understand why a multi-agent run went where it did.

transfer_to_agent is the big one. When an LlmAgent decides a specialist should own the conversation, it emits an event naming that agent; the runtime re-roots the rest of the invocation there, and subsequent events arrive with the new agent as their author. Control has genuinely moved — contrast this with AgentTool, where a nested agent runs to completion and returns a value while the parent keeps the wheel. escalate is the inverse signal, bubbling upward: it is what a sub-agent inside a LoopAgent raises to say ‘this is good enough, stop iterating’ — how a loop terminates on a condition, not a count. skip_summarization tells the flow not to send a tool result back through the model for a natural-language rewrite — a real token saving when the tool already returns exactly the string the user should see. artifact_delta records that a file was written, so the reference travels with the history.

Advertisement

Sync run versus async run_async

run_async is the real API. Agents, tools, callbacks, and services in ADK are asynchronous top to bottom, because an agent turn is mostly waiting — on a model, on an HTTP tool, on a database — and blocking a thread for each concurrent user does not survive contact with production traffic. A ParallelAgent is only genuinely parallel because the whole stack is async.

There is also a synchronous run, which yields the same events from an ordinary for loop. Treat it as a convenience wrapper for scripts, notebooks, and tests with no event loop. Do not reach for it inside an async web handler; you will block the loop you should be sharing.

A third method, run_live, handles bidirectional streaming — live audio and video where the user can interrupt mid-response. It takes a live request queue instead of a single new_message, and its stream carries interruption and turn-completion signals the request/response path never produces.

Where control returns to your code

Because run_async yields, your code is re-entered at every meaningful step of the turn — not just at the end. That is the design decision that makes ADK observable, and it means the consumer loop is where your application logic lives. Two patterns cover almost everything.

async def ask(runner, user_id, session_id, text):
    """Pattern 1: collect the final answer, ignore the middle."""
    msg = types.Content(role="user", parts=[types.Part(text=text)])
    final = None
    async for event in runner.run_async(
        user_id=user_id, session_id=session_id, new_message=msg,
    ):
        if event.is_final_response() and event.content:
            final = event.content.parts[0].text
    return final

Pattern two keeps the middle: forward every event to the client as it arrives, so the UI can show ‘calling lookup_order…’ instead of a spinner. Filter on event.get_function_calls() for tool activity, event.partial for token chunks, and event.actions for control moves. The same loop is your hook for metrics and cost accounting — every model call and tool invocation passes through it exactly once.

Concretely, one refund turn arrives as: the user message; a triage-agent event carrying actions.transfer_to_agent; a lookup_order function call and its response, trimmed of internal cost fields by an after_tool callback; then an issue_refund call that a before_tool guardrail short-circuits, writing needs_approval as a state delta instead of executing; and finally the agent’s text. When a human approves later, the same session resumes as a new invocation and the refund runs for real.

The generator gotcha and bounding a run

run_async returns an async generator, and generators are lazy. The invocation advances only as you iterate. Call the method and never iterate, and nothing runs at all; break out of the loop halfway and you abandon the invocation partway through, with whatever events were already yielded persisted and the rest never produced. This bites in two familiar shapes: an early break after the first final response in a multi-agent run that had more to do, and a client that disconnects from an SSE endpoint. Neither is a crash — you get a truncated turn and a session whose history stops mid-thought. If you want the full turn regardless of the client, drain the generator and buffer rather than letting the socket drive the loop.

The complementary risk is a run that does not stop. Bound it deliberately: max_llm_calls in RunConfig caps model calls per invocation, max_iterations caps a LoopAgent, ctx.end_invocation stops a run from inside a callback, and an asyncio timeout caps wall-clock time. An unbounded agent loop is a billing incident waiting to happen.

Pausing a turn: long-running tools and human approval

Some steps cannot finish inside a turn. A refund needs a manager to approve it; a data export takes four minutes; an OAuth flow needs a browser click. ADK handles this by letting a tool declare itself long-running: it returns an acknowledgement immediately rather than a result, and the event carrying that call is marked in long_running_tool_ids so your consumer loop can recognize it.

The invocation then ends normally — the model can even tell the user ‘I have queued this for approval’ — and your application owns the pause. No thread is parked in memory: the state of the paused work lives in the session, so the process can restart or scale down. When the approval lands, you resume by starting a new invocation on the same session, delivering the outcome as a function response for the original call. The agent picks up with the full history in context. Authentication flows use the same shape via requested_auth_configs: pause, obtain credentials out of band, resume the session.

From adk web to production, same Runner

During development you rarely construct a Runner at all. adk run gives you a terminal chat loop, adk web a local UI with an event inspector and a trace view, and adk api_server an HTTP surface — each builds a Runner over your agent with in-memory services. In tests, InMemoryRunner(agent=...) wires the same thing in one line. Deploying to Cloud Run or Agent Engine swaps those services for persistent ones and leaves the execution path untouched.

That substitutability is the payoff of routing everything through one conductor and one event stream. The trajectory you inspected in adk web is the same object shape that production persists, that OpenTelemetry spans wrap, and that the eval tooling replays — which is why a recorded session doubles as a regression fixture. Get the Runner and its event loop clear in your head and the rest of ADK stops looking like a pile of classes and starts looking like one loop with well-placed seams.

The Runner is ADK’s conductor: built once with an app name, a root agent, and services, then called per turn with a user id, a session id, and a message. One call is one invocation — possibly many model calls, tool executions, and transfers — surfaced as an async generator of events. Every event is persisted by the Runner as it is yielded, and appending an event is also what commits its state delta, so state mutated outside the event flow is neither durable nor audited. Because the generator is lazy, the turn advances only while you iterate: abandon the iterator and you abandon the run. Bound every invocation (max_llm_calls, loop iteration caps, end_invocation), render partial events but act on final ones, and pause long work by ending the invocation and resuming the same session later. Same Runner in adk web and in production — only the services change.