Intercept and control agent behaviour with hooks
In the SDKs the “hook” surface is the JSON-RPC event stream. You observe what the agent does, log it, and decide whether to allow, deny, or alter the next step. The CLI also exposes filesystem-level hooks for pre and post-prompt scripts.
Two layers of hooks
- SDK-level (event stream): subscribe to `tool_start`, `tool_update`, `tool_end`, `file_modified`, `permission_request`, and `error`. Use these for logging, metrics, UI rendering, audit trails, and approval gates.
- CLI-level (filesystem hooks): drop scripts into `~/.autohand/hooks/` to run before or after every prompt the CLI handles. Use these for organisation-wide policy, secret scrubbing, or routing every prompt through a logging pipeline.
The Swift SDK additionally exposes a `HookManager` that the `PermissionManager` consults for in-process approval decisions.
Hook into the event stream
Wrap a `streamPrompt` loop in a small dispatcher. Each branch is a hook: keep them fast, push slow work to a background queue.
TypeScript
import { AutohandSDK } from '@autohandai/agent-sdk';
const sdk = new AutohandSDK({ cwd: '.' });
await sdk.start();
for await (const event of sdk.streamPrompt({ message: prompt })) {
switch (event.type) {
case 'tool_start':
logger.info('tool.start', { tool: event.toolName });
break;
case 'tool_end':
logger.info('tool.end', { tool: event.toolName, durationMs: event.durationMs });
break;
case 'file_modified':
cache.invalidate(event.path);
break;
case 'permission_request': {
const dangerous = ['run_command', 'delete_path'].includes(event.tool);
const allowed = !dangerous;
await sdk.permissionResponse({ requestId: event.requestId, allowed });
break;
}
case 'error':
metrics.increment('agent.error', { code: event.code });
break;
}
}
Python
from autohand_sdk import AutohandSDK
async with AutohandSDK(cwd=".") as sdk:
async for event in sdk.stream_prompt(prompt):
t = event["type"]
if t == "tool_start":
logger.info("tool.start", extra={"tool": event.get("tool_name")})
elif t == "tool_end":
logger.info("tool.end", extra={"tool": event.get("tool_name")})
elif t == "file_modified":
cache.invalidate(event.get("path"))
elif t == "permission_request":
dangerous = event.get("tool") in {"run_command", "delete_path"}
await sdk.respond_to_permission(
event["request_id"],
decision="deny" if dangerous else "allow",
allowed=not dangerous,
)
elif t == "error":
metrics.increment("agent.error")
Go
for event := range events {
switch e := event.(type) {
case autohand.ToolStartEvent:
log.Info("tool.start", "tool", e.ToolName)
case autohand.ToolEndEvent:
log.Info("tool.end", "tool", e.ToolName)
case autohand.FileModifiedEvent:
cache.Invalidate(e.Path)
case autohand.PermissionRequestEvent:
dangerous := e.Tool == "run_command" || e.Tool == "delete_path"
scope := autohand.ScopeOnce
_ = sdk.PermissionResponse(ctx, e.RequestID, !dangerous, scope)
case autohand.ErrorEvent:
metrics.Increment("agent.error")
}
}
Java
sdk.streamPrompt(new PromptParams(prompt), event -> {
if (event instanceof Events.ToolStartEvent tse) {
logger.info("tool.start tool={}", tse.toolName());
} else if (event instanceof Events.ToolEndEvent tee) {
logger.info("tool.end tool={}", tee.toolName());
} else if (event instanceof Events.FileModifiedEvent fme) {
cache.invalidate(fme.path());
} else if (event instanceof Events.PermissionRequestEvent pre) {
boolean dangerous = pre.tool().equals("run_command") || pre.tool().equals("delete_path");
if (dangerous) {
sdk.denyPermission(pre.requestId(), DecisionScope.ONCE);
} else {
sdk.allowPermission(pre.requestId(), DecisionScope.ONCE);
}
} else if (event instanceof Events.ErrorEvent err) {
metrics.increment("agent.error");
}
});
Swift
let hookManager = HookManager()
let permissionManager = PermissionManager(
hookManager: hookManager,
mode: .interactive
)
Runner.setPermissionManager(permissionManager)
for try await event in Runner.runStream(agent: agent, prompt: prompt) {
switch event.type {
case .toolStart:
logger.info("tool.start (event.toolName ?? "")")
case .toolEnd:
logger.info("tool.end (event.toolName ?? "")")
case .error:
metrics.increment("agent.error")
default:
break
}
}Filesystem hooks
The Autohand CLI looks for executable scripts in `~/.autohand/hooks/`. Two slots run on every prompt:
- `pre-prompt`: receives the user prompt on stdin. Exit non-zero to block the prompt; print to stdout to rewrite it.
- `post-prompt`: receives the final assistant response on stdin. Use it for audit logging, redaction, or downstream notifications.
This is the right layer for org-wide policy that should apply to every SDK consumer, including ad-hoc CLI use.
bash
#!/usr/bin/env bash
# ~/.autohand/hooks/pre-prompt
set -euo pipefail
prompt="$(cat)"
if grep -qE 'AKIA[0-9A-Z]{16}' <<< "$prompt"; then
echo "blocked: AWS access key in prompt" >&2
exit 1
fi
echo "$prompt"Best practices
- Treat the event stream as your hook surface. It is consistent across SDKs and forward-compatible with new event types.
- Keep handlers fast. Push slow work (network calls, disk writes) to a background queue.
- Use filesystem hooks for policy that has to apply to every CLI run, not just SDK code paths.
- Always handle `error` events and `permission_request` in `interactive` mode.
- Avoid mutating the prompt mid-stream. If you need to redirect the agent, call `abort()` and start a new run.