In ADK for Java, everything an agent does is reported as an Event. The user's message is an event. A token of streamed model text is an event. A tool call, its result, a hand-off to another agent, a state change and an error are all events. The Runner hands them to you as a Flowable<Event>, the session service stores the ones that matter, and the next model call rebuilds its context from the stored list. If you understand the event object, you understand what the framework can show you, what it remembers, and what it will replay.

This page treats the event as a contract. It covers the fields, the EventActions that let an event change session state, the rules that separate a streamed partial event from a persisted final one, and how a client should route each kind. A worked refund-agent turn ties it together. Method names were checked against the google/adk-java source on the date above; the library still moves, so pin your version.

Architecture at a glance

One invocation: events flow out, final events flow into the sessionClientrunAsync(...)RunnerFlowable of EventAgent / flowLLM + toolsModelpartial chunksToolfunction callmessagepartial = true?stream onlyeach eventyes: to clientappendEventfinal events onlynoSessionevents list + stateapply stateDeltatemp: keys skipped; app: and user: keysrouted to wider scopes by the serviceClientrenders final eventthen emittedNext model callcontext rebuilt from stored events
Every event reaches the caller; only non-partial events are appended to the session, and appending applies the event's state delta. The next model call rebuilds its context from the stored list.

From first principles: why an agent speaks in events

Why build an agent framework around events instead of return values? Because an agent turn is not one computation. A single user message can trigger several model calls, a dozen tool calls, a transfer to a sub-agent and a long-running approval. The caller wants to see progress while that happens, the session needs a durable record afterwards, and the next model call needs history to reason over. One stream of immutable records serves all three: the UI renders it, the session service persists it, and the context builder reads it back.

That gives you three properties worth protecting. First, the event log is the source of truth: session state is derived from the stateDelta carried on stored events, so a correct log means correct state. Second, events are append-only: you do not edit a stored event, you emit a new one. Third, every event names its author (the user, or an agent's name) and its invocationId, so you can always tell who said what in which turn.

Anatomy of an Event

The Event class in com.google.adk.events is an immutable value built with Event.builder(). Most fields are Optional because an event usually carries only one kind of payload. These are the ones you will touch:

AccessorTypeWhat it tells you
id()StringUnique id; Event.generateEventId() returns a random UUID.
invocationId()StringWhich user turn produced it. Group by this for traces.
author()String"user" or the name of the agent that emitted it.
content()Optional<Content>The message parts: text, function calls, function responses.
partial()Optional<Boolean>True for a streamed chunk that will be superseded.
turnComplete()Optional<Boolean>Set by live/bidi flows when the model finishes its turn.
actions()EventActionsSide effects: state and artifact deltas, transfer, escalate.
errorCode()Optional<FinishReason>A model finish reason, not a string or HTTP status.
errorMessage()Optional<String>Human-readable error text.
branch()Optional<String>Agent path in multi-agent trees, used to scope history.
timestamp()longCreation time in epoch milliseconds.
usageMetadata()Optional<...>Token counts from the model response, when present.

Three convenience methods do the parsing for you. functionCalls() and functionResponses() return immutable lists pulled from the content parts, empty when there are none. finalResponse() answers the question a UI asks most: is this the thing to show the user as the answer? In the current source it is true when the event's actions set skipSummarization, or when a long-running tool call is pending; otherwise it is true only when the event has no function calls, no function responses, is not partial and does not end with a code-execution result. Note what that means: a final response is a property of one event, not of the whole invocation. A turn can contain several events where finalResponse() is true, for example one per sub-agent in a sequential pipeline.

EventActions: how an event changes the world

The content of an event is what was said. EventActions is what the event does. The session service reads it when the event is appended, and the flow reads it to decide what happens next.

  • stateDelta(), a Map<String, Object>: key-value changes to merge into session state. A value of State.REMOVED deletes the key. Key prefixes set scope: app: for every user of the app, user: for one user across sessions, temp: for the current invocation only, and no prefix for this session.
  • artifactDelta(), a Map<String, Integer>: artifact name to the version saved by this event. The bytes live in the artifact service; the event records that a version was written.
  • transferToAgent(): the name of the agent to hand control to. The flow acts on it; your UI can show a hand-off.
  • escalate(): a signal for loop agents to stop iterating and return control upwards.
  • skipSummarization(): tells the flow not to send a tool's result back to the model for summarising. The tool response becomes the final answer.
  • requestedAuthConfigs() and requestedToolConfirmations(): a tool needs credentials or a human yes/no before it can continue. These pause the turn until the client answers.

Treat stateDelta as the only legal way to change state from inside an agent turn. If a tool mutates the session object directly, the change is not on any event, so it is not persisted by a durable session service, is invisible to replay, and disappears when the session is reloaded. Tools and callbacks get a context object whose state writes are collected into the delta of the event they produce; use it.

Lifecycle: produced, streamed, persisted

Every event goes through three stages: produced by the flow, emitted to the caller, and, if it is final, persisted. The Runner source is explicit that partial events are streamed to the caller but never persisted. The default appendEvent in BaseSessionService also returns early for partial events, so even a custom service built on it will not store them. For a non-partial event it applies the delta, skipping keys that start with temp:, removing keys whose value is the removal sentinel, and then adds the event to the session's list.

Partial events only appear when you ask for streaming. With RunConfig.StreamingMode.NONE, the default, you receive whole events. With SSE the model's text arrives as a run of partial events followed by an aggregated non-partial event that carries the complete text and is the one stored. In the current Gemini integration each partial carries the text accumulated so far, not just the newest chunk; check the model class you use. BIDI is used with runLive for audio and live sessions, where turnComplete and interrupted become meaningful.

Two consequences follow. A client that appends every partial and then the final event repeats the answer; render each partial by replacing the draft, then replace the draft with the final event's text. And anything you only put in a partial event is lost on reload. Put state changes, artifact versions and errors on final events.

Consuming the stream in a client

Here is a router that a web handler can use to turn the event stream into server-sent events for a browser. It classifies by payload, not by author, because a sub-agent's tool call looks the same as the root agent's.

RunConfig cfg = RunConfig.builder()
    .streamingMode(RunConfig.StreamingMode.SSE)
    .build();
Content msg = Content.fromParts(Part.fromText("Refund order 1182, it arrived broken"));

runner.runAsync(userId, sessionId, msg, cfg)
    .blockingForEach(ev -> route(ev, sink));

static void route(Event ev, Sink sink) {
  String inv = ev.invocationId();
  if (ev.errorCode().isPresent() || ev.errorMessage().isPresent()) {
    sink.send("error", inv, ev.errorMessage().orElseGet(() -> ev.errorCode().map(String::valueOf).orElse("error")));
    return;
  }
  if (ev.partial().orElse(false)) {               // draft text, never stored
    sink.send("draft", inv, textOf(ev));
    return;
  }
  for (FunctionCall fc : ev.functionCalls()) {
    sink.send("tool_call", inv, fc.name().orElse("?"));
  }
  for (FunctionResponse fr : ev.functionResponses()) {
    sink.send("tool_result", inv, fr.name().orElse("?"));
  }
  ev.actions().transferToAgent().ifPresent(a -> sink.send("handoff", inv, a));
  if (!ev.actions().requestedToolConfirmations().isEmpty()) {
    sink.send("needs_approval", inv, ev.id());    // client answers, then resumes
  }
  if (ev.finalResponse() && !textOf(ev).isEmpty()) {
    sink.send("answer", inv, textOf(ev));         // replaces the draft
  }
}

static String textOf(Event ev) {
  StringBuilder sb = new StringBuilder();
  ev.content().flatMap(Content::parts).ifPresent(parts ->
      parts.forEach(p -> p.text().ifPresent(sb::append)));
  return sb.toString();
}

In a real server use the reactive operators rather than blockingForEach, so a slow browser applies backpressure instead of holding a thread. Send the event id with each server-sent event: a reconnecting client can then skip duplicates, and your logs can join browser reports to stored events.

Emitting events from your own agent

Custom agents emit events too. A deterministic step, such as a policy check before a refund, should produce an event with its decision in the content and its state change in the actions, so the decision is visible in the trace and persisted with the session.

@Override
protected Flowable<Event> runAsyncImpl(InvocationContext ctx) {
  Object order = ctx.session().state().get("order");
  boolean eligible = policy.isRefundable(order);
  Event decision = Event.builder()
      .id(Event.generateEventId())
      .invocationId(ctx.invocationId())
      .author(name())
      .content(Content.fromParts(Part.fromText(
          eligible ? "Refund is within policy." : "Refund needs manual review.")))
      .actions(EventActions.builder()
          .stateDelta(Map.of("refund_eligible", eligible,
                             "temp:policy_version", policy.version()))
          .escalate(!eligible)
          .build())
      .timestamp(System.currentTimeMillis())
      .build();
  return Flowable.just(decision);
}

The temp: key is visible to later agents in this invocation but is never written to stored state, which suits values that are cheap to recompute. refund_eligible has no prefix, so it is stored for the session and survives a restart.

Worked example: one refund turn, event by event

Follow one turn of a refund assistant: a root agent with a lookup tool, a policy agent like the one above, and a refund tool that needs human confirmation. Streaming is on. The user writes "Order 1182 arrived broken, refund it." The stream carries roughly this sequence:

#authorpayloadpartialstoredclient shows
1userthe message textnoyeschat bubble
2rootfunction call: lookup_ordernoyes"Looking up order"
3rootfunction response, stateDelta order=...noyesnothing, or a detail pane
4-9roottext chunksyesnodraft text, growing
10rootaggregated text, no callsnoyesreplaces the draft
11policydecision text, stateDelta refund_eligible=truenoyes"Within policy"
12rootfunction call: issue_refundnoyes"Preparing refund"
13rootrequestedToolConfirmationsnoyesapprove / reject buttons

The turn pauses at event 13. When the operator approves, the client sends the confirmation back and the run resumes; the refund tool's response and the final text arrive as new events under the same session. If the server restarted between 13 and the approval, nothing is lost: events 1 to 3 and 10 to 13 are stored, the state keys order and refund_eligible are rebuilt from their deltas, and only the drafts 4 to 9 are gone, which is correct because event 10 supersedes them.

A naive client gets three things wrong: it shows the paragraph twice (4 to 9, then 10), treats 11 as the end of the turn because finalResponse() is true, and ignores 13 so the turn hangs. All three are routing bugs, and the router above handles them.

Failure modes

  • Duplicate text. Partials and the aggregated final event appended to each other. Replace the draft each time; never append.
  • State that vanishes on reload. A tool mutated the session map directly, or the change rode on a partial event, or the key had a temp: prefix. Move it into the delta of a final event and pick the scope prefix deliberately.
  • Turn treated as finished too early. A client stopped at the first finalResponse(). Mark completion when the Flowable completes, not on the first final event.
  • Hung approvals. requestedToolConfirmations or requestedAuthConfigs were never surfaced. Route them explicitly and alert on invocations that stay paused beyond a deadline.
  • Errors read as strings. errorCode() is a FinishReason such as a safety or token-limit stop. Map it to a user message per value; do not show the raw enum.
  • Unbounded history. Every stored event is replayed into context. Large tool responses stored verbatim inflate every later call. Store a summary or an artifact reference and keep the bulk in the artifact service.
  • Sensitive data in the log. Events are durable and are shown to the model again. Redact tool responses before they become events, not after they are stored.

Trade-offs

Streaming partial events makes the interface feel fast but doubles the event volume on the wire and adds the draft-replacement logic above; for back-end jobs with no human watching, NONE is simpler. Storing every tool response gives a complete audit trail and exact replay, at the cost of context size and storage; storing references keeps both small but makes replay depend on the artifact store. Putting decisions on events from deterministic agents costs a few lines and pays for itself the first time someone asks why a refund was approved. And because state is derived from deltas, a durable session service must apply the event and its delta atomically; if your custom service writes them separately, a crash between the two leaves state and history disagreeing.

What to do next

  1. Pin your ADK Java version and read Event and EventActions in that tag; note any fields beyond those listed here.
  2. Write an event router like the one above and unit-test it with hand-built events: partial text, a final with calls, a final with text, a transfer, a confirmation request, an error.
  3. Search your tools for direct session-state writes and replace them with context state writes that land in stateDelta.
  4. Audit every state key for its prefix: session, user:, app: or temp:. Write the intended scope next to the constant.
  5. Make your custom agents emit decision events with their reasons in the content.
  6. Log invocationId, author and event id on every line so traces join to stored events.
  7. Restart the server mid-turn in a test and confirm the reloaded session has the state you expect.
  8. Keep learning: storing the event log in Postgres, the execution loop that produces events, callbacks that observe and rewrite them, streaming to clients and session context in depth.
Key takeaway: An ADK Java event is the unit of everything an agent does: messages, tool calls, results, hand-offs, state changes and errors. Partial events stream to the caller but are never stored; final events are appended and their stateDelta becomes session state, scoped by the app:, user: and temp: prefixes. Route events by payload, replace drafts with final text, surface confirmation requests, end the turn when the stream completes, and make every state change ride on a final event.