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 commandspre_tool_use + script. Pattern-check exec arguments and block destructive commands. It answers in about a millisecond, so it's worth leaving on permanently
  • Order notificationspost_tool_use + http. Push to a Slack webhook whenever the order tool runs. Observing, so it adds no latency
  • Automatic policy textpre_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 matcher before entering any runtime, and order the chain with priority
  • 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.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기