Prompt-based control is the anti-pattern ADK’s callback system retires. ‘Never reveal PII’, ‘always check the caller’s role’, ‘don’t refund more than $1000’ — written as instruction text, these are suggestions the model follows most of the time, which is the wrong reliability profile for a control you have to defend in an audit. Callbacks move enforcement out of the probabilistic layer into ordinary Python that runs every time and can be unit-tested: the model decides, the callbacks enforce. This article is about the mechanism — the six hooks and what each can see, the return protocol that lets a hook replace a step instead of merely watching it, the context objects, how several callbacks compose and in what order, and the specific ways callbacks go wrong in production. What policy you write inside them is a separate subject; this is the wiring underneath it.
Six hooks and one lattice
ADK exposes six callback slots, and they are plain constructor arguments on an agent — not a separate registry, not middleware wired into the Runner. before_agent_callback and after_agent_callback bracket the agent’s whole invocation. before_model_callback and after_model_callback bracket each LLM call — note the plural: a turn that goes model → tool → model fires them twice. before_tool_callback and after_tool_callback bracket each function call the model requests. The lattice is positioned where trust changes hands, bidirectional (a before-hook prevents a step, an after-hook repairs one), and composable — caching, redaction, and audit stack without knowing about each other.
from google.adk.agents import LlmAgent
agent = LlmAgent(
name="billing_agent",
model="gemini-2.0-flash",
tools=[lookup_charge, issue_refund],
before_agent_callback=check_account_active,
before_model_callback=[block_injection, cache_lookup],
after_model_callback=[cache_store, redact_pii],
before_tool_callback=validate_refund_args,
after_tool_callback=cap_result_size,
after_agent_callback=write_audit_record,
)The return protocol — None proceeds, a value replaces
Everything else about callbacks is detail; this is the contract. Return None and the wrapped step runs normally. Return a value of the hook’s expected type and ADK uses your value instead of running the step. That single rule turns an observer into an override, and it is why the same mechanism serves logging, caching, and policy enforcement without three different APIs.
| Hook | Receives | Return to short-circuit | Effect |
|---|---|---|---|
before_agent_callback | CallbackContext | types.Content | Agent never runs; your content is its output |
before_model_callback | CallbackContext, LlmRequest | LlmResponse | LLM is not called; your response is used |
after_model_callback | CallbackContext, LlmResponse | LlmResponse | Your response replaces the model’s |
before_tool_callback | tool, args, ToolContext | dict | Tool never executes; your dict is the result |
after_tool_callback | tool, args, ToolContext, response | dict | Your dict replaces the tool’s result |
after_agent_callback | CallbackContext | types.Content | Your content replaces the agent’s output |
Two consequences are worth internalising early. A short-circuit is invisible to the model as a failure: a denial dict returned from before_tool_callback arrives looking exactly like a normal tool result, so it should read like one. And falling off the end of a Python function returns None, which means ‘proceed’ — the safe default is also the accidental one.
CallbackContext and ToolContext — what a hook can see
Every callback receives a context object, and it is the only sanctioned way to reach the invocation’s surroundings. CallbackContext gives you state (the session’s mutable key/value bus, readable and writable), agent_name and invocation_id for correlating logs and traces, the user content that triggered the invocation, and artifact load/save for large payloads. Tool hooks get ToolContext, which extends that with the identifier of the specific function call being handled, artifact listing and memory search, and actions — the signal channel that lets a hook set things like skip_summarization, request a transfer to another agent, or escalate out of a loop.
The mental model is the same one that governs tool signatures elsewhere in ADK: model-chosen inputs are arguments, ambient inputs are context. A callback should never take the caller’s identity from something the model produced; it reads it from context.state, where your own code put it. The model can hallucinate an argument. It cannot forge state.
The agent boundary: preconditions and finalisation
before_agent_callback fires once, before any model or tool work, and it is the cheapest place to say no. Authorization, feature flags, quota checks, ‘is this account still active’ — anything that makes the entire invocation pointless belongs here, because returning content from it skips the run entirely and you pay for zero tokens. It is also where you seed state that later hooks and templated instructions will read.
from google.adk.agents.callback_context import CallbackContext
from google.genai import types
def check_account_active(ctx: CallbackContext) -> types.Content | None:
if not ctx.state.get("user:account_active"):
return types.Content(
role="model",
parts=[types.Part(text="Your account is inactive. "
"Please contact support to reactivate it.")],
)
ctx.state["turn:started_at"] = time.time()
return None # proceed
after_agent_callback is the mirror: it runs once as the agent finishes and is the natural home for the audit record and the latency metric. Note the asymmetry — the before-hook prevents work, the after-hook can only relabel it. If a decision has to stop something happening, it cannot live in an after-hook.
The model boundary: request in, response out
before_model_callback receives the fully assembled LlmRequest — contents, generation config, system instruction, tool declarations — and you can do three distinct things with it. Inspect and return None: log the prompt, count tokens. Mutate in place and return None: trim old turns to hold a context budget, append a directive. The object you were handed is the one that will be sent, so an in-place edit sticks — this is the mechanism people miss, reaching for a short-circuit when all they wanted was a tweak. Short-circuit by returning an LlmResponse: the provider is never called.
from google.adk.models import LlmRequest, LlmResponse
def block_injection(ctx: CallbackContext,
llm_request: LlmRequest) -> LlmResponse | None:
last = llm_request.contents[-1] if llm_request.contents else None
text = "".join(p.text or "" for p in (last.parts or [])) if last else ""
if looks_like_injection(text):
ctx.state["policy:blocked"] = True
return LlmResponse(content=types.Content(
role="model",
parts=[types.Part(text="I can't help with that request.")]))
return Noneafter_model_callback sees the LlmResponse before anything downstream does — before it becomes an event, before a requested function call is dispatched — which makes it the last deterministic gate on generated text. One gotcha dominates: in streaming mode a completion arrives as a series of partial responses, so a hook that assumes a finished answer runs on every fragment, sees half-formed text, and happily caches a stub. Check the partial flag and return early unless you want per-chunk behaviour. Any check that needs the whole answer is at odds with streaming it, and you must pick one.
The tool boundary: gate the call, then shape the result
before_tool_callback is where most real enforcement ends up, because tools are where an agent touches the world. It receives the tool object, the parsed argument dict, and a ToolContext; since one callback sees every tool, the first line is almost always a dispatch on tool.name. Mutating args in place and returning None rewrites the call — clamp a page size, inject a tenant filter the model should not control. Returning a dict denies it: the function never runs, and your dict becomes the tool result.
from google.adk.tools import BaseTool, ToolContext
def validate_refund_args(tool: BaseTool, args: dict,
tool_context: ToolContext) -> dict | None:
if tool.name != "issue_refund":
return None # not our business
amount = float(args.get("amount", 0))
if amount > 1000 and not tool_context.state.get("user:is_manager"):
return {"status": "denied",
"reason": "approval_required",
"threshold": 1000}
args["amount"] = round(amount, 2) # in-place rewrite
return NoneWrite the denial as a legible result, not an error string: a model that receives {'status': 'denied', 'reason': 'approval_required'} can explain and offer to escalate, whereas one that receives "ERROR" tends to retry the same call.
after_tool_callback receives the same tool and args plus the response, and its main job is protecting the context window — results flow straight back into the prompt, so an API returning 4,000 rows quietly eats your budget. Return a modified dict to cap it, and make the truncation announced ({'truncated': True, 'total_available': 4000}); silently trimming teaches the model that twenty is all there is. The same hook is the right place to strip fields the model has no business seeing, to offload an oversized payload to an artifact, and to record per-tool latency.
State is the bus between callbacks
Callbacks are independent functions, so anything one needs to tell another travels through context.state. A cache-lookup hook writes the key it computed; the cache-store hook on the way back reads it. An entry hook records a start timestamp; the exit hook turns it into a latency metric. A guardrail sets a blocked flag; the audit hook reports it.
State writes made through a context are recorded as a delta and committed with the event ADK emits for that step, which is what makes them durable rather than a local variable that evaporates. Two disciplines keep this from becoming a swamp. Namespace your keys — ADK’s prefixes distinguish scopes, and temp: marks values that should not survive the invocation, exactly right for a cache key. And treat the keys as a contract: they are the interface between your callbacks and your tools, so document them in one place. The classic bug is a hook that reads user:is_manager while the code setting it writes user:isManager; nothing raises, the check simply always denies.
Ordering and composition when several callbacks apply
ADK accepts either a single callable or a list in each callback slot. A list runs in order and the first callback to return a non-None value wins — the rest of that slot is skipped along with the wrapped step. That is a short-circuit chain, exactly like middleware, and it dictates the ordering.
Put cheap, decisive checks first: a policy block that costs a regex should run before a cache lookup that costs a vector search. Put pure observers last, or accept that they will not run when something upstream short-circuits — which is why audit logging belongs in after_agent_callback, outside the chains that can be skipped, rather than in after_model_callback, which never fires for a call that was blocked before it happened.
| Order | Callback | Why here |
|---|---|---|
| 1 | Input policy check | Cheapest; a block makes the rest moot |
| 2 | Cache lookup | Skips the provider call on a hit |
| 3 | Context trimming | Mutates in place; must run on real calls |
| 4 | Prompt sampling | Pure observer; runs only if nothing blocked |
Be explicit about nesting too. Within one turn the order is before_agent, then before_model / after_model per model call, then before_tool / after_tool per requested function, and finally after_agent. Tool hooks always sit inside a model round trip, never beside one.
Scope: callbacks are per-agent, not per-app
The most common architectural surprise is that callbacks are bound to the agent instance you set them on. In a multi-agent system, a before_model_callback on the coordinator does not fire for model calls made by a sub-agent after a transfer. Your carefully written injection filter simply is not there once control moves to the specialist agent.
There are three ways out. Attach the same function to every agent explicitly — verbose, but obvious and easy to vary per agent. Write a factory that builds agents with a standard callback bundle already applied, which is the pattern that scales past a handful of agents. Or, in current ADK versions, use the plugin mechanism registered on the Runner, which hooks the same lifecycle points application-wide and is the intended home for genuinely global concerns like tracing, metrics, and a universal input filter. The rule of thumb: if the control is about this agent’s job it is a callback; if it is about every agent in the process, it wants to be global.
Caching at the model boundary — a worked example
Caching shows the whole mechanism at once: a before-hook that short-circuits, an after-hook that observes, and state carrying the key between them. On a hit the provider call disappears — while the agent-level authorization and audit hooks still run, because they sit outside the cached boundary. One concern optimises the hot path without weakening another.
import hashlib
def cache_lookup(ctx: CallbackContext,
llm_request: LlmRequest) -> LlmResponse | None:
key = hashlib.sha256(repr(llm_request.contents).encode()).hexdigest()
ctx.state["temp:cache_key"] = key
hit = CACHE.get(key)
if hit is None:
return None # miss: call the model
return LlmResponse(content=types.Content(
role="model", parts=[types.Part(text=hit)]))
def cache_store(ctx: CallbackContext,
llm_response: LlmResponse) -> LlmResponse | None:
if llm_response.partial: # never cache a stream fragment
return None
key = ctx.state.get("temp:cache_key")
text = "".join(p.text or "" for p in (llm_response.content.parts or []))
if key and text:
CACHE[key] = text
return None # observe only
The honest caveat: hashing raw contents only hits on an exact repeat, and a response containing a function call needs different handling than plain text. Both are solvable — normalise before hashing, skip caching tool-calling responses — but decide deliberately rather than discovering it in production.
Failure modes: the ways callbacks bite
Callbacks run on the hot path of every turn, in code that is easy to get wrong precisely because it usually does nothing. The recurring failures:
| Failure | Symptom | Fix |
|---|---|---|
| Callback raises | Whole invocation fails from a hook meant to help | try/except; fail open or closed on purpose |
| Swallows the response | Returns a value on a path that meant ‘proceed’; output vanishes | Return None on every non-acting branch |
| Slow hook | P95 regression with no model or tool change | Time every callback; move I/O off the path |
| Blocking I/O in async | Event loop stalls, concurrency collapses | Use async def hooks and await |
| Fires on partials | Redaction per chunk; cache stores a stub | Return early unless the response is complete |
| Shared mutable state | Races across parallel tool calls | Namespace per call; avoid read-modify-write |
The subtlest is the second. Because the return value is the control signal, a callback that returns something on a branch you did not think about silently replaces a real model response with whatever that branch produced — often an empty string. No exception, no log line; the agent just gets quieter. Treat return None as a statement you write deliberately.
Testing and observing callbacks
The reason to prefer callbacks over prompt instructions is determinism, and determinism is only worth something if you verify it. A callback is an ordinary function with an ordinary signature, so test it directly: build a fake context with the state you care about, hand it a synthetic request, args dict, or response, and assert on what comes back. A policy hook wants a test per branch — allowed, denied, malformed input — and a rewriting hook should assert on the mutated object as well as the return value, because those are two different effects.
In production, instrument three things. Did it fire? A span or counter per callback keyed by invocation ID, so a trace shows which hooks ran and in what order. Did it act? Count short-circuits separately from pass-throughs; a guardrail whose block rate silently drops to zero is a broken guardrail, and a cache whose hit rate collapses is a normalisation bug. What did it cost? Per-callback latency — the lattice sits between your user and every answer, and a hook that grows from two milliseconds to two hundred shows up in the P99 long before anyone thinks to suspect it.
None and the wrapped step proceeds; return a typed value and it replaces the step. That single rule covers observation, in-place rewriting, and outright short-circuiting, which is why logging, caching, argument validation, and policy enforcement all use the same mechanism. Everything else is discipline: read ambient inputs from CallbackContext or ToolContext rather than from anything the model produced, pass information between hooks through namespaced session state, order a callback list cheapest-and-most-decisive first because the first non-None return wins, and remember that callbacks bind to one agent — a sub-agent after a transfer does not inherit them. The failure modes are equally consistent: a hook that raises kills the invocation, a hook that returns a value on an unconsidered branch silently swallows the model’s answer, and every hook taxes every request. Write them small, return None on purpose, test each branch, and time all of them.