Callback Patterns
Practical patterns for ADK-TS callbacks — guardrails, caching, state management, logging, and conditional execution.
These patterns cover the most common reasons to use callbacks: enforcing policies, caching expensive calls, propagating data across steps, and controlling what runs. Each pattern shows the minimum code needed to solve a specific problem.
State writes inside callbacks are automatically tracked and persisted by the
SessionService. You don't need to do anything extra to save state changes
made inside a callback.
Guardrails and policy enforcement
Block unsafe prompts before they reach the LLM, and reject unauthorized tool calls before they execute. Both work by returning early from a before_ callback.
import { AgentBuilder, LlmResponse } from "@iqai/adk";
import type {
CallbackContext,
LlmRequest,
BaseTool,
ToolContext,
} from "@iqai/adk";
const { runner } = await AgentBuilder.create("policy_agent")
.withModel("gemini-2.5-flash")
.withBeforeModelCallback(
({
callbackContext,
llmRequest,
}: {
callbackContext: CallbackContext;
llmRequest: LlmRequest;
}): LlmResponse | null => {
const text = (llmRequest.contents ?? [])
.flatMap(c => c.parts ?? [])
.map(p => p.text ?? "")
.join(" ");
if (text.includes("confidential") || text.includes("internal policy")) {
return new LlmResponse({
content: {
role: "model",
parts: [{ text: "I can't help with that topic." }],
},
});
}
return null;
},
)
.withBeforeToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
): Record<string, any> | null => {
if (
tool.name === "transfer_funds" &&
toolContext.state.get("user_tier") !== "pro"
) {
return { error: "This operation requires a Pro account." };
}
return null;
},
)
.build();State management across steps
Use callbacks to read and write session state so data flows naturally between agent steps, LLM calls, and tool executions without manual wiring.
import { AgentBuilder } from "@iqai/adk";
import type { CallbackContext, BaseTool, ToolContext } from "@iqai/adk";
const { runner } = await AgentBuilder.create("stateful_agent")
.withModel("gemini-2.5-flash")
.withBeforeAgentCallback((callbackContext: CallbackContext): undefined => {
// Stamp the run start time so downstream callbacks can measure duration
callbackContext.state.set("run_start_ms", Date.now());
return undefined;
})
.withAfterToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
toolResponse: Record<string, any>,
): Record<string, any> | null => {
// Persist key results so later tool calls or the agent can reference them
if (toolResponse.order_id) {
toolContext.state.set("last_order_id", toolResponse.order_id);
}
return null;
},
)
.withAfterAgentCallback((callbackContext: CallbackContext): undefined => {
const startMs = callbackContext.state.get("run_start_ms") ?? Date.now();
console.log(`Run completed in ${Date.now() - startMs}ms`);
return undefined;
})
.build();Logging and observability
Add structured logs at each lifecycle stage for monitoring without changing any business logic.
import { AgentBuilder } from "@iqai/adk";
import type {
CallbackContext,
LlmRequest,
LlmResponse,
BaseTool,
ToolContext,
} from "@iqai/adk";
const { runner } = await AgentBuilder.create("monitored_agent")
.withModel("gemini-2.5-flash")
.withBeforeAgentCallback((callbackContext: CallbackContext): undefined => {
console.log(
`[agent:start] ${callbackContext.agentName} invocation=${callbackContext.invocationId}`,
);
return undefined;
})
.withBeforeToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
): Record<string, any> | null => {
console.log(
`[tool:start] ${tool.name} args=${JSON.stringify(args)} call=${toolContext.functionCallId}`,
);
return null;
},
)
.withAfterModelCallback(
({
callbackContext,
llmResponse,
}: {
callbackContext: CallbackContext;
llmResponse: LlmResponse;
}): LlmResponse | null => {
const chars = llmResponse.content?.parts?.[0]?.text?.length ?? 0;
console.log(
`[model:done] ${callbackContext.agentName} response=${chars} chars`,
);
return null;
},
)
.build();Tool result caching
Avoid redundant tool calls by checking a cache key in state before execution. If the result is cached, return it immediately; if not, save it after the tool runs.
import { AgentBuilder } from "@iqai/adk";
import type { BaseTool, ToolContext } from "@iqai/adk";
const { runner } = await AgentBuilder.create("caching_agent")
.withModel("gemini-2.5-flash")
.withBeforeToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
): Record<string, any> | null => {
const cacheKey = `cache:${tool.name}:${JSON.stringify(args)}`;
const cached = toolContext.state.get(cacheKey);
if (cached) {
console.log(`Cache hit for ${tool.name}`);
return cached;
}
return null;
},
)
.withAfterToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
toolResponse: Record<string, any>,
): Record<string, any> | null => {
const cacheKey = `cache:${tool.name}:${JSON.stringify(args)}`;
toolContext.state.set(cacheKey, toolResponse);
return null;
},
)
.build();Request and response modification
Mutate the LLM request to inject dynamic context, or modify the response to clean up formatting before the agent processes it further.
import { AgentBuilder } from "@iqai/adk";
import type { CallbackContext, LlmRequest, LlmResponse } from "@iqai/adk";
const { runner } = await AgentBuilder.create("enriched_agent")
.withModel("gemini-2.5-flash")
.withBeforeModelCallback(
({
callbackContext,
llmRequest,
}: {
callbackContext: CallbackContext;
llmRequest: LlmRequest;
}): LlmResponse | null => {
// Append per-user context to the system instruction
const userTier = callbackContext.state.get("user_tier") ?? "free";
if (llmRequest.config) {
const current = llmRequest.getSystemInstructionText() ?? "";
llmRequest.config.systemInstruction =
`${current}\nUser tier: ${userTier}`.trim();
}
return null;
},
)
.withAfterModelCallback(
({
callbackContext,
llmResponse,
}: {
callbackContext: CallbackContext;
llmResponse: LlmResponse;
}): LlmResponse | null => {
// Collapse multiple blank lines in the response text
if (llmResponse.content?.parts) {
llmResponse.content.parts = llmResponse.content.parts.map(part =>
part.text
? { ...part, text: part.text.replace(/\n{3,}/g, "\n\n") }
: part,
);
}
return null;
},
)
.build();Conditional step skipping
Return from a before_ callback to skip a step entirely — useful for maintenance modes, quota enforcement, or feature flags.
import { AgentBuilder, LlmResponse } from "@iqai/adk";
import type { CallbackContext, BaseTool, ToolContext } from "@iqai/adk";
import type { Content } from "@google/genai";
const { runner } = await AgentBuilder.create("conditional_agent")
.withModel("gemini-2.5-flash")
.withBeforeAgentCallback(
(callbackContext: CallbackContext): Content | undefined => {
if (callbackContext.state.get("maintenance_mode")) {
return {
role: "model",
parts: [
{ text: "Service is temporarily unavailable for maintenance." },
],
};
}
return undefined;
},
)
.withBeforeModelCallback(
({
callbackContext,
}: {
callbackContext: CallbackContext;
}): LlmResponse | null => {
if (callbackContext.state.get("use_cached_response")) {
const cached =
callbackContext.state.get("cached_response_text") ??
"No cached response.";
return new LlmResponse({
content: { role: "model", parts: [{ text: cached }] },
});
}
return null;
},
)
.withBeforeToolCallback(
(
_tool: BaseTool,
_args: Record<string, any>,
toolContext: ToolContext,
): Record<string, any> | null => {
if (toolContext.state.get("api_quota_exceeded")) {
return { error: "API quota exceeded. Please try again later." };
}
return null;
},
)
.build();Per-tool auth injection
Inject authentication tokens or enrich arguments for specific tools without changing the tool implementations.
import { AgentBuilder } from "@iqai/adk";
import type { BaseTool, ToolContext } from "@iqai/adk";
const { runner } = await AgentBuilder.create("auth_agent")
.withModel("gemini-2.5-flash")
.withBeforeToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
): Record<string, any> | null => {
// Only inject auth for tools that need it
if (["search_api", "fetch_data"].includes(tool.name)) {
const token = toolContext.state.get("api_token");
if (!token) {
return { error: "No API token found. Please authenticate first." };
}
args.authorization = `Bearer ${token}`;
}
return null;
},
)
.withAfterToolCallback(
(
tool: BaseTool,
args: Record<string, any>,
toolContext: ToolContext,
toolResponse: Record<string, any>,
): Record<string, any> | null => {
// Flag the result for summarization if the agent requested it
if (
tool.name === "search_api" &&
toolContext.state.get("auto_summarize")
) {
return { ...toolResponse, _needs_summary: true };
}
return null;
},
)
.build();