TypeScriptADK-TS

Working with Events

Read text content, detect final responses, inspect tool calls, track state changes, and filter event streams from ADK-TS agents.

The runner.runAsync() method returns an async generator of Event objects. This page shows the practical patterns for working with that stream: how to read different types of content, when an event is ready to display, and how to inspect side-effect signals like state changes and artifact saves.

Reading text content

Text lives in event.content.parts as an array of Part objects. Each text part has a text string:

for await (const event of runner.runAsync(request)) {
  // Only write streaming deltas — the final non-partial event contains
  // the full accumulated text, so writing both would duplicate output.
  if (event.partial && event.content?.parts?.[0]?.text) {
    process.stdout.write(event.content.parts[0].text);
  }
}

During streaming, the LLM emits many events with partial: true, each carrying a text delta. Once generation finishes, a final non-partial event is emitted containing the full accumulated text. To avoid duplicating output, only process one of the two — use the streaming utilities which handle this automatically, or filter by event.partial yourself.

Detecting the final response

event.isFinalResponse() returns true when an event is safe to display as the agent's completed reply. Internally it checks that the event is not a streaming chunk, not a function call, and not a function response:

import { AgentBuilder } from "@iqai/adk";

const { runner } = await AgentBuilder.create("assistant")
  .withModel("gemini-2.5-flash")
  .withInstruction("You are a helpful assistant.")
  .build();

for await (const event of runner.runAsync({
  userId: "user-1",
  sessionId: "session-1",
  newMessage: {
    role: "user",
    parts: [{ text: "Summarise quantum computing." }],
  },
})) {
  if (event.isFinalResponse()) {
    const text = event.content?.parts?.[0]?.text;
    if (text) console.log("Final answer:", text);
  }
}

isFinalResponse() also returns true in two special cases:

  • event.actions.skipSummarization is set — the raw tool result should be shown directly without an LLM summary
  • event.longRunningToolIds is set — a background tool has started; signal a loading state to the user

Inspecting tool calls and results

Tool interactions produce two distinct event shapes. A function-call event carries what the agent wants to invoke; a function-response event carries what the tool returned.

getFunctionCalls() returns an array of { name, args } objects — one entry per tool the agent is invoking in this turn:

for await (const event of runner.runAsync(request)) {
  const calls = event.getFunctionCalls();
  if (calls.length > 0) {
    for (const call of calls) {
      console.log(`Tool requested: ${call.name}`);
      console.log("Args:", call.args);
    }
  }
}

getFunctionResponses() returns an array of { name, response } objects — one entry per completed tool result:

for await (const event of runner.runAsync(request)) {
  const responses = event.getFunctionResponses();
  if (responses.length > 0) {
    for (const result of responses) {
      console.log(`Tool "${result.name}" returned:`, result.response);
    }
  }
}

Tracking state changes

State changes travel in event.actions.stateDelta. Each entry is a key-value pair the agent or a tool wrote to context.state during this turn. Reading these deltas lets you update your UI reactively without re-fetching the full session:

for await (const event of runner.runAsync(request)) {
  const delta = event.actions.stateDelta;
  if (Object.keys(delta).length > 0) {
    for (const [key, value] of Object.entries(delta)) {
      console.log(`State: ${key} = ${JSON.stringify(value)}`);
    }
  }
}

Keys prefixed with temp_ are ephemeral and are not persisted to the session. Keys prefixed with app: or user: have broader scope. See State for the full scoping rules.

Tracking artifact saves

When an agent or tool saves a file, event.actions.artifactDelta records the filename and the version number that was written:

for await (const event of runner.runAsync(request)) {
  const saved = event.actions.artifactDelta;
  if (Object.keys(saved).length > 0) {
    for (const [filename, version] of Object.entries(saved)) {
      console.log(`Saved: ${filename} (v${version})`);
    }
  }
}

Filtering events

A few common filters worth applying in your loop:

for await (const event of runner.runAsync(request)) {
  // Only events from agents (not the user message)
  if (event.author === "user") continue;

  // Only complete text (skip streaming chunks)
  if (event.partial) continue;

  // Only events with text content
  const text = event.content?.parts?.[0]?.text;
  if (!text) continue;

  console.log(`[${event.author}] ${text}`);
}

For post-run analysis, filter session.events by invocation ID to isolate a single interaction:

const run = session.events.filter(e => e.invocationId === myInvocationId);
const agentReplies = run.filter(
  e => e.author !== "user" && e.isFinalResponse(),
);

Handling errors

Event inherits errorCode and errorMessage from LlmResponse. When the LLM or a safety filter rejects a request, the resulting event carries these fields:

for await (const event of runner.runAsync(request)) {
  if (event.errorCode) {
    console.error(`Error ${event.errorCode}: ${event.errorMessage}`);
    break;
  }

  if (event.isFinalResponse()) {
    // normal processing
  }
}

Identifying the agent that spoke

In multi-agent systems, event.author tells you which agent produced the event. Compare it against known agent names to route display logic:

for await (const event of runner.runAsync(request)) {
  if (!event.isFinalResponse()) continue;
  const text = event.content?.parts?.[0]?.text;
  if (!text) continue;

  if (event.author === "ResearchAgent") {
    displayResearchResult(text);
  } else if (event.author === "SummaryAgent") {
    displaySummary(text);
  } else {
    displayGenericReply(text);
  }
}

Next steps