Why architecture matters here
Prompt-based control is the anti-pattern this architecture retires. 'Never reveal PII', 'always check the user's role', 'don't call the refund tool for amounts over $1000' — encoded as instruction text, these are suggestions the model follows most of the time, which is precisely the wrong reliability profile for a security or compliance control. A jailbreak, a confusing context, or simple sampling variance defeats them. Callbacks move enforcement out of the probabilistic layer and into deterministic code that runs every time, cannot be argued with, and can be unit-tested. The architectural principle is: the model decides, the callbacks enforce.
Three properties make the lattice the right shape for this. It is positioned: hooks sit at exactly the boundaries where trust changes — entering the agent (is this caller allowed?), leaving for the model (is this input safe to send, have we seen it before?), returning from the model (does this output leak anything?), entering a tool (are these arguments valid and permitted?), leaving a tool (is this result too big, does it need shaping?). It is bidirectional: a before-hook can prevent a step, an after-hook can repair one. It is composable: caching, guardrails, and audit are separate callbacks that stack without knowing about each other, so you add a control without rewriting the agent.
The trade-off is that callbacks run on the hot path of every turn. A slow callback taxes every request; a buggy callback that swallows exceptions hides failures; a callback that mutates shared state without discipline creates races across parallel tool calls. So the same mechanism that gives you deterministic control also gives you a new surface for latency and subtle bugs — which is why understanding the context object, the short-circuit contract, and the execution order is not optional.
The architecture: every piece explained
Top row: the model-facing hooks. before_agent_callback fires once when the agent is invoked, before any model or tool work — the place for authorization, precondition checks, and seeding session state. before_model_callback fires before each LLM request with the assembled LlmRequest; here you inject guardrails (return a canned response to block), consult a semantic cache (return a cached LlmResponse to skip the call), or rewrite the request (trim context, add a system directive). The LLM call itself runs only if no before-hook short-circuited. after_model_callback fires with the LlmResponse — redact PII, rewrite disallowed content, or record the raw completion for audit before it flows onward.
Middle row: the tool-facing hooks. When the model requests a function call, before_tool_callback fires with the tool and its parsed arguments — validate the arguments, enforce authorization on the specific action ('refunds over $1000 require a manager'), or return a dict to short-circuit and skip execution entirely (a mock, a cached result, or a policy denial). The tool executes if allowed. after_tool_callback fires with the raw result — cap its size, reshape it, strip fields, or annotate it before it re-enters the model's context. Finally after_agent_callback fires once as the agent finishes — finalize, emit an audit record, write summary state.
Bottom rows: the machinery. Every callback receives a CallbackContext (or ToolContext for tool hooks), which exposes session state (read and write — the durable bus between turns), actions (signals like transfer or escalate), and artifact access (save and load files). The short-circuit return is the core contract: return None and the wrapped step proceeds normally; return a typed object (an LlmResponse from a model hook, a result dict from a tool hook, content from an agent hook) and ADK uses your value instead of running the step. The ops strip is what you watch: traces that show which callbacks fired and how they altered the request, cache hit rate, block/redact counts, and per-callback latency so a slow hook is visible before it becomes a P99 regression.
End-to-end flow
Follow a support request through a billing agent with four callbacks registered: an authz check on entry, a guardrail-plus-cache on the model boundary, an argument validator on the refund tool, and an audit logger on exit.
A user asks to refund a charge. before_agent fires: it reads the caller's identity from context state, confirms they own an active account, and — since they do — returns None, letting the agent proceed. The agent assembles its first LLM request. before_model fires: the guardrail scans the input for prompt-injection and disallowed content (clean), then hashes the normalized prompt and checks a semantic cache (miss), so it returns None and the model runs. The model responds with a function call: issue_refund(charge_id, amount=1400). after_model fires and records the raw completion for audit, unchanged.
ADK now dispatches the tool, so before_tool fires with the parsed arguments. The validator sees amount=1400, and policy says refunds over $1000 require manager approval, which this session lacks. It short-circuits: it returns {'status': 'denied', 'reason': 'approval_required', 'threshold': 1000} — the issue_refund function never executes, no money moves. That dict re-enters the model as the tool result. after_tool would fire on a real result to cap or shape it; here it passes the denial through. The model, seeing the denial, generates a message explaining that a manager must approve. before_model fires again for this second turn (guardrail clean, cache miss); after_model runs a PII redactor over the completion (nothing to redact). Finally after_agent fires: it writes an audit record — caller, requested amount, decision, callbacks that acted — to durable storage.
Notice what the model never got to decide: whether the caller was authorized, whether the large refund could proceed, or whether the audit happened. Those were enforced deterministically around it. Now consider a repeat: the same user asks the same question a minute later. This time before_model's cache check hits — it returns the stored LlmResponse and the expensive model call is skipped entirely, shaving latency and cost, while the authz and audit callbacks still run because they sit outside the cached boundary. The lattice let one concern (caching) optimize the hot path without weakening another (authorization) — which is the whole point of positioning hooks at distinct trust boundaries.