The short answer
before_tool_callback is called just before a tool’s run_async, after the model has generated a function call for it. It receives tool, args and tool_context, and can inspect or change the arguments and run authorization checks. Return None and the tool runs, with the possibly modified arguments. Return a dictionary and the tool is skipped; the dictionary becomes the tool call’s result.1
For guardrails that must apply to every agent, ADK’s docs recommend plugins over callbacks: a plugin registered once applies to every agent, tool and model call managed by that runner, and its callbacks run before, and can override, the agent-level ones.2
Deedglass
A tool call meets your rules before it runs
Asks to run
DELETE FROM visits;
Deletes every row in visits
Deedglass checks your rules
- Reading dataAllowed
- Deleting a whole table (DROP, TRUNCATE)Never
- Changing or deleting rows without saying which (WHERE)This one
- Other changesWait for a person
When the callback runs
| Callback | Called | Arguments |
|---|---|---|
before_tool_callback |
After the model asks for the tool, before run_async |
tool, args, tool_context |
after_tool_callback |
After run_async completes successfully |
tool, args, tool_context, tool_response |
Both are fields on LlmAgent.1 Plugins add an on_tool_error hook as well.2
What the return value means
From the docs:1
| Callback returns | Effect |
|---|---|
before: None |
The tool runs, with the (possibly modified) args |
before: a dictionary |
The tool is skipped; the dictionary is used as the result of the tool call |
after: None |
The original tool_response is used |
after: a new dictionary |
It replaces the original tool_response |
The docs add a Python-specific note to the first row: “only None lets the tool run.”1 That makes the dictionary return a block and a reply at once. The model doesn’t see an exception; it sees a tool result. So write that result for the model: say what happened and what to do instead.
A guardrail callback
A sketch for a database tool, using the documented signature (check names against your installed ADK; we didn’t run it against a live model):
Code Program code, for developers. The text around it says what it does.
BLOCKED = ("DROP ", "TRUNCATE ", "DELETE FROM")
def sql_guard(tool, args, tool_context):
if tool.name != "run_sql":
return None
query = str(args.get("query", "")).upper()
if any(word in query for word in BLOCKED):
return {
"status": "blocked",
"reason": "Destructive SQL needs a person. Tell the user what you wanted to run and why.",
}
if "LIMIT" not in query and query.lstrip().startswith("SELECT"):
args["query"] = args["query"].rstrip("; ") + " LIMIT 100"
return None
agent = LlmAgent(
name="analyst",
model="gemini-2.5-flash",
tools=[run_sql],
before_tool_callback=sql_guard,
)
Three choices in it:
- Return
Nonefor every tool you don’t handle. Anything else skips the tool, in Python.1 - The block result tells the model what to do next. Without that, models tend to retry with different spelling.
- Edits happen on
args. The tool runs with the modified arguments.1
For an approval rather than a block, the callback can return a result that says “waiting for approval” and record the request; the tool runs later, when a person agrees, through your own flow. The callback runs inline, while the tool call waits, so a long wait for a person is better handled outside it.
Testing a callback without a model
Because a callback is a plain function, you can test it without ADK running at all. Build a small stand-in for tool with a name attribute, pass a dictionary as args and None as tool_context, and assert on what comes back:
| Input | Expected return | Expected args afterwards |
|---|---|---|
run_sql, DROP TABLE users |
A dict with status: blocked |
Unchanged |
run_sql, SELECT * FROM orders |
None |
Query ends with LIMIT 100 |
send_email, anything |
None |
Unchanged |
The third row is the one that catches the empty-dict trap: a callback that accidentally returns {} for tools it doesn’t handle fails that test immediately, instead of silently blocking email in production.
Callbacks or plugins
| Agent callback | Plugin | |
|---|---|---|
| Registered on | One LlmAgent |
The runner (an App in Python), once2 |
| Applies to | That agent’s tools | Every agent, tool and model call the runner manages2 |
| Order | After plugin callbacks | First; if it returns a value, the agent-level callback is skipped2 |
| Good for | Agent-specific logic | Security guardrails, logging, policy that must hold everywhere |
ADK’s docs recommend plugins for security guardrails and describe prebuilt ones, including a Model Armor plugin that screens input and output, and a Tool Call Integrity plugin that signs function calls stored in the session and runs a tool only when its signature verifies.2 In Python, adk web and adk api_server can load a plugin with --extra_plugins.2
The practical rule: a sub-agent added next month by someone else won’t have your agent-level callback. It will be managed by the same runner, so it will have your plugin.
Traps
- An empty dict isn’t “allow.” In Python, only
Nonelets the tool run.1 A helper that returns{}by default blocks every tool. - Lists of callbacks chain on falsy values. When you pass a list, ADK moves to the next callback when one returns a falsy value such as
Noneor an empty dict.1 Test the combination, not each callback alone. - The model sees your block message. It’s a tool result. Don’t put internal details in it.
- Callbacks are in-process. Another program calling the same tools skips them. For rules that hold for every client, put a check in front of the tools; Deedglass’s docs include an ADK recipe that does this through its MCP server, one of nine framework recipes, with no live model run behind them.3
A checklist
- Guardrails that must always hold live in a plugin on the runner.
- Every callback returns
Nonefor tools it doesn’t handle. - Block results explain the block and the next step.
- Argument edits are tested: the tool receives the edited value.
- Callback lists tested together.
- Blocks and edits logged with agent, tool and arguments.
For the same pattern elsewhere, see CrewAI tool call hooks and LangGraph interrupts; the tool-call policy entry explains what such rules look like. To see what the agents configured on your machine can change, paste the config into the check page or run npx deedglass scan (how the grade works); to try rules in your browser, open the playground.
Written with AI assistance and checked against our claims register.