Event Patterns
Practical ADK-TS event patterns — chat loops, tool activity indicators, agent transfer tracking, state replay, and audit logs built on the real framework APIs.
These patterns address concrete problems you encounter when building with ADK-TS events. Each one is grounded in the actual APIs — runner.runAsync(), event.actions, session.events, and the helper methods on Event.
Multi-turn chat loop
A production chat loop needs to handle streaming chunks, tool activity, agent transfers, and the final reply in a single pass. This pattern separates each concern without nested conditionals:
import { AgentBuilder } from "@iqai/adk";
const { runner } = await AgentBuilder.create("assistant")
.withModel("gemini-2.5-flash")
.withInstruction("You are a helpful assistant.")
.build();
async function chat(userId: string, sessionId: string, message: string) {
let streamBuffer = "";
for await (const event of runner.runAsync({
userId,
sessionId,
newMessage: { role: "user", parts: [{ text: message }] },
})) {
// 1. Accumulate streaming chunks for real-time display
if (event.partial && event.content?.parts?.[0]?.text) {
streamBuffer += event.content.parts[0].text;
onStreamChunk(streamBuffer);
continue;
}
// 2. Show tool activity while the agent is working
const calls = event.getFunctionCalls();
if (calls.length > 0) {
onToolStart(calls.map(c => c.name));
continue;
}
const responses = event.getFunctionResponses();
if (responses.length > 0) {
onToolEnd(responses.map(r => r.name));
continue;
}
// 3. React to state changes as they happen
if (Object.keys(event.actions.stateDelta).length > 0) {
onStateChange(event.actions.stateDelta);
}
// 4. Display the completed reply
if (event.isFinalResponse()) {
const text = event.content?.parts?.[0]?.text ?? streamBuffer;
onFinalReply(text.trim());
streamBuffer = "";
}
}
}Tool activity indicator
When an agent makes tool calls, users need feedback that something is happening. Function-call and function-response events bracket the tool execution window — use them to drive a loading state:
for await (const event of runner.runAsync(request)) {
const calls = event.getFunctionCalls();
const responses = event.getFunctionResponses();
if (calls.length > 0) {
const names = calls.map(c => c.name).join(", ");
setStatus(`Using tools: ${names}…`);
} else if (responses.length > 0) {
setStatus("Processing results…");
} else if (event.isFinalResponse()) {
setStatus("idle");
}
}For long-running tools specifically, the longRunningToolIds field appears on a final-response event before the tool finishes. This is the signal to show a persistent background indicator rather than a brief spinner:
for await (const event of runner.runAsync(request)) {
if (event.longRunningToolIds && event.isFinalResponse()) {
showBackgroundProcessingBanner(
`Running in background: ${[...event.longRunningToolIds].join(", ")}`,
);
}
if (event.isFinalResponse() && event.content?.parts?.[0]?.text) {
displayReply(event.content.parts[0].text);
}
}Agent transfer tracking
In multi-agent systems, event.actions.transferToAgent appears the moment the active agent hands off to another. Watch for it to update a "currently talking to" indicator in your UI:
let activeAgent = "assistant";
for await (const event of runner.runAsync(request)) {
if (event.actions.transferToAgent) {
activeAgent = event.actions.transferToAgent;
updateAgentLabel(activeAgent);
}
if (event.isFinalResponse() && event.content?.parts?.[0]?.text) {
displayMessage({ author: event.author, text: event.content.parts[0].text });
}
}event.author identifies which agent produced each event. In a multi-agent conversation this lets you render a distinct name or avatar for each agent:
for await (const event of runner.runAsync(request)) {
if (!event.isFinalResponse()) continue;
const text = event.content?.parts?.[0]?.text;
if (!text) continue;
appendMessage({
author: event.author === "user" ? "You" : event.author,
text,
// event.branch is the dotted path, e.g. "coordinator.billing"
agentPath: event.branch,
});
}Reactive state updates
Rather than re-fetching session.state after each turn, read event.actions.stateDelta to learn exactly what changed. This is efficient for driving UI components that depend on individual state keys:
for await (const event of runner.runAsync(request)) {
const delta = event.actions.stateDelta;
// Only update the parts of the UI that changed
if ("task_status" in delta) setTaskStatus(delta.task_status);
if ("result_count" in delta) setResultBadge(delta.result_count);
if ("app:theme" in delta) applyTheme(delta["app:theme"]);
}Keys prefixed with temp_ are ephemeral and not persisted. Skip them if you
are mirroring state to durable storage.
Artifact save notifications
event.actions.artifactDelta records every file saved during a turn. Use it to refresh previews or notify users without polling:
for await (const event of runner.runAsync(request)) {
for (const [filename, version] of Object.entries(
event.actions.artifactDelta,
)) {
if (filename.endsWith(".pdf")) {
refreshPdfPreview(filename, version);
} else if (filename.endsWith(".csv")) {
refreshDataTable(filename, version);
}
}
}Rebuild state from session history
session.events is the full chronological record of every event in a session. Walk the stateDelta fields in order to reconstruct what session.state looked like at any point — useful for debugging, auditing, or rewinding to a past turn:
import type { Session } from "@iqai/adk";
function rebuildStateAt(
session: Session,
beforeTimestamp: number,
): Record<string, any> {
const state: Record<string, any> = {};
for (const event of session.events) {
if (event.timestamp >= beforeTimestamp) break;
if (event.actions?.compaction) continue; // skip summary events
for (const [key, value] of Object.entries(event.actions.stateDelta)) {
if (!key.startsWith("temp_")) {
state[key] = value;
}
}
}
return state;
}Session audit log
session.events gives you a complete, ordered trace of everything that happened — who said what, which tools were called, and what state changed. This pattern builds a human-readable log from that history:
import type { Session } from "@iqai/adk";
function buildAuditLog(session: Session): string[] {
const lines: string[] = [];
for (const event of session.events) {
const ts = new Date(event.timestamp * 1000).toISOString();
const calls = event.getFunctionCalls();
const responses = event.getFunctionResponses();
if (calls.length > 0) {
for (const call of calls) {
lines.push(`[${ts}] ${event.author} called tool "${call.name}"`);
}
} else if (responses.length > 0) {
for (const r of responses) {
lines.push(`[${ts}] Tool "${r.name}" returned a result`);
}
} else if (event.content?.parts?.[0]?.text && !event.partial) {
lines.push(`[${ts}] ${event.author}: ${event.content.parts[0].text}`);
}
const stateKeys = Object.keys(event.actions.stateDelta);
if (stateKeys.length > 0) {
lines.push(`[${ts}] State updated: ${stateKeys.join(", ")}`);
}
}
return lines;
}Next steps
🔍 Working with Events
Core API reference for reading content, detecting final responses, and filtering the event stream
⚡ Event Actions
Full reference for stateDelta, artifactDelta, transferToAgent, and all other action fields
⚡ Streaming
textStreamFrom, collectTextFrom, and streamTextWithFinalEvent utilities