Two Ecosystems: Claude Code and OpenAI Codex

Claude Hooks: Automatic Checks on Agent Events

How Claude Code hooks run automatic checks and actions at specific points in the agent's lifecycle, what they can and cannot block, and how to choose…

Chapter 34Two Ecosystems: Claude Code and OpenAI Codex

A unit of the "AI Development" (Artificial Intelligence) course · snapshot of fast-changing documentation: 10 October 2026

You can give Claude a clear instruction such as "do not edit the billing rules file", but an instruction is still just text. The model has to understand it correctly and apply it at the right moment. A hook (an event handler) lets you run a defined action at a specific point in the Claude Code lifecycle: for example, check an argument before a tool is called, format a file that has just been written, or show a person a notification.

A hook automates one particular step. It does not turn the whole project into a protected zone. A check that runs before a call covers only the events and execution paths it is attached to. A check that runs after a call cannot undo the write, the command already sent over the network, or any other side effect. That is why you need to know both exactly how the tool behaves and what it does not control.

The behaviour described below was checked against the current Claude Code documentation on 10 October 2026. The most recent release in the changelog on that date was 2.1.296, published on 9 October. That is not a claim about the version installed on any particular machine. For critical rules, check the reference documentation and the version in your own environment before relying on them.

Instructions, automatic checks and the operating-system boundary

Take the rule "Do not open or modify files that contain passwords". It might live in CLAUDE.md or in a prompt. It is useful context, but it does not intercept a tool call, and it does not take away the process's access to the disk.

The PreToolUse event (fired before a tool is called) can pass the details of a specific call to a script. The script compares them against a list of forbidden paths and rejects that particular call. This is now automatic control over an event that Claude Code handed to the hook. But if the file can be changed some other way, for instance through a different shell or an external process, the check does not become a universal ban.

Claude Code permissions decide which calls may run, which need approval and which must be rejected. A sandbox (an isolated environment) limits the resources that supported commands can reach. Operating system (OS) permissions decide what the process's own user account is able to do. These are separate layers with different reach. In manual-approval mode, for example, Claude Code manages read and write permissions separately, and the shell sandbox can add file-system and network isolation on top. The Claude Code security documentation describes these mechanisms separately.

Mechanism What it controls What it does not prove on its own
Instruction in text What the model should take into account That the check will definitely happen, or that the tool physically cannot act
Hook The Claude Code events it is configured for; for some events, a decision before execution That alternative tools, external processes and the consequences of an already completed action are covered
Claude Code permissions Tools, commands and approval prompts within the rules of the current environment That a process outside Claude Code will obey these rules
Sandbox and system permissions The files, network and resources actually available to the process That the business decision is correct, or that every operation passed the required check

So a hook fits when a rule can be tied to a clear event and checked algorithmically. To forbid access to a secret, do not rely on text or an argument filter alone. You need the appropriate permissions, isolation or account rights.

Events, matchers and handler types

Claude Code stores its settings in JSON (JavaScript Object Notation, a text format for structured data). Inside the hooks field you name an event, optionally add a matcher filter, and then list one or more handlers. The matcher decides which occurrences of the event to react to: for example, only Edit and Write rather than every tool. Matcher syntax depends on the event. For tool events it is tool names or patterns; for SessionStart it is the source that started the session; for ConfigChange it is the source of the settings change. Do not assume an arbitrary pattern is an exact match without checking the matcher rules in the reference.

The events that matter most for this unit:

Event When it fires Can it prevent the action?
PreToolUse After the tool's parameters are prepared, but before the call is processed Yes: it can allow, deny, ask for confirmation or defer the call
PostToolUse After a tool has run successfully No: it can give Claude feedback, but the tool has already run
PostToolUseFailure After a tool that started running returned an error No: the tool has already returned an error
UserPromptSubmit When the user has submitted a prompt, before Claude processes it Yes: it can add context or block processing of the prompt
Stop When Claude is about to finish its turn Yes: it can ask Claude to keep working
SessionStart / SessionEnd At the start / end of a session SessionStart can add context; SessionEnd cannot delay shutdown and shares a default budget of 1.5 seconds
PreCompact / PostCompact Before / after the conversation history is compacted The first can stop compaction; the second runs after it
ConfigChange When the relevant settings change For most sources it can stop the change from being applied; policy settings are an exception

This is not the full list. Claude Code also supports notifications, changes of working directory, model and files, agent events and other lifecycle points. The existence of an event does not mean it can block an action. Exact names, matcher types and the effect of each event are listed in the Hooks reference.

For each handler, the type field says how it runs. The current documentation describes five types:

  • command runs a command. Input arrives on standard input (stdin), and the result is reported through the exit code, standard output (stdout) and standard error (stderr).
  • http sends the data as an HTTP (Hypertext Transfer Protocol) request. A blocking decision is expressed in valid JSON in the body of a successful response, not by the HTTP status itself. A non-success status or a connection error produces a non-blocking error by default; onFailure: "block" can make a hook failure blocking (Hooks reference).
  • mcp_tool calls a tool on an already connected MCP (Model Context Protocol) server. It is not available on every event: at session start, for example, the servers may not be available yet.
  • prompt asks a model to evaluate a condition and return a structured decision. This suits checks that need judgement, but it does not make the result deterministic.
  • agent launches a subagent that can inspect files or tool results. This type is marked experimental; it supports the same events as a prompt hook except PermissionRequest (Hooks reference).

For an ordinary check with a clear rule, a command is better than a model call: its result can be repeated and explained. If the decision requires reading context or judging content, a model-based hook can help, but its verdict should be treated as an assessment, not as an infallible policy. Anthropic's guide to automating actions with hooks explains this distinction.

Input and output: what a hook actually sees

A command hook receives a JSON object on standard input. It contains common fields plus fields specific to the event. For PreToolUse, for example, a typical documented input looks like this:

{
  "session_id": "demo-session",
  "transcript_path": "/workspace/store/.claude/transcript.jsonl",
  "cwd": "/workspace/store",
  "permission_mode": "default",
  "hook_event_name": "PreToolUse",
  "tool_name": "Edit",
  "tool_input": {
    "file_path": "/workspace/store/config/pricing-policy.json",
    "old_string": "...",
    "new_string": "..."
  },
  "tool_use_id": "toolu_demo"
}

session_id identifies the session, transcript_path is the path to its history file, cwd is the working directory, permission_mode is the permission mode and hook_event_name is the event name. tool_name and tool_input name the tool and its parameters; the parameter schema varies from tool to tool. tool_use_id identifies the call. The values in the example are invented; the field names come from the reference. permission_mode is missing from some events, and the history at transcript_path may lag behind messages that have only just been produced. Do not infer the value of a field that is not in the schema of that particular event. The reference for common fields and event schemas lists them separately.

The tool_input parameters are data, not ready-made shell code. If a command hook uses an argument without parsing and proper escaping, a path or a piece of text can turn into command injection. To check a path, read the structured value with a JSON library and compare the normalised absolute path. When calling a shell, pass the executable and its arguments separately whenever possible. On Windows, paths in Read/Edit/Write may contain backslashes even when the hook runs in Git Bash, so normalise separators before matching a path pattern.

PostToolUse adds tool_response, the tool's result, and may also include duration_ms. PostToolUseFailure carries error fields, notably error and the optional is_interrupt and duration_ms. The format of error depends on the tool, so it is not a stable machine-readable field for every detail. A validation error for an unknown tool or its parameters can happen before execution and trigger neither PreToolUse nor PostToolUseFailure. A rejection by permissions, by contrast, triggers PreToolUse but not PostToolUseFailure. So even a log of "all tool errors" built on a single hook can have gaps.

The exit code and output have precise semantics:

  • Exit code 0 means the handler finished successfully; on its own it neither allows nor forbids anything. It is the usual code for a structured JSON response.
  • Exit code 2 signals a blocking error where the event supports blocking. For PreToolUse it blocks the call. For PostToolUse the action already performed stays in place, and the error text may be shown to Claude as feedback. For other events the meaning differs.
  • Any other non-zero code usually does not block on its own. In particular, exit code 1 is not a universal "deny". For most events it produces a non-blocking error and the action continues.
  • Structured JSON must go to stdout with no other text around it. For example, PreToolUse can return a decision like this:
{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "deny",
    "permissionDecisionReason": "This path may only be edited through the approved process"
  }
}

For PreToolUse, the decision fields live inside hookSpecificOutput; the current set of options is allow, deny, ask and defer. allow can remove a confirmation prompt, but deny and ask rules still apply; it also does not replace the interaction that AskUserQuestion, ExitPlanMode and MCP tools that require it still need. The updatedInput output replaces the entire parameter object, so you must keep the fields you are not changing; the new parameters also go through the permission check. If several hooks return decisions at once, precedence is deny, then defer, then ask, then allow. The defer decision is meant for integrations that run Claude Code in non-interactive mode with claude -p: an interactive session shows a warning and ignores it. It works only for a single tool call in a turn; with a parallel group of calls, defer is ignored too and the tools go through the normal permission path (Hooks reference).

PostToolUse, in contrast, can add an explanation or replace the tool's output through updatedToolOutput, but only for what Claude will see. The call itself has already happened, and the replacement must match the shape of the tool's output. Even exiting with code 2 on PostToolUse does not roll back a file or cancel a request that has been sent. All matching handlers run in parallel, so do not build in a sequence like "the first one changes the file and the second reads exactly the new version" without separate coordination.

Before or after: choosing the right control point

PreToolUse runs once Claude has prepared the call's parameters but before the call executes. It can reject an edit to a particular path or ask for confirmation before a risky action. This is the best point for checking arguments and rules that must apply to the call itself. However, the event sees only the tool calls that Claude Code passes to it. A file attached with @ in a prompt, for example, is not a Read call, so a hook filtering on Read will not see it.

PostToolUse fires after a successful call. It can check the syntax of a document that was written, add a result to Claude's context, or replace the response text the model sees. The file, command or external action has already changed the state of the system; an "error" message does not bring it back. To react to an execution failure there is a separate event, PostToolUseFailure, and it does not roll back whatever managed to happen either.

There is also a FileChanged event, which reacts to an actual change to a specified file regardless of which process made it. It runs after the change and has no blocking decision. On the other hand, PostToolUse on Edit|Write may miss a change made by a Bash command or by a process outside Claude Code. The official guide to hook scenarios presents formatting after an edit and blocking a protected path as different scenarios: one controls a parameter before the action, the other works with the result.

A deterministic procedure calls for a deterministic check:

  • check that a path is inside an allowed directory;
  • check that a required parameter is present in the tool request;
  • format a file in a consistent style;
  • log the event ID and the decision without any secret values.

By contrast, "does this migration comply with business policy?" is not a job for a regular expression (regex) run over text. A regex can find a date format or a suspicious string, but it cannot prove that a rate applies to the right customer, that a calculation does not alter past transactions, or that a decision agrees with a requirement. That kind of judgement needs requirements, tests and human review. A model-based hook can prepare a signal for review, but it does not replace this evidence.

Timeouts, errors and the choice to stop or continue

The timeout field is set in seconds. The default depends on the hook type and the event: for ordinary command, http and mcp_tool hooks the documentation gives 600 seconds; for prompt, 30; for agent, 60. For some events the command/http/mcp_tool limits are lower: 30 seconds for UserPromptSubmit, PreModelSwitch and PostModelSwitch, and 10 for MessageDisplay. SessionEnd has a shared budget of 1.5 seconds. CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS can raise that overall budget to 60 seconds, but a hook without its own timeout stays at the default limit, and a plugin hook does not increase it. A command hook with async: true runs in the background and gets no timeout once started; its result arrives on the next turn, and claude -p cancels the background process when the session ends. Do not run a heavy check synchronously before every action unless you need to: the user will wait on each such request (Hooks reference).

If a synchronous command, HTTP or MCP-tool hook reaches its timeout, Claude Code cancels it and discards its output. For PreToolUse, a timeout falls through to the normal permission flow: on its own it does not block the tool. An Agent SDK callback on PreToolUse has a different contract: its timeout blocks the call. On UserPromptSubmit, a timeout of a synchronous command/http/mcp_tool hook discards its output, but the prompt still reaches Claude; a timeout of an SDK callback blocks the prompt. On PreModelSwitch, a timeout blocks the model change. Before you rely on any of this, find out the contract for that specific event.

Whether to fail closed (stop the risky operation when something goes wrong) or fail open (let work continue when something goes wrong) depends on the consequences. For a formatter on low-risk changes it can be reasonable to continue, leave the result explicitly unchecked and tell Claude. For a payment, a publication or a deployment, continuing without the check may be unacceptable. But calling something "fail-closed" does not change how the platform behaves. If, according to the documentation, a PreToolUse timeout continues into the normal permission flow, the restriction has to be enforced in that flow, on the target system's side, or in the operating environment. A hook can return deny only if it got the chance to run and return a decision.

A real example should be read together with its limits. In the open bug report in anthropics/claude-code, issue #85250, the author described a Node.js child process hanging on Windows with Claude Code 2.1.226: the command reportedly never started running the script, and the specified two-second timeout did not terminate the process. The page carries the reproducedBug label, and the issue was still open when checked on 10 October. This is a specific report for that version and those conditions, not proof that every timeout in the current version behaves the same way. On the snapshot date the latest release was 2.1.296; we do not conclude that the issue has been fixed, or that this case necessarily occurs in that version. The reference describes the expected timeout behaviour, but reports like this explain why you should verify it under your own load and keep a separate safety boundary.

Retries and side effects need the same attention. Do not assume that an event fires exactly once, or that the process will not restart after an error. If a hook writes an audit record, include the call identifier tool_use_id and make retries safe: receiving the same event again must not create an extra transaction or duplicate a notification. Do not launch another Claude session from a hook without a clear reason and protection against re-entry: that session may read the same settings and trigger the hook again. Asynchronous hooks each run in their own background process with no automatic de-duplication; their result appears on the next turn, when Claude is able to read it.

Protecting the hook itself and the limits of coverage

A command hook is an executable program. Claude Code warns that it runs with the user's permissions and can potentially read, modify or delete any file that account can access. So the script itself becomes part of the trust boundary rather than a harmless "sensor" by default. Anthropic's security best practices for hooks recommend validating and sanitising input, always quoting shell variables, guarding against path traversal with .., using absolute paths to scripts, and not excluding sensitive files from protection.

Before reviewing the settings of a shared repository, check where they came from. In an interactive session, Claude Code holds back hooks from settings files until you accept trust for the working directory. In non-interactive mode and in sessions run through the SDK (Software Development Kit), no trust dialog is shown and the directory is treated as trusted. That means a project settings file containing a hook can run it even in a directory the user never explicitly trusted. Team settings live in .claude/settings.json; personal overrides can sit in .claude/settings.local.json; organisational policies can be added separately. The Claude Code settings documentation explains their boundaries and sources.

Before running a check, ask:

  1. Does the script insert untrusted arguments into a command line without safe quoting? It is better to pass arguments separately or to pass machine-generated JSON.
  2. What permissions does the hook have? Could it accidentally send a secret over the network or write it to a log, an error message or diagnostics?
  3. Where is the executable loaded from? A relative path or a changeable PATH can lead to a different program; a known absolute path and a controlled script are better, and should be checked separately.
  4. Can .claude/settings.json or its script itself be changed? A team policy needs a proper owner, review and change control; otherwise the code being checked can change both the rule and the check.
  5. Does the program receive environment variables containing tokens and keys? Keep them out of parameters and logs; where possible, pass only the values that are actually needed.
  6. Has the hook turned from a deterministic check into a new, unrestricted network call? Network access adds another dependency and another point of leakage.

Coverage can break for reasons other than a bug in the script. Another tool can change the same file; a command can spawn a child process; a separate process can bypass the Claude Code lifecycle entirely; permissions or settings may fail to load; matchers may not match the name you expected. The documentation warns that if a script does not start, or its output fails schema validation, most events carry on without the hook's decision. Check the actual configuration sources, error messages and diagnostic records, not merely that a JSON file exists in Git.

Across this course the boundaries are split between several topics: rules, permissions, the sandbox and review complement one another, but they do not automatically duplicate one another. Keep hooks for event-driven control and repeatable automation. Permissions express what the agent is allowed to call; isolation and process rights restrict access at execution time; review confirms decisions and their consequences. Security and continuous integration (CI) are covered more fully in security for AI agents and continuous integration.

Three worked cases

All company names, files, people, systems and data in the examples below are invented. These are teaching scenarios, not statements about any real configuration or any client's rules.

Case 1: Protecting a policy file before an edit

The team behind an invented platform called Lantern maintains the file config/pricing-policy.json. Only the product owner may change it, and only through an agreed review. Agents may edit ordinary text files and interface components in the working copy. The team needs to catch specifically an attempt by the Edit or Write tool to change the protected path.

A project hook is attached to PreToolUse with the matcher Edit|Write. The script reads tool_input.file_path, normalises the path and compares it against the repository root and the exact path of the policy file. It does not guess the path from the name, does not search arbitrary text for the word "price" and does not use eval. If the target is config/pricing-policy.json, the hook returns valid JSON with permissionDecision: deny and a short reason. If the target is, say, src/catalog-card.tsx, the hook returns no decision, and Claude Code's normal permission flow stays in force. There is no need to return allow for every permitted file: such an allow can remove an expected confirmation and is not a neutral message.

Expected result. An Edit call for src/catalog-card.tsx goes on to the normal permission check and is edited only within the rights granted. A Write call for config/pricing-policy.json is stopped before the write; Claude receives the reason, and the file is not changed by that call.

What this does not cover. The hook is not a control over every possible change on disk. PreToolUse works for tool calls, not for context added with @; a Bash command or a third-party process can change the file by another route. Those scenarios need appropriate command permissions, file permissions, a protected branch or another external boundary. The after-the-fact FileChanged event can record the change but cannot forbid a write that has already happened. And if the author you need to control can edit both the hook and the settings file, the hook provides no independent protection.

Case 2: Formatting and validation after a write

An invented team is changing a table of regional multipliers in data/regions.json. A PostToolUse hook with the matcher Edit|Write runs two steps: a formatter adjusts the style of that file only, and a validator checks the JSON structure, required keys and value types. The formatter may reorder fields into a consistent order and fix indentation. It does not touch package-lock.json, test data or the rest of the repository unless the procedure is specifically aimed at them. The validator reads the changed file and leaves a report in the context; the formatter modifies it.

Expected result. If formatting or the schema check fails, Claude receives a specific message after the successful write and can fix the file in the next call. The first write has still happened: PostToolUse is not a precondition, and a hook error does not mean an automatic rollback. If the formatter itself changes the file, the next review must read the final content, not assume it matches what the tool originally sent.

What the check does not prove. Valid JSON and consistent formatting do not tell you whether the rates are right for the intended audience, whether historical data is interpreted consistently, or whether the table meets the product requirements. That needs business rules and evidence from the relevant tests. The formatter and the schema check can both pass when the values are wrong but syntactically acceptable.

The event's boundary. PostToolUse for Edit|Write fires after a successful call to those tools. It may not run if a different tool produced an error, or if a Bash command rewrote the same file. For an error in a tool that has already started there is PostToolUseFailure, but failures before a tool starts are handled by other events. So this hook is useful feedback, not a complete record of every change and failure.

Case 3: A check exceeds its timeout

Consider two hypothetical uses of the same external verification service, which sometimes responds slowly.

Low risk: formatting a note. After an invented internal reference page is edited, a hook checks the Markdown style and waits no more than five seconds. If the limit is exceeded, the file stays at its last written version. The team decides not to halt further editing just because the formatter is unavailable: Claude receives a message that the automatic check did not finish, and the author knows the style is still unchecked. This is a deliberate fail-open for unimportant formatting quality, not a hidden claim that "everything passed".

High risk: publishing a new tariff. The check has to confirm authorisation and the target environment before a rate is changed in an external payment system. If the service does not respond, the team must not treat that as approval. Yet a single synchronous PreToolUse command hook does not guarantee this outcome: the documentation says that after its timeout the call continues through the normal permission flow. So fail-closed is implemented at the control boundary itself. For example, the publishing credentials are available only to a separate broker that requires an explicit decision; without that decision, the broker does not change the tariff. A Claude Code rule can additionally ask a person for confirmation, but it does not substitute for the check on the service's side. If the verifier is unavailable, Claude reports that the publication was not completed and authorisation was not obtained.

Comparing the outcomes. In the first case, work continues on a known draft, accepting the limited risk of unchecked formatting. In the second, the external system does not accept the change without a positive authorisation, so an unavailable control does not turn into an opportunity to publish. Fail-open and fail-closed describe how the whole system chooses to respond to missing evidence, not just the name of a timeout field.

Maintaining hooks

Every hook needs a clear role, an owner and a defined scope. For each event, matcher and decision, document which condition is checked, which values are read, when a failed check blocks, asks for confirmation or only reports, and what permissions the script itself has. Keep a log of decisions without secrets or unnecessary fragments of arguments. If several hooks react to the same call, record their independent actions and their precedence.

After any change to Claude Code, a script, a dependency or the shape of the parameters, check the documentation and the example payloads for the relevant event. A matcher can quietly stop matching a tool; the configuration can be syntactically wrong; the executable can be missing; the JSON output can contain stray text; exit code 1 may not block the action. Confirming that the settings were loaded does not prove that the right handler ran and returned a decision.

Do not attach a slow external service to every harmless read or edit. Use matchers only on the events you need, trim the input to what is necessary, set a time limit and think through how the service is isolated. If a critical control did not run, the system must show the result as unverified, not as "success with no errors". Keep real tests for the hook itself and for the access rules within the team's process; this unit itself does not involve installing or running any hooks.

Key points for practice

A hook answers the question "what automatic action will happen when this event occurs?". That is useful for clear, repeatable procedures: check a call before the action, format a file after a write, add a signal about an error. But the strength of the decision depends on the event, the output format, the process's permissions and the other layers of control. PreToolUse can reject a tool call; PostToolUse only works with what has already happened. Even a precise hook does not replace a business check or the permissions of the operating environment.

If a rule matters, find out four things: exactly which call it sees; exactly how a rejection is returned; what happens on a timeout or an error; and which lower layer will stop the rule from being bypassed by another route. These answers show whether the hook really serves the control you need or merely creates a feeling of safety.

Sources and further reading

Claude Code reference pages may change. The version and snapshot date that the fast-changing facts are tied to are given below.