Why the event is the fundamental unit

It is tempting to model an agent as a function: text in, text out. That framing survives exactly one turn. The moment an agent calls a tool, delegates to a sub-agent, updates working state, or streams a partial answer, ‘the result’ stops being a single value and becomes a sequence of things that happened over time. ADK takes that sequence seriously and makes it the primary abstraction: an agent’s output is a stream of Event objects, each one a discrete, labelled record of a single occurrence.

This reframing pays for itself immediately. Because every model call, every tool invocation, and every state change is an event with an author and a timestamp, ‘the agent did something weird’ becomes a concrete, inspectable log rather than a mystery buried inside a prompt loop. The event stream is simultaneously the communication channel between components, the API your application subscribes to for real-time UI, and the durable history that gets persisted for audit, evaluation, and resumption. One abstraction serves all three roles, which is why understanding the Event object unlocks the rest of the runtime: sessions, tools, callbacks, and workflow agents all speak in events.

Advertisement

Anatomy of the Event object

An ADK Event is a structured record, not a bare string. Its fields answer a few precise questions: who, what, what-else-happened, and when.

FieldMeaning
authorWho produced this event — 'user' or the name of the agent
contentThe payload: a Content with a role and a list of parts (text, function call, function response)
actionsAn EventActions carrying side effects and control signals (state deltas, transfer, escalate…)
invocation_idCorrelates every event produced within one run_async invocation
idA unique identifier for this individual event
timestampWhen the event was created
partial / turn_completeStreaming flags distinguishing an in-progress chunk from a completed message
branchThe agent-tree path, so events from nested agents stay attributable

The shape matters because it is uniform. A user message, a model’s reasoning, a request to call get_weather, and that tool’s answer are all the same kind of object with the same fields. Consumers do not need a different parser per message type; they inspect author, walk content.parts, and check actions. That uniformity is what lets a generic Runner drive an arbitrarily complex agent tree.

Advertisement

Content and parts: what an event carries

The substance of most events lives in event.content.parts, a list borrowed from the underlying generative model’s content format. A single event can carry several parts, and each part is one of a small number of shapes. The three that matter most are text, a function call, and a function response.

for part in event.content.parts:
    if part.text:
        print(part.text)                      # model or user prose
    elif part.function_call:
        fc = part.function_call
        print(fc.name, fc.args)               # the model wants to call a tool
    elif part.function_response:
        fr = part.function_response
        print(fr.name, fr.response)           # the tool's result

This is the crux of how a tool-using agent actually works. The model does not ‘call’ your Python function directly — it emits an event whose part is a function_call naming the tool and supplying arguments. The runtime executes the real function, then produces a follow-up event whose part is a function_response carrying the return value. Both are ordinary events on the same stream, which is why a tool call and its result appear in the history exactly where they happened, with the arguments the model chose and the value the tool returned both permanently recorded.

Who authors events: roles on the stream

Every event names its author, and that single field carries most of the semantics of ‘event type.’ ADK does not rely on a large enum of message kinds; instead it combines the author with the shape of the content to tell you what an event is.

AuthorContent shapeWhat it represents
'user'text (or media) partThe human’s input that opened the turn
agent nametext partThe agent’s natural-language reply or reasoning
agent namefunction_call partThe agent asking the runtime to run a tool
agent namefunction_response partA tool’s result, folded back in under the agent’s authorship
agent nameempty content, actions onlyA pure side effect: a state change or control signal

Function responses are worth a note: although the value originates in a tool, ADK records the response as an event so the tool’s output is a visible, replayable part of history rather than a hidden intermediate. In a multi-agent system the author becomes even more valuable — when a triage agent transfers to a research agent, the stream interleaves events from both, and author plus branch keep every line attributable to the agent that actually produced it. Reading a session transcript, you always know who said what.

EventActions: side effects and control on the stream

Text and function parts describe what was said. The actions field — an EventActions object — describes what changed and what should happen next. This is where ADK keeps the consequences of a turn out of the prose and in a structured, machine-readable place.

ActionEffect
state_deltaA map of session-state keys to write when this event is committed
artifact_deltaRecords that named binary artifacts were saved in this step
transfer_to_agentHand control to a named sub-agent
escalateBubble control up — e.g. break out of a LoopAgent
skip_summarizationTell the model not to re-summarize a tool result; use it verbatim

Bundling these onto the event, rather than mutating the world as a side effect, is a deliberate discipline. A state change is not applied the instant a tool runs; it is attached to an event as a state_delta and applied when the Runner commits that event to the session. The consequence is that history and state never disagree: replaying the events in order reproduces the exact sequence of state transitions, because each transition is an event. Control signals get the same treatment — a transfer or an escalate is data on the stream, so the reason the flow changed direction is recorded next to where it changed.

State deltas: how events mutate session state

ADK keeps a mutable key-value session.state, but agents and tools do not write to it directly and imperatively. Instead a change is expressed as a state_delta on an event’s actions, and the SessionService folds that delta into the materialized state when the event is appended. The most common way to produce one is via the ToolContext or CallbackContext handed to your code:

def record_city(city: str, tool_context: ToolContext) -> dict:
    # This assignment becomes a state_delta on the event the runtime emits.
    tool_context.state['last_city'] = city
    tool_context.state['user:preferred_city'] = city   # user-scoped, persists across sessions
    return {'status': 'ok'}

Scope prefixes on the key decide reach: a plain key is session-scoped, user: follows the user across sessions, app: is shared application-wide, and temp: lives only for the current invocation and is never persisted. Because the write travels as a delta on an event, you get atomicity and auditability for free: the state at any point in a conversation is exactly the left-fold of every state_delta up to that event, which means you can reconstruct why a value is what it is by reading the events that set it.

Control-flow events: transfer, escalate, loop exit

Multi-agent orchestration in ADK is not implemented with hidden function calls between agents; it is implemented with events. When an LlmAgent decides to delegate, it emits an event carrying transfer_to_agent in its actions, and the Runner responds by routing the next turn to the named sub-agent. The delegation is therefore visible on the stream — you can see the exact moment control moved and which agent requested it.

The escalate signal is the complement: it bubbles control upward. Inside a LoopAgent, a sub-agent that has satisfied the exit condition emits an event with escalate=True, and the loop stops iterating — a clean, data-driven way to say ‘we are done’ without a magic return value or an exception. This is how you build a critique-and-revise loop that terminates when a reviewer approves, or a retry loop that ends on success. Because both transfer and escalate are ordinary EventActions, an evaluation harness can assert on them directly: did the triage agent transfer to billing for this input? is a question you answer by scanning the recorded events, not by instrumenting the model. Control flow becomes testable precisely because it is expressed as data.

Agents yield events: the async stream

The production side of the stream is an asynchronous generator. An ADK agent’s core method does not build a list and return it; it yields events one at a time as they occur, which is what allows the caller to react the instant something happens rather than after the whole turn finishes.

class MyAgent(BaseAgent):
    async def _run_async_impl(self, ctx: InvocationContext):
        # ... do work, call the model, run a tool ...
        yield Event(author=self.name, content=some_text_content)
        yield Event(author=self.name, actions=EventActions(
            state_delta={'step': 'done'}))

The async/yield shape is doing real work here. It means an agent can emit a partial text chunk, then another, then a function-call event, then a function-response event, then a final message — and each is handed to the consumer as it is produced. It also composes: a workflow agent like SequentialAgent runs its children and simply re-yields their events upward, so a deeply nested tree still presents to the Runner as one flat, ordered stream. The generator model is the reason ADK can express both a simple one-shot reply and a long, multi-step, multi-agent run through the exact same interface — the only difference is how many events get yielded before the generator is exhausted.

The Runner consumes the stream and persists it

If agents are the producers, the Runner is the consumer and the system of record. You start a turn with runner.run_async(...), and it drives the root agent’s generator, doing three jobs for every event that comes out. First, it appends the event to the Session through the SessionService, which also applies any state_delta and artifact_delta the event carries. Second, it yields the event onward to your application code so you can render or log it. Third, it continues the loop — if the event was a tool call, the runtime executes the tool and feeds the resulting response event back so the model can react.

This is why persistence and delivery are never out of sync: the same event that reaches your UI is the one written to history, in the same order, in the same operation. It also cleanly separates concerns. Your application decides how to present events; the Runner guarantees they are durable and correctly ordered; the agent decides what events to produce. Swap the SessionService from in-memory to a database-backed implementation and nothing about the agent or the consumer loop changes — only where the same events land.

Iterating over run_async: the consumer loop

Here is the pattern nearly every ADK application is built around: iterate the async stream, branch on the shape of each event, and treat is_final_response() as the signal that the turn’s user-facing answer has arrived.

content = types.Content(role='user',
                        parts=[types.Part(text='Weather in Paris?')])

async for event in runner.run_async(user_id=uid,
                                    session_id=sid,
                                    new_message=content):
    # 1) natural-language and tool traffic
    if event.content and event.content.parts:
        for part in event.content.parts:
            if part.text:
                print(f'[{event.author}] {part.text}')
            elif part.function_call:
                print(f'[{event.author}] -> {part.function_call.name}'
                      f'({part.function_call.args})')
            elif part.function_response:
                print(f'[tool] <- {part.function_response.response}')

    # 2) side effects
    if event.actions and event.actions.state_delta:
        print('state:', event.actions.state_delta)

    # 3) the final answer for this turn
    if event.is_final_response():
        print('FINAL:', event.content.parts[0].text)

Notice that a single loop handles the model’s prose, its tool requests, the tools’ answers, and state changes — because they are all the same kind of object. You do not write separate handlers for ‘streaming’ versus ‘tool’ versus ‘final’ modes; you inspect fields on a uniform Event and decide what to do.