Hooks
Hooks run on either side of every tool dispatch:
@agent.before_tool. Runs before the call. Returning aPermissionDecision.deny_(...)short-circuits the dispatch.@agent.after_tool. Runs after, with theToolResult. Used for logging, metrics, redaction.
@before_tool for inline review
from loomflow import Agent
from loomflow.core.types import PermissionDecision
agent = Agent("...", model="claude-opus-4-7", tools=[send_email])
@agent.before_tool
async def review(call):
if call.tool == "send_email" and "@enemy.com" in str(call.args):
return PermissionDecision.deny_("blocked by reviewer")
return None # allow — fall through to permissionsReturn values:
None. Allow; fall through to the permissions layer.PermissionDecision.deny_(reason). Deny and stop.PermissionDecision.allow_(). Allow and skip the permissions layer.PermissionDecision.ask_(reason). Go through the approval handler.
Multiple @before_tool hooks are called in registration order; the
first non-None decision wins.
@after_tool for logging and metrics
@agent.after_tool
async def log(call, result):
print(f"{call.tool} → ok={result.ok}, took={result.duration_ms}ms")
if not result.ok:
await alert(f"tool failure: {result.error}")Hooks see the ToolResult whether the call succeeded or failed.
@after_tool can’t change the result; for that, use a sandbox.
Async vs sync
Both hook types accept async def or def. Sync hooks are dispatched
to a worker thread via anyio.to_thread.run_sync so they don’t block
the event loop:
@agent.after_tool
def log_sync(call, result): # sync is fine
open("audit.log", "a").write(...)Redaction example
Strip API keys from tool args before they hit your logger:
from loomflow.security.secrets import _apply_redaction
@agent.before_tool
async def redact_args(call):
redacted = _apply_redaction(repr(call.args))
logger.info(f"calling {call.tool}({redacted})")
return None_apply_redaction masks common API-key shapes (OpenAI, Anthropic,
AWS, GitHub PATs). For custom redaction, pass your own regex set.
Multi-tool denial pattern
Block a list of dangerous tool names without writing a custom permission policy:
DENIED = {"format_disk", "exec_arbitrary", "shutdown"}
@agent.before_tool
async def block_dangerous(call):
if call.tool in DENIED:
return PermissionDecision.deny_(f"{call.tool} is permanently blocked")
return NoneFor longer-lived deny lists, StandardPermissions(denied_tools=...)
is cleaner.
Telemetry from hooks
Hooks have access to the active RunContext via get_run_context():
from loomflow import get_run_context
@agent.after_tool
async def emit_metric(call, result):
ctx = get_run_context()
metric_client.increment(
"agent.tool.calls",
tags={
"tool": call.tool,
"user_id": ctx.user_id or "anonymous",
"ok": result.ok,
},
)OTelTelemetry already emits per-tool spans; use hooks for custom
metric backends or business-specific tagging.
Hooks vs permissions vs sandboxes. Use hooks for logic specific
to one agent (this email tool blocks @enemy.com); use permissions
for policies that apply across agents (a Permissions impl is
reusable); use sandboxes for OS-level isolation (filesystem,
subprocess). Three different layers, different contracts.