The One Instruction You Never Want Reaching the Model, and the Six Lines That Stop It

Configuration ends where callbacks begin. ADK gives you six precise points to inspect, modify, or block what your agent is about to do, and one return-value rule turns every one of them into a guardrail.

Rick Hightower

Configuration shapes an agent from the outside. ADK callbacks let you reach into the running agent, inspect the exact request about to hit the model, and refuse it before a single token is spent. This is where guardrails live.

In this article: You will learn the six callback points ADK gives you, the three before-and-after pairs they form around the agent loop, and the single return-value contract that lets any callback either quietly observe or completely take over. You will wire a real guardrail that blocks destructive requests before they reach the model, see the same hooks double as caching and logging, and learn the one parameter-naming trap that produces a baffling TypeError. By the end you can stop configuring your agent and start controlling it.

Up to now you have shaped your agent from the outside. You wrote instructions, gave it tools, arranged it into a pipeline, and chose where its state lives. All of that is configuration: you set the agent up, then let it run.

Sometimes "let it run" is not good enough. You need to look at the exact request about to hit the model and stop it if it contains something forbidden. You need to validate a tool's arguments before a destructive operation fires. You need to cache an expensive result, or log every tool call for an audit trail. None of that is configuration. It is interception, and it happens while the agent runs.

ADK callbacks are how you do it: plain functions you attach to an agent that the framework calls at specific, predefined moments during execution. This is the first time you reach into the running agent and change what it does, rather than just deciding what it is. Because callbacks fit cleanly into the agent's event loop, the rules about when they fire and what they can do are precise rather than magical.

Six points, three pairs

There are six callback points, and they come in three before-and-after pairs wrapped around the three things an agent does: run, call a model, and use a tool.

A mindmap of the six ADK callback points grouped into three before-and-after pairs: the agent pair wraps the whole turn and works on any agent, the model pair wraps each LLM call on an LlmAgent, and the tool pair wraps each tool run on an LlmAgent.

The agent pair, before_agent_callback and after_agent_callback, wraps the agent's entire handling of a request. The before fires after the context is set up but before the core logic starts. The after fires once the agent has produced its result. These are available on any agent, including workflow agents.

The model pair, before_model_callback and after_model_callback, wraps each call to the LLM. The before fires just before the request goes to the model. The after fires the moment the response comes back, before the agent does anything with it. These are specific to LlmAgent.

The tool pair, before_tool_callback and after_tool_callback, wraps each tool execution. The before fires after the model has decided to call a tool but before the tool actually runs. The after fires once the tool returns. These are also LlmAgent-specific.

Map these onto the agent loop and they slot in exactly where you would expect. Agent callbacks bracket the whole turn, model callbacks bracket each model round-trip inside it, and tool callbacks bracket each tool run. Nothing mysterious, just hooks at the seams of the loop.

The contract that makes callbacks powerful

Here is the single rule that turns callbacks from passive observers into active controllers. Every callback can either step aside or take over, and which one it does is decided by what it returns.

A flowchart showing the return-value contract: a callback fires at a seam in the loop, and if it returns None the step proceeds as a passthrough using any in-place edits, while if it returns a value the framework uses that value as the result and skips the real work entirely.

Return None or nothing, and the callback was a passthrough. The agent continues exactly as it would have, possibly using any modifications you made to the request or response in place. This is the observe-and-tweak mode: log something, adjust an argument, then let the step proceed.

Return an actual value and you short-circuit the step. The framework treats your returned value as the result of that step and skips the real work entirely. What you return depends on the callback: a before_model_callback returns an LlmResponse to skip the model call and use your response instead, a before_tool_callback returns a dict to skip the tool and use that as its result, and a before_agent_callback returns Content to skip the agent's whole run.

That short-circuit is the mechanism behind every guardrail you will ever write. Inspect what is about to happen. If it is fine, return None and let it through. If it is not, return a canned result, and the dangerous step never runs.

A guardrail: blocking a forbidden instruction

Make it concrete. Suppose you never want your fixer agent to act on a request to delete files or run destructive shell commands, no matter how the user phrases it. A before_model_callback is the right place: it sees the request just before it reaches the model, so it can refuse before the model ever gets a chance to comply.

from google.adk.agents import LlmAgent
from google.adk.models import LlmResponse
from google.genai import types

FORBIDDEN = ("delete everything", "rm -rf", "drop table")


def block_destructive_requests(callback_context, llm_request):
    """Inspect the outgoing request; block it if it asks for something destructive."""
    # Pull the latest user text out of the request being sent to the model.
    last_text = ""
    if llm_request.contents:
        for part in llm_request.contents[-1].parts:
            if part.text:
                last_text += part.text.lower()

    if any(phrase in last_text for phrase in FORBIDDEN):
        # Returning an LlmResponse short-circuits the model call entirely.
        return LlmResponse(
            content=types.Content(
                role="model",
                parts=[types.Part(text="I can't help with destructive operations on a repo.")],
            )
        )
    # Returning None lets the (possibly modified) request proceed to the model.
    return None


fixer = LlmAgent(
    model="gemini-flash-latest",
    name="fixer",
    instruction="You diagnose failing Python tests and propose fixes.",
    tools=[run_tests],
    before_model_callback=block_destructive_requests,
)

Read what happens. On a normal request, the callback inspects the text, finds nothing forbidden, returns None, and the request flows to the model untouched. On a request containing a forbidden phrase, it returns an LlmResponse, and the framework uses that as the model's answer without ever calling the model. The dangerous prompt never reaches Gemini, and you spent no tokens finding that out.

A sequence diagram contrasting two requests: a destructive prompt is caught by the before_model_callback, which scans for forbidden phrases and returns a canned LlmResponse so the model is never called, while a normal question passes the scan, the callback returns None, and the request flows to Gemini for a real answer.

You attach the callback by passing the function to the matching parameter on the agent, before_model_callback=... here. Each of the six points has its own parameter, and you wire in only the ones you need. Ten lines, and a class of requests can no longer get through.

Beyond blocking: modify, cache, and log

Guardrails are the headline use, but the same six points cover a lot more, all through the same return-or-passthrough contract.

A flowchart showing four uses of the same six callback points under one contract: a guardrail returns a canned result to block a step, modification edits the request or response and returns None, caching has the before return a stored value on a key hit while the after stores the fresh result, and logging records the name, args, and id then returns None.

Modification is the passthrough case with edits. A before_model_callback can inject a dynamic instruction into the request based on state, for example appending the user's language preference, then return None so the modified request proceeds. An after_model_callback can reformat or censor the model's response before the agent uses it. A before_tool_callback can rewrite a tool's arguments, and an after_tool_callback can reshape its result.

Caching pairs a before and an after. In a before_tool_callback, compute a cache key from the arguments and check state for a stored result. If it is there, return it and skip the tool. If it is not, return None to let the tool run, and use the after_tool_callback to store the fresh result under that key. You could cache test runs so that asking about the same untouched project twice does not shell out to pytest twice.

Logging is the pure observer case. A callback that logs the agent name, the tool name, the invocation id, and the arguments, then returns None, gives you a detailed execution trace without changing behavior at all. It is also why callbacks reading and writing state matters: a write you make in a callback persists through the same commit-on-event discipline as any other state change, carried in the state_delta when the event is processed.

The trap that produces a confusing error

There is one mistake that every Python ADK developer hits at least once, and it produces an error that does not obviously point at the cause.

Gotcha. Callback parameter names must match the documented names exactly, because ADK passes callback arguments by keyword, not by position. An agent callback takes callback_context. A model callback takes callback_context and llm_request, or llm_response for the after. A tool callback takes tool, args, and tool_context, plus tool_response for the after. Rename callback_context to ctx to be tidy and you get a runtime TypeError, not a clear "wrong parameter name" message. When a callback blows up with a TypeError about unexpected keyword arguments, check the parameter names against the documented ones first.

This is worth internalizing because the error surfaces at runtime, deep in the framework's call, rather than at definition time where the mistake actually is. The fix is trivial once you know it: use the exact documented names and resist the urge to shorten them.

A note on scope

Everything here attaches a callback to one specific agent. That is the right tool when you want to shape the behavior of a single agent: this fixer should refuse destructive prompts, that reviewer should log its verdicts.

A flowchart contrasting local callbacks, which attach to one agent and shape that one agent's behavior, against Plugins, which register once on the Runner so their hooks apply everywhere, showing that cross-cutting concerns escalate from per-agent callbacks to system-wide policy.

Some concerns are not about one agent at all. Logging every tool call across your entire system, enforcing one security policy everywhere, and caching universally: those are cross-cutting, and configuring the same callback on every agent by hand is both tedious and easy to get wrong. ADK has a global counterpart for exactly that, called Plugins, which register once on the Runner and apply their hooks everywhere. They are the natural escalation from per-agent callbacks to system-wide policy.

Do this today

  • Pick one agent and add a guardrail. Write a before_model_callback that scans the latest user text for a phrase you never want acted on, and return an LlmResponse to block it.
  • Prove the short-circuit. Send a forbidden request and confirm the canned refusal comes back with no model call and no tokens spent, then send a normal request and watch it pass.
  • Add a logging callback. Attach a callback that records the agent name, tool name, invocation id, and arguments, then returns None, and read the trace it produces.
  • Try a cache pair. Use a before_tool_callback to return a stored result on a key hit and an after_tool_callback to store fresh results, so a repeated expensive tool call runs once.
  • Guard against the TypeError. Copy the exact documented parameter names into every callback signature, and never rename callback_context to ctx.

You crossed the line from configuring to controlling

You now know the six callback points and the three pairs they form, the return-or-passthrough contract that lets a callback either observe or take over, and how to wire a real guardrail that blocks destructive requests before they reach the model. You saw the same points double as caching, modification, and logging hooks, and you know the one parameter-naming trap that produces a baffling TypeError.

Configuration ends where callbacks begin. The moment you needed to inspect the exact request about to hit the model and refuse it, you stopped describing your agent and started steering it. That is a different kind of power, and it is the foundation of every safety, audit, and policy layer you will ever build on top of an agent. The instruction you never wanted reaching the model now never will, and it took six lines to guarantee it.


This is Part 7 of "Building with Google's Agent Development Kit (ADK)," an eleven-part guide that takes a developer from zero to a hardened, observable, production-deployed agent.