Tutorial · Updated
Claude Code hooks: capture every edit with PostToolUse
A working Claude Code PostToolUse hook that sends every Edit and Write to another program, plus the turn hooks, the payloads, and the gotchas we hit with claude -p.
Claude Code can run a shell command at fixed points in a session: before and after each tool call, when you submit a prompt, when a turn stops, and when the session ends. The command gets a JSON payload on stdin. That is enough to send every file edit Claude makes to another program: a log, a linter, a review queue. This tutorial builds that hook, adds the turn hooks, and lists the problems we hit running it against claude 2.1.282 in September 2026.
The reference is the official Claude Code hooks documentation. Where this page and the docs disagree, the docs win for your version; we say which version we checked.
Where hooks live
Hooks sit in the same settings files as the rest of Claude Code's configuration. Hooks from every level are merged, so a hook in one file does not remove a hook in another.
| File | Scope | Committed? |
|---|---|---|
~/.claude/settings.json | Every project on this machine | No |
.claude/settings.json | One project, shared with the team | Yes |
.claude/settings.local.json | One project, this machine only | No |
Put an edit hook that points at a script on your own disk in .claude/settings.local.json. The command holds an absolute path, such as /Users/you/tools/on-edit.js. That path means nothing on a teammate's machine or in another checkout, so it should never reach the shared settings.json. The docs say Claude Code gitignores settings.local.json when it saves a setting there itself. If you create the file by hand, check that git ignores it (see the last section).
If the whole team should run the hook, commit the script inside the repository and reference it from .claude/settings.json through $CLAUDE_PROJECT_DIR, which the docs describe as the project root where the session started.
A minimal config
The file maps an event name to a list of groups. Each group has an optional matcher and a list of handlers. A handler of type: "command" runs its command through a shell. This config runs one script after every file edit, and again at each turn's start and end:
- PostToolUse fires after a tool call. The matcher
Edit|Write|MultiEditlimits it to the file tools. A matcher made only of letters, digits,_,-, spaces, commas and|is a list of exact tool names. Any other character makes it an unanchored JavaScript regular expression. - MultiEdit is only sent by older Claude Code builds. The current docs list
EditandWrite. Keeping it in the matcher costs nothing. - UserPromptSubmit marks a turn's start. It runs with
async: true, in the background, so it never holds up the model call. - Stop, StopFailure and SessionEnd mark the end: a normal stop, a turn that ended on an error, and the session closing. These run in the foreground. The gotchas below say why.
timeoutis in seconds. The default for a command hook is 600, so set a small one.
The docs say Claude Code's file watcher normally picks up direct edits to hooks, so you should not need to restart. Type /hooks in Claude Code to see what it loaded: a read-only list of every event, its matchers, and each handler.
For a worked example, this is the file TypeBack's installer writes on a Mac with node on the PATH. It is the same shape, with the script quoted for sh and an --agent flag on the turn hooks:
What the script receives
Every event sends a JSON object on stdin. These fields come with all of them:
session_id,transcript_path,cwd,permission_mode,hook_event_name
PostToolUse adds the tool call:
tool_name:Edit,Write, orMultiEditon older builds.tool_input: forEdit,file_path,old_stringandnew_string. ForWrite,file_pathandcontent. ForMultiEdit,file_pathand aneditsarray ofold_stringandnew_stringpairs.tool_responseandtool_use_id.
The turn events add little. UserPromptSubmit carries the prompt. Stop carries stop_hook_active and last_assistant_message. SessionEnd carries a reason, such as clear, logout or prompt_input_exit.
Here is an Edit payload, shaped like the ones we test against:
Note that old_string and new_string are fragments, not the whole file. To place the change in the file, read the file after the edit and find new_string in it. If it appears exactly once, you know where the hunk is. If it appears more than once, you cannot tell which copy changed, so fall back to git diff for that file.
A script that logs every edit
This script reads stdin to the end, pulls out the fields above, and appends one JSON line to ~/agent-edits.jsonl. It writes nothing to stdout and exits 0 whatever happens.
It is CommonJS. Keep it outside any folder whose package.json says "type": "module", or name it on-edit.cjs. Try it before you wire it up:
You should see exit code 0, no other output, and the edit as the last line of the log.
Gotchas
Never print to stdout
Claude Code reads a hook's stdout. If it starts with { and ends with }, it is parsed as JSON hook output, which can change what Claude Code does next. On UserPromptSubmit, plain text on stdout is added as context the model sees. A stray console.log is not harmless. Write to a file.
Always exit 0
Exit code 2 is a blocking error, and Claude Code uses your stderr as its message. Other non-zero codes are reported as non-blocking errors. An uncaught exception in Node exits with 1, so wrap everything in try and exit 0 at the end.
Keep it fast
A foreground hook holds the session until it exits. A PostToolUse hook runs after every edit, so its cost adds up over a long turn. Do the minimum in the hook: hand the payload to something else and return. Give the script its own deadline of a few hundred milliseconds, so a slow disk or a dead socket cannot hold the agent.
claude -p kills background hooks
async: true is right for the turn start. It is wrong for the turn end. A one-shot run with claude -p exits right after its last turn and kills any background hook still running, so an async Stop hook may never finish. With an async hook still pending, we also saw Claude Code 2.1.282 skip SessionEnd. Run the end hooks in the foreground. The cost is one short process start after the agent has finished, about 30 ms for node on a Mac.
Because the start runs in the background, a quick turn's Stop can reach you before its UserPromptSubmit. Record the time in the hook and order events by it, not by arrival.
Async hooks have no timeout
The docs say Claude Code does not enforce timeout on a command hook run with async: true. A background hook that hangs stays hung. Give it its own deadline, for example a setTimeout that calls process.exit(0).
node must be on the PATH
The command runs in a shell, so node has to be on the PATH that shell sees. Claude Code's native installer does not need Node, so a machine may not have it. If you cannot rely on it, use an absolute path to a Node binary in the command.
Windows runs a different shell
On macOS and Linux the command runs under sh -c. On Windows, Claude Code uses Git Bash when it is installed and PowerShell otherwise. In PowerShell, a line that starts with a quoted path is read as a string, not a command. Prefix it with the call operator: & 'C:\Program Files\nodejs\node.exe' 'C:\tools\on-edit.js'.
VS Code may run these hooks too
With chat.useClaudeHooks on, VS Code runs the hooks in .claude/settings*.json for Copilot agent sessions, and it ignores the matcher. Your script then also gets Copilot's payloads, for every tool. VS Code payloads carry a timestamp field and Claude Code's never do, which is how to tell them apart. The VS Code agent hooks tutorial covers that side.
Keep the hook file out of git
A hook file with your absolute paths should never be committed. A teammate who pulls it gets a hook that runs a script they do not have, on every edit. Git has a per-clone ignore list at .git/info/exclude. It works like .gitignore, but it is never committed, so it keeps your machine's files out without changing a shared file.
git rev-parse --git-path info/exclude finds the right file in a linked worktree too. Paths in it are relative to the repository root. git check-ignore -v prints the rule that matched, so you can see it worked.