TypeScriptADK-TS

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();

Next steps