Hooks — The Unified Observation and Intervention Layer¶
What hooks are for¶
Hooks are the single, session-scoped extension point that every harness mechanism builds on. They let you intercept ADK’s execution at 12 lifecycle points without writing a callback wrapper or a tree-walker:
When |
Event |
Use case |
|---|---|---|
Before a tool runs |
|
Block dangerous commands, rewrite args, ask for approval |
After a tool runs |
|
Lint an edit, checkpoint the workspace, log the result |
On tool error |
|
Retry policy, structured error telemetry |
Before a model call |
|
Strip secrets, inject system messages, budget check |
After a model call |
|
Parse structured output, capture usage, stream to UI |
On model error |
|
Fall back to cheaper model, surface quota errors |
Before / after an agent turn |
|
Session setup, memory load/save |
Session start / end |
|
One-time init and cleanup |
User prompt submitted |
|
Redact PII, add context, record turn |
ADK event emitted |
|
Custom event taps |
Harness extensions |
|
Plumbing for the rest of the harness foundations |
They fire for every invocation in the tree, not just the top-level agent.
Because they install as an ADK BasePlugin, adk-fluent does not have to walk
your agent hierarchy and attach callbacks to every sub-agent — ADK’s plugin
manager does that automatically, including for dynamically-spawned subagents.
This is the only layer adk-fluent ships for intercepting runtime behavior. Permissions, budgets, compression, plan mode — they all dispatch through this foundation.
The shape of a hook¶
Every hook is the same function: it takes a HookContext and returns a
HookDecision. Nothing else.
from adk_fluent import H
from adk_fluent._hooks import HookContext, HookDecision, HookEvent
def block_rm_rf(ctx: HookContext) -> HookDecision:
command = (ctx.tool_input or {}).get("command", "")
if "rm -rf" in command:
return HookDecision.deny("rm -rf is forbidden in this workspace")
return HookDecision.allow()
hooks = H.hooks("/project").on(HookEvent.PRE_TOOL_USE, block_rm_rf)
import { H, HookContext, HookDecision, HookEvent } from "adk-fluent-ts";
function blockRmRf(ctx: HookContext): HookDecision {
const command = (ctx.toolInput ?? {}).command ?? "";
if (command.includes("rm -rf")) {
return HookDecision.deny("rm -rf is forbidden in this workspace");
}
return HookDecision.allow();
}
const hooks = H.hooks("/project").on(HookEvent.PreToolUse, blockRmRf);
The decision types are fixed. There are six, and they compose — a deny
short-circuits the chain, a modify rewrites arguments and lets downstream
hooks see the new values, an inject queues a system message for the next
LLM call, and allow is the “no opinion” pass-through.
HookDecision.allow() # Pass through
HookDecision.deny(reason) # Short-circuit with an error
HookDecision.modify(tool_input=new_args) # Rewrite tool arguments
HookDecision.replace(output=result) # Short-circuit with a fake result
HookDecision.ask(prompt) # Surface a permission request
HookDecision.inject(system_message=text) # Queue a transient system message
Return None from a hook and it is treated as allow(). Raising an
exception is treated as deny(reason=str(exc)) — hook authors never have to
remember to wrap everything in a try/except.
Registering hooks¶
H.hooks(workspace=...) returns a HookRegistry. All registration methods
are chainable.
from adk_fluent import H
from adk_fluent._hooks import HookDecision, HookEvent, HookMatcher
hooks = (
H.hooks("/project")
# Callable hooks — full decision power
.on(HookEvent.PRE_TOOL_USE, block_rm_rf,
match=HookMatcher.for_tool(HookEvent.PRE_TOOL_USE, "bash"))
.on(HookEvent.POST_TOOL_USE, lint_after_edit,
match=HookMatcher.for_tool(
HookEvent.POST_TOOL_USE, "edit_file", file_path="*.py"))
# Shell hooks — notification-only, always allow
.shell(HookEvent.POST_TOOL_USE, "ruff check {tool_input[file_path]}",
match=HookMatcher.for_tool(HookEvent.POST_TOOL_USE, "edit_file"))
.shell(HookEvent.SESSION_END, "echo 'session {session_id} ended'")
)
Callable hooks are the canonical way to intervene — they get the full
HookContext and return a HookDecision. Shell hooks are for side effects
you would otherwise script around a subprocess: lint-on-save, external
notifications, metrics push. They never block the tool call, and their exit
code does not affect the decision chain.
Matchers¶
A HookMatcher filters which contexts a hook sees. The filter is layered:
event → tool name regex → per-argument glob → optional predicate. All layers
are ANDed.
# Most specific: a regex on the tool name plus an fnmatch glob on an arg
HookMatcher(
event=HookEvent.PRE_TOOL_USE,
tool_name="edit_file",
args={"file_path": "*.py"},
)
# Shorthand
HookMatcher.for_tool(HookEvent.PRE_TOOL_USE, "edit_file", file_path="*.py")
# Or the equivalent H factory
H.hook_match(HookEvent.PRE_TOOL_USE, "edit_file", file_path="*.py")
A matcher with no filter fires for every context of its declared event — the normal case for session-wide hooks.
Shell placeholders¶
Shell hook commands support template substitution from the context:
Placeholder |
Value |
|---|---|
|
The firing event name |
|
Tool name (for tool events) |
|
Agent name |
|
ADK session id |
|
ADK invocation id |
|
Raw user prompt (for |
|
Model name (for model events) |
|
Error string (for |
|
Value of |
All substituted values are shlex.quote’d so spaces, quotes, and shell
metacharacters do not break the command. The context is also exported as
ADKF_HOOK_* environment variables for scripts that prefer env access over
argv.
Installing the registry¶
The registry produces an ADK BasePlugin via registry.as_plugin(). Install
it on the App or Runner that drives your agent:
from google.adk.apps import App
from google.adk.runners import Runner
app = App(name="coder", root_agent=agent.build())
runner = Runner(app=app, plugins=[hooks.as_plugin()])
Because the plugin is installed at the App/Runner layer, it is session-scoped and inherited by every subagent in the invocation tree automatically — no tree-walking, no per-agent wrapping.
Decision semantics in detail¶
allow¶
Pass-through. Equivalent to returning None from an ADK callback. This is
the “no opinion” decision — downstream hooks keep firing and the wrapped call
proceeds normally. If you are unsure what to return, return allow().
Never return an empty dict from a raw ADK callback. ADK uses
“first-truthy-wins” semantics for callback chains — an empty dict counts as
“I made a decision, stop calling”. The HookDecision layer handles this for
you; always use the decision constructors.
deny(reason)¶
Short-circuit the wrapped call with a failure. For tool events, the plugin
synthesises a tool response dict containing the error so the LLM sees why the
tool did not run. For model events, it builds an LlmResponse with
error_message=reason. For agent events, it builds a model Content with
the reason text.
After a deny, subsequent hooks for the same event are not called. Deny is terminal.
modify(tool_input=new_args)¶
Only meaningful for pre_tool_use. The plugin mutates the ADK
function_args dict in place (ADK passes it by reference) and returns
None, letting the tool proceed with the rewritten arguments. Downstream
hooks in the same dispatch see the rewritten args — this is how redaction
chains work.
replace(output)¶
Short-circuit the wrapped call and pretend the tool, model, or agent produced
output directly. For tools, output should be a dict (mapped to the tool
response). For models, an LlmResponse (used as-is). For agents, a
Content object. For scalars, the plugin wraps them in the smallest sensible
container.
Replace is terminal.
ask(prompt)¶
Raise a permission request. The plugin raises a HookAsk exception carrying
the prompt; the harness runtime (H.repl(), permission plugin, etc.) catches
it and surfaces the prompt to the user. Until the runtime supplies an
approval path the call terminates cleanly as a tool error — the LLM sees the
prompt and can retry.
Ask is terminal.
inject(system_message=text)¶
Append a transient system message to the session’s SystemMessageChannel.
The channel is a reserved-key list (_adkf_hook_system_messages) on the ADK
session state. On the next before_model callback, the plugin drains the
channel and prepends the drained messages to llm_request.contents as a
[system] ... user-role turn.
Inject is a side effect — it does not short-circuit the chain. You can
return inject from a post_tool_use hook to tell the model what just
changed, while still letting the wrapped call complete normally. Multiple
injects in the same dispatch are concatenated in order.
Folding multiple hooks¶
When several hooks match the same event, they run in registration order. The registry folds their decisions:
allowhooks are skipped — they have no effect.injectdecisions are collected into a pending list and attached to the final decision asmetadata["pending_injects"].modifyrewritesctx.tool_inputin place and the chain continues — the next hook sees the rewritten args.The first
deny,replace, oraskis terminal — iteration stops and that decision becomes the final one, with any pending injects still attached so they run as side effects.
This means a redactor + an approval gate + a usage tracker can all coexist on the same event without knowing about each other, and the harness author does not have to decide a priori how conflicts resolve.
Cookbook¶
Block destructive commands¶
def block_destructive(ctx):
command = (ctx.tool_input or {}).get("command", "")
banned = ["rm -rf", "mkfs", "dd if=", ":(){ :|:& };:"]
for needle in banned:
if needle in command:
return HookDecision.deny(f"blocked: {needle!r}")
return HookDecision.allow()
hooks.on(
HookEvent.PRE_TOOL_USE,
block_destructive,
match=HookMatcher.for_tool(HookEvent.PRE_TOOL_USE, "bash"),
)
Redact secrets before every tool call¶
import re
SECRET_RE = re.compile(r"(?i)(api[_-]?key|token|password)\s*[:=]\s*\S+")
def redact_secrets(ctx):
command = (ctx.tool_input or {}).get("command")
if command and SECRET_RE.search(command):
new = dict(ctx.tool_input or {})
new["command"] = SECRET_RE.sub(r"\1=***", command)
return HookDecision.modify(new)
return HookDecision.allow()
hooks.on(HookEvent.PRE_TOOL_USE, redact_secrets)
Lint-on-save with a shell hook¶
hooks.shell(
HookEvent.POST_TOOL_USE,
"ruff check {tool_input[file_path]}",
match=HookMatcher.for_tool(
HookEvent.POST_TOOL_USE, "edit_file", file_path="*.py"
),
)
Tell the LLM what just happened¶
def nudge_after_edit(ctx):
path = (ctx.tool_input or {}).get("file_path")
return HookDecision.inject(
f"You just edited {path}. Consider running the test suite next."
)
hooks.on(
HookEvent.POST_TOOL_USE,
nudge_after_edit,
match=HookMatcher.for_tool(HookEvent.POST_TOOL_USE, "edit_file"),
)
Stub an expensive tool in tests¶
def fake_search(ctx):
return HookDecision.replace({"results": [{"title": "stub", "url": ""}]})
hooks.on(
HookEvent.PRE_TOOL_USE,
fake_search,
match=HookMatcher.for_tool(HookEvent.PRE_TOOL_USE, "web_search"),
)
Ask the user before running anything destructive¶
def gate_destructive(ctx):
tool = ctx.tool_name or ""
if tool in {"edit_file", "delete_file", "bash"}:
return HookDecision.ask(f"Allow {tool} with args {ctx.tool_input}?")
return HookDecision.allow()
hooks.on(HookEvent.PRE_TOOL_USE, gate_destructive)
Relationship to ADK callbacks¶
If you already use raw ADK callbacks (before_tool_callback,
before_model_callback, etc.), hooks are the replacement. They give you:
A structured decision type instead of the subtle first-truthy-wins return contract ADK documents but is easy to get wrong.
One API for every lifecycle point instead of learning each callback’s argument shape and return type.
Matcher-based filtering instead of per-hook name and arg checks.
Automatic session-scoping and subagent inheritance via the plugin manager.
A first-class
injectchannel for transient system messages.
Hooks and raw callbacks can coexist — the plugin does not interfere with agent-level callbacks you still want to write by hand — but anything new should be written as a hook.
API reference¶
from adk_fluent import H
from adk_fluent._hooks import (
HookAction, # String constants: "allow" / "deny" / ...
HookContext, # Normalized context passed to every hook
HookDecision, # Structured return type
HookEvent, # Canonical event names (PRE_TOOL_USE, etc.)
HookMatcher, # Event + regex + arg glob + predicate filter
HookPlugin, # ADK BasePlugin that dispatches a registry
HookRegistry, # User-facing registry (chainable)
HookEntry, # Single registered hook (callable or shell)
ALL_EVENTS, # frozenset of every valid event name
SystemMessageChannel, # Transient system message queue
SYSTEM_MESSAGE_STATE_KEY, # Reserved session state key
)
Factory |
Returns |
|---|---|
|
A new |
|
The |
|
A |
Method |
Effect |
|---|---|
|
Register a callable hook |
|
Register a shell hook |
|
Combine two registries (new instance) |
|
Produce the ADK |
|
Fire all matching hooks for |
|
List of registered |
See the master plan at docs/plans/2026-04-12-harness-foundations-master-plan.md
for where hooks fit in the nine-mechanism foundation, and
tests/manual/test_hooks_modules.py for the exhaustive test module.