Adding a Hook System to an LLM Agent: Changing Behavior Without Touching Code
"Append a disclaimer to every response." "Only let one team use the exec tool." "Ping Slack whenever an order goes out."
Each request is trivial. The problem is that they keep coming, and planting one more if in the pipeline each time leaves your agent core a pile of special cases a few months later. A hook system inverts this: the core only emits events, and extensions attach from outside.
1. Where to Put the Events
Not just anywhere. Only at meaningful boundaries. Trace one agent turn and the candidates surface naturally.
user input
│
├─ user_prompt_submit ← before input enters the pipeline
│
LLM call → decides on a tool call
│
├─ pre_tool_use ← immediately before the tool runs
│ (tool executes)
├─ post_tool_use ← immediately after the tool runs
│
response finalized
│
├─ pre_response ← just before delivery to the channel
│
session ends
└─ stop
If you use sub-agents, add subagent_start and subagent_stop.
2. Always Separate Blocking from Observing
This distinction is the heart of the design. Some hooks must decide whether to proceed (so the pipeline waits on them); others merely watch.
func (e HookEvent) IsBlocking() bool {
switch e {
case EventUserPromptSubmit, EventPreToolUse, EventSubagentStart:
return true // synchronous, needs an allow/block verdict
default:
return false // fire and forget, no waiting on a result
}
}
The reason to split is latency. Wait synchronously on every event and a single hook taking 300 ms compounds on every turn. Conversely, make pre_tool_use asynchronous and blocking becomes meaningless — by the time you decide to block, the tool has already run.
And blocking hooks must be fail-closed.
const (
DecisionAllow Decision = "allow"
DecisionBlock Decision = "block"
DecisionError Decision = "error"
DecisionTimeout Decision = "timeout"
)
func (d *Dispatcher) fire(ctx context.Context, ev Event, cfg HookConfig) Decision {
if !ev.HookEvent.IsBlocking() {
go d.runAsync(ev, cfg) // observing: fire and forget
return DecisionAllow
}
ctx, cancel := context.WithTimeout(ctx,
time.Duration(cfg.TimeoutMS)*time.Millisecond)
defer cancel()
dec, err := d.runSync(ctx, ev, cfg)
if err != nil || dec == DecisionTimeout {
return cfg.OnTimeout // defaults to block
}
return dec
}
If a security hook fails to answer and you "let it through for now," then slowing the hook server down is enough to defeat your security. When you can't decide, blocking is correct. Make on_timeout configurable per hook so unimportant ones can open up with allow.
Keep the chain rule simple too: the first hook to block wins. One block ends it; the rest don't run.
3. Four Kinds of Handler
What a hook actually executes depends on the job.
- script — a short JS snippet inside a sandbox. Lightest and fastest. Run it in a runtime with filesystem and network access removed (an embedded engine like goja) and cap execution time
- command — a local shell command. Event data goes in on stdin; the exit code and stdout come back as the result. Great for reusing scripts you already have
- http — POST to an external endpoint. Use it to hook into an approval system or an internal policy server. Note that network latency becomes turn latency, so keep timeouts short on blocking events
- prompt — ask the LLM about the event. For judgments you can't express as rules, like "does this request violate policy?" Slow and expensive, so use it sparingly
This much is a sufficient contract for a script handler.
// input: event object, output: { decision, reason, updatedInput? }
function handle(event) {
if (event.toolName === "exec") {
var cmd = (event.toolInput.command || "");
if (/rm\s+-rf|mkfs|dd\s+if=/.test(cmd)) {
return { decision: "block", reason: "destructive command blocked" };
}
}
return { decision: "allow" };
}
4. Filtering When to Run — Matchers and Conditions
Running every registered tool hook on every call is waste. Filter in two stages.
{
"event": "pre_tool_use",
"matcher": "exec|write_file", // tool name pattern — cheap first pass
"if_expr": "toolInput.path.startsWith('/etc')", // condition — second pass
"timeout_ms": 2000,
"on_timeout": "block",
"priority": 10,
"scope": "tenant"
}
matcher is evaluated before entering any runtime, so it's extremely cheap. Filter out the bulk here before spinning up a script engine. priority controls order — put fast hooks with a high chance of blocking first and the whole chain short-circuits early.
Three scope levels — global / tenant / agent — works well in practice. Platform-wide safety rails go global, customer policy goes tenant, and an individual agent's quirks go agent.
5. Should Hooks Modify Input? A Dangerous Capability
A hook that can modify input rather than only allow or block is powerful: appending context to a user message, stripping a dangerous option out of tool arguments.
It is also the most dangerous capability in the system. If a tenant-authored script can rewrite tool arguments at will, that is effectively code execution. Guard it in two layers.
// ① only hooks from a trusted tier may mutate
if res.UpdatedInput != nil {
if cfg.Source != SourceBuiltin {
log.Warn("hook.mutation_stripped", "hook_id", cfg.ID)
res.UpdatedInput = nil // ignore mutations from user-authored hooks
} else {
// ② apply only allow-listed fields
applyMutation(&ev, res.UpdatedInput, allowlistFor(cfg.ID))
}
}
Put plainly: anyone may block, only trusted hooks may mutate, and even then only whitelisted fields. And don't drop a rejected mutation silently — log a warning. Who attempted what is itself a security signal.
6. Response Post-Processing — Append or Send Separately?
pre_response hooks are among the most used in practice. It helps to distinguish modes for how the result gets applied.
- append — concatenate onto the original response. Disclaimers, source attributions
- send — deliver as a separate message. Follow-up notices or summaries that read better detached from the main body
type FireResult struct {
Decision Decision
AppendResponse []string // fragments to concatenate onto the response
ExtraMessages []string // delivered as independent messages
}
7. Audit Trail — Hooks Must Leave Records
Hooks are machinery that changes behavior invisibly. Without records, nobody can explain why a request was blocked.
CREATE TABLE hook_executions (
id TEXT PRIMARY KEY,
hook_id TEXT, -- NULL when the hook is deleted (record survives)
session_id TEXT NOT NULL,
event TEXT NOT NULL,
input_hash TEXT NOT NULL, -- sha256 of canonical JSON (instead of raw input)
decision TEXT NOT NULL,
duration_ms INTEGER,
dedup_key TEXT, -- (hook_id, event_id), prevents double-recording
error TEXT, -- truncated to 256 chars
error_detail BLOB, -- details stored encrypted
created_at TIMESTAMP
);
A few design points. Storing a hash instead of the raw input lets you answer "did the same input repeat?" without retaining personal data. Error details often carry sensitive values, so encrypt them. And dedup_key stops a retry from recording the same execution twice.
8. Three Real Uses
- Blocking destructive commands —
pre_tool_use+ script. Pattern-checkexecarguments and block destructive commands. It answers in about a millisecond, so it's worth leaving on permanently - Order notifications —
post_tool_use+ http. Push to a Slack webhook whenever the order tool runs. Observing, so it adds no latency - Automatic policy text —
pre_response+ command (append). Attach a notice to the end of the response. Instruct this via prompt and the LLM occasionally forgets; via hook it lands 100% of the time
That last case captures what hooks are for. Rules that must hold belong in code, not in the prompt. A prompt is a probability; a hook is a guarantee.
Summary
- Place events only at meaningful boundaries — input entry, around tools, before delivery, at shutdown
- Separate blocking from observing — all-synchronous is slow, and an async blocking hook is pointless
- Blocking timeouts must be fail-closed (block) — otherwise slowing the hook defeats it
- Handlers come as script / command / http / prompt, differing in weight and purpose
- Filter cheaply with
matcherbefore entering any runtime, and order the chain withpriority - Allow input mutation only from trusted hooks, on allow-listed fields, and log every rejection
- Audit logs are mandatory — hash the input, encrypt the error detail
- Rules that must hold go in hooks, not prompts
Add a hook system and the agent core gets simple again. Special cases move outside it, and the core carries one responsibility: emitting events at exactly the right moments. The more extension requests a system keeps receiving, the more that single boundary determines how long the codebase survives.
댓글
댓글 쓰기