Home / Articles

Enforcing Agent Rules in Code: PreToolUse, PostToolUse and Stop Hooks

Learn why authorisation for LLM agents belongs in deterministic tool-call hooks, how to deny calls safely, and how to wrap a dispatcher without recursion.

Publisher: REACTAPP.TOP12 min read2721 words

This article is published in English.

Sources: LLM Agents, The Way Nobody Told You — Part 8: Hooks

A system prompt that says "only premium users may request refunds" feels like a rule, but to the model it is a strong suggestion. It will be honoured for almost every input and broken for the occasional request that is phrased just oddly enough. This article shows how to move rules that actually matter out of the prompt and into hooks: small pieces of deterministic code that run around every tool call, see trusted server-side context, and can stop an action before it happens. By the end you will know which hook type to use for which job, how to deny a call without breaking the conversation, and how to attach hooks to an existing tool dispatcher safely.

How a prompt rule fails in practice

Picture a customer-support agent with a refund specialist. Its prompt states that only premium customers may open refund cases and that free-tier customers should be told politely that refunds are not included in their plan. Testing looks fine: free users are turned away, premium users get a case file.

Then a free-tier user sends a complaint worded in an unusual way. It reads like a refund request, although it is really about a different kind of account credit. The model, which is built to be helpful and is now uncertain about the framing, decides the situation is close enough and creates the case file anyway. Nothing in the rule was ambiguous to a human, but a customer who was never allowed to open cases now has one.

That is the category of bug hooks exist to eliminate. The model is not bad at following instructions; it is probabilistic. A rule that holds for 99% of inputs will fail on some share of the remaining 1%, and in production that share shows up. When breaking a rule has a real cost, the rule has to be enforced by code that runs every time and that the model cannot argue with.

Three hook points in the tool-call lifecycle

Hooks attach to specific moments in the life of a tool call. There are three of them, and each has a distinct job:

  • PreToolUse runs before the tool executes. It may deny the call, rewrite its input, or simply observe it.
  • PostToolUse runs after the tool has executed. It can only observe: log, audit or notify. It cannot reverse what already happened.
  • Stop runs when the session ends and handles cleanup, summaries and persistence.

A compact way to remember them is that PreToolUse asks whether something should happen, PostToolUse records that it did happen, and Stop marks the end of the session.

These roles are not interchangeable. Authorisation belongs in PreToolUse, because a check in PostToolUse runs after the action has already taken effect. Audit logging belongs in PostToolUse, because an entry written in PreToolUse records only an intention; the call might still be denied or fail, so the log would describe things that never occurred. Match each concern to the hook whose timing fits it.

Capability, authorisation and policy are different checks

Suppose you want to add three new rules to the case-file tool:

  • Rule A: a case file needs a non-empty list of evidence.
  • Rule B: customers on the free tier are not allowed to open case files.
  • Rule C: case creation is blocked from 2am to 4am while the weekly deploy runs.

Each of these rejects some tool calls, yet they belong in three different places. It helps to sort every rule into one of four categories:

  • Capability, meaning whether the inputs are internally consistent, lives in the tool implementation. "The evidence list must be non-empty" is a capability check.
  • Authorisation, meaning whether this caller may do this right now, lives in a PreToolUse hook. "Free users cannot create case files" is authorisation.
  • Policy, meaning an organisation-wide rule, also lives in a PreToolUse hook. "No writes during the deploy window" is policy.
  • Observation, meaning a record of what happened, lives in a PostToolUse hook. "Log every write to the audit log" is observation.

A useful shortcut: the tool asks whether it can perform the operation with the arguments it received, while the hook asks whether the caller is permitted to perform it at this moment.

Why the split follows from what each layer can see

The distinction is not stylistic; it comes from the inputs each layer has. A tool receives only the arguments the model generated. It can compare those arguments with one another, confirming that a list is non-empty or an amount is positive, but it has nothing else to compare them with.

A hook receives the same arguments plus trusted context assembled by the server: the authenticated user ID, the user's real tier, the current time and the session ID. That context lives in a HookContext object created at session start and frozen afterwards.

With both in hand, a hook can reason like this: the model wants to call lookup_user with user_id='u_999', but the session belongs to u_004, so the call is denied. The tool could never make that decision because it never sees who the session belongs to. Authorisation always means comparing untrusted model output with trusted server state, and only hooks hold both.

Keeping trusted context and untrusted input apart

HookContext is server-side state. Your own code builds it when the session begins, from sources you control such as the authentication layer, the session manager and the database. It is immutable for the lifetime of the session, and neither the user nor the model can influence it. The frozen dataclass below captures the minimum you need:

@dataclass(frozen=True)
class HookContext:
    user_id: str         # set from your auth layer
    tier: Literal["free", "premium"]
    session_id: str

frozen=True is doing real work here: once constructed, the object cannot be mutated by any code path, including a hook with a bug.

tool_input, by contrast, is the dictionary the model placed in its tool_use block. Treat it exactly like the body of an incoming HTTP request. A user can steer the model into producing almost anything there, through prompt injection or simply through persistence.

An authorisation check compares the two. Does the user_id the model supplied equal HookContext.user_id? Does HookContext.tier == "free" rule out this write tool? The principle that keeps the system safe is simple: the security decision must rest on HookContext, never on anything the model provided.

Denying a call without breaking the conversation

The obvious way to deny a call is to skip it. With tool-calling APIs that follow the Anthropic message format, that does not work. Every tool_use block the assistant emits must be answered by a tool_result block carrying the same tool_use_id in the very next user message. Leave one unanswered and the following API request fails with a 400 error.

So a PreToolUse denial must produce a synthetic result that stands in for the tool's output. The example below is a Python dictionary (note the True) marked as an error, with a message explaining why the call was refused and what to do instead:

{
  "type": "tool_result",
  "tool_use_id": "toolu_01ABC",
  "is_error": True,
  "content": "BLOCKED: free-tier users cannot call prepare_case_file. Suggest upgrade or route to general FAQ."
}

This satisfies the API contract, and the model treats it as an observation like any other. It sees that the request was refused for a tier reason, apologises and offers another route, and the conversation carries on normally.

Two details make this work well.

A consistent prefix turns denials into a signal

The BLOCKED: prefix is not something the API requires. It is a convention. A consistent marker helps the model recognise a deliberate refusal rather than a transient failure, so it changes course instead of retrying the same call. The same marker lets an evaluation harness separate intentional denials from genuine tool errors later. A plain-text convention becomes a machine-readable signal.

The denial message is a prompt

The model will build its next reply from whatever text you return, so write it with the same care as a prompt. A bare "Denied" gives it nothing to work with. A message that names the reason (the free tier cannot use this tool) and proposes alternatives (upgrading, or a general FAQ route) tells it both why the call failed and what to offer instead.

Attaching hooks by wrapping the dispatcher

Most agents already have a dispatcher with a call_tool function that takes a tool_use block and invokes the matching Python function. The least invasive way to add hooks is to wrap that function rather than rewrite the dispatcher. The first step is to keep a reference to the original before touching anything:

original_call_tool = dispatcher.call_tool   # save the reference FIRST

Then define a wrapper that runs the hook chain around the saved original and install it in place of the old function. In the snippet as published, the final return result and the reassignment dispatcher.call_tool = hooked_call_tool have been fused onto one line; they are two separate statements, with the reassignment sitting outside the function body.

def hooked_call_tool(name, args):
    event = build_event(name, args)
    if denial := registry.run_pre(event):
        return synthetic_error_result(denial)   # PreToolUse denied
    result = original_call_tool(name, args)     # call the saved original
    registry.run_post(event, result)
    return resultdispatcher.call_tool = hooked_call_tool   # NOW replace it

The wrapper performs five steps:

  1. Turn the tool call into a hook event.
  2. Run every PreToolUse hook.
  3. If any of them denies, return a synthetic error result; the real tool is never invoked.
  4. Otherwise call the saved original and capture its result.
  5. Run every PostToolUse hook for auditing, notification and logging.

Save before replace

The pattern to internalise is saving the original before replacing it, and it applies equally to decorators, middleware and interceptors. Consider what happens if you skip the saved reference and write dispatcher.call_tool = lambda *a, **kw: dispatcher.call_tool(*a, **kw). The lambda looks up dispatcher.call_tool only when it runs, and by then that attribute points at the lambda itself. The wrapper calls itself, which calls itself again, until the first tool call ends in unbounded recursion and a stack overflow.

Assigning the original to a variable first means the closure holds a reference to the real function object rather than to a name that will later be rebound. The wrapper therefore always reaches the genuine implementation.

Why hooks beat prompt rules and static tool lists

There are three common ways to stop an agent from doing something, and each fails differently:

  • A prompt instruction such as "do not do X" can simply be ignored when the model is uncertain.
  • Tool filtering, meaning leaving the tool out of the list entirely, is static and cannot react to runtime state.
  • Hooks are deterministic and aware of runtime state.

Tool filtering is still valuable. If the free-tier specialist never needs create_jira_ticket, do not give it that tool. A model cannot emit a tool_use block for a tool it has never been told about, and an ability the model lacks is more reliable than an instruction it might disregard.

Filtering falls short for rules that depend on the session. Whether the user is free-tier depends on who is logged in: the agent code is identical, but different sessions are allowed different actions. A static tool list cannot express that; a hook can.

The robust arrangement layers all three:

  1. Tool filtering at the specialist level, for tools a specialist never needs.
  2. Prompt guidance for soft preferences and general nudges.
  3. Hooks for runtime rules that must hold without exception.

For a comparable approach inside a framework, see how LangChain guardrails and middleware handle the same concern.

One tool surface means one enforcement layer

Hooks deliver the most when every action the agent can take is modelled as a tool call: sub-agent delegation, external API requests, database queries and knowledge-base searches. The hooked_call_tool wrapper then sees all of them, including tools added later, and authorisation, policy and auditing apply uniformly across the entire action surface.

If delegation to a sub-agent were implemented as a direct function call instead, the hook layer would never see it. You would need a second authorisation mechanism just for routing, which means two systems to maintain and two places for bugs to hide. Designing for a uniform tool surface from the beginning makes hooks a small addition; retrofitting it later turns them into a large refactor.

How a chain of PreToolUse hooks behaves

Real systems accumulate several PreToolUse hooks, for example a tier check, a deploy-window check, a user-ID match and a spending limit. They run in sequence with these semantics:

  • The first denial wins. It short-circuits the chain, so later hooks do not run and neither does the tool.
  • An allow does not short-circuit. Every hook must pass for the call to proceed.
  • Modifications compose. Hook B receives the input as hook A rewrote it.

Order the chain by cost and criticality, placing cheap, decisive checks first. Checking whether the user is free-tier reads one field; checking a weekly organisation spending limit may need a database query. Running the cheap check first avoids needless work on calls that will be refused anyway, much like putting the most selective conditions first in a SQL WHERE clause.

What should happen when a hook itself crashes

Each hook type needs a different failure policy, and getting it wrong creates real bugs rather than style problems:

  • PreToolUse errors should propagate. A crashed policy hook is a defect. Pretending the gate ran when it did not is worse than stopping, so halt the agent until the policy is fixed.
  • PostToolUse errors must be swallowed. A failing audit logger should not break the user's request, which matters more in the moment than the log entry. Record the failure on a separate channel and continue.
  • Stop errors must be swallowed. The session is ending anyway, so the best option is to log the cleanup failure and exit.

A PreToolUse hook that silently catches its own exceptions is a security hole: the check did not run, the call went through, and the audit trail offers no explanation. A PostToolUse hook that lets its exceptions escape turns every logging outage into an outage of the agent.

Where the audit trail lives

A straightforward setup writes two append-only files:

  • data/audit_log.jsonl, written by PostToolUse for every tool call, with the tool name, input, result, an error flag and a timestamp.
  • data/session_summaries.jsonl, written once per session by the Stop hook, holding one row of high-level statistics per conversation.

The audit log quickly becomes the most valuable file in the project. Built in from the start, it serves compliance, evaluation harnesses, cost analysis, debugging and dashboards from a single source of truth. In production you would typically ship the same records to a durable store, but the shape of the data stays the same.

Key takeaways

  • Prompt instructions are advisory; any rule whose violation has a real cost must be enforced in code.
  • Put input-consistency checks in the tool, authorisation and policy in PreToolUse, and auditing in PostToolUse.
  • Base every security decision on frozen server-side context, and treat the model's tool input as untrusted request data.
  • Deny calls by returning a synthetic, clearly prefixed tool_result so the conversation stays valid and the model can recover.
  • Wrap the dispatcher by saving the original function first, and route every agent action through that one tool surface.
  • Let PreToolUse failures stop the agent, and let PostToolUse and Stop failures be logged and ignored.