5. A Check That Runs Without Being Asked
Summary
Turning the type check into a hook
The routine still relied on memory for two steps: running the type check and running the tests. An example shows the gap. A request to add an optional note field to list items looks fine in the browser. Then the compiler fails on a shared-view component the agent never opened, because the note was marked required by mistake. CLAUDE.md is an instruction the model can skip. A hook is a command Claude Code itself runs at a defined moment, every time.
The chapter wires the project's no-emit TypeScript check to the PostToolUse event with an Edit|Write matcher, as described in the hooks guide. It lives in the committed project settings file so everyone working in the repository gets it. Exit codes carry the meaning, per the hooks reference:
- Zero stays silent.
- Two hands the compiler's stderr back to Claude to fix.
- Any other code is a non-blocking warning.
The build starts small: a hook that only logs "hook fired." It is confirmed with /hooks, and a file read is shown not to trigger it. The logger is then replaced with a committed script. The script reads the event JSON with jq, skips non-TypeScript files, and checks the whole project, because the note bug lived in an untouched file. It runs through CLAUDE_PROJECT_DIR with a 120-second timeout. The test suite stays out of the hook because it is too slow to run after every edit.
The hook is proven by breaking types on purpose, then by making a correct edit that should produce no output.
Where hooks go wrong
Four risks are covered:
- Cost: every second the hook takes is added to every edit.
- Setup failures: a missing tool or uninstalled dependencies should exit one, not two, so the agent isn't sent to fix errors it didn't cause.
- Repair loops: start from a clean type check, and step in if the agent goes in circles.
- Trust: hooks run with your permissions without a prompt, so review them like code.
disableAllHooks turns off custom hooks but not hooks from an organization's managed settings. The work ends with a commit and one line in CLAUDE.md explaining where the type errors come from.
This fortnight's releases
The Claude Code changelog covers versions 2.1.286 through 2.1.288:
- PreToolUse and PermissionRequest hooks now fail closed.
- Shell sandbox escapes through nested
bash -cand symlinked writes are patched. - Background time limits are strictly enforced in headless runs, CI and the Agent SDK.
- Up arrow on an empty prompt restores a request cleared with Control C.
/code-reviewaccepts a max-findings option.
The routine this course has built so far works. You inspect the project and state what should happen. You let Claude Code make one small change, read the diff, run the checks, click through the journey and commit. The done-checkbox feature made it across three sessions because the plan and the decisions lived in files, not in the conversation. One weak spot is still there, though. Two steps in that routine happen only because you remember to do them: running the type check and running the tests.
This chapter closes that gap. You will take the type check you already run by hand and make Claude Code run it on its own, every time it edits a file, and send any failure straight back to the agent so it fixes the problem before it says it is finished.
How a check becomes a hook
Here is the gap in action. It is an illustrative example, using the shared-list app.
Say you want each list item to carry an optional note, a short line of extra text under the name. You ask Claude Code for something small. Add an optional note field to the item type, and show it under the item name when it is present. The agent edits the type definition, then the item component, and reports that it is done. The diff looks reasonable. The note appears in the browser when you test it.
Then you run the type check, the command in your CLAUDE.md that runs the TypeScript compiler without building anything. It fails. A second component, the one that renders items in the shared view, builds an item object by hand. When the agent changed the type, it marked the note as required by mistake. That second component now hands the compiler an item with no note, and the compiler says so, with a file name and a line number.
Nothing about this is dramatic. The agent did not lie. It edited the files it was looking at, and the page it checked worked. The error lived in a file it never opened. You caught it because you ran the type check. On a day when you forgot, or were in a hurry, the break would have gone into a commit.
You might ask why the CLAUDE.md file does not already solve this. It does say that a finished change means the tests pass, and it lists the type check command. But CLAUDE.md is an instruction. Chapter two drew this line for the deny list. The model reads instructions and usually follows them. It can also decide, partway through a task, that a check is not needed this time, or simply lose track of it in a long session. The deny list was different because Claude Code itself enforces it. The model cannot talk its way past a blocked command. Checking work needs the same kind of thing: something the tool runs, not something the model is asked to remember.
That thing is a hook.
A hook is a command you register with Claude Code so that it runs at a defined moment in a session. Claude Code is the application. It sits between the Claude model and the tools that read files, edit files and run commands. Because the application is the one calling those tools, it knows exactly when each one finishes. A hook ties your command to one of those moments. When the moment comes, Claude Code runs your command. It does this every time. The model does not decide whether it happens.
Claude Code names these moments as events. The one that matters here is called Post Tool Use, written as one word, PostToolUse. It fires right after a tool finishes. There are others. Pre Tool Use fires before a tool runs and can block it. Another event fires when a tool fails. For checking an edit, though, you want the moment just after the file has changed on disk. That is Post Tool Use.
The file-changing tools have plain names. Edit replaces text inside an existing file. Write creates a file or replaces one completely. So the plan is simple to say: after Edit or Write finishes, run the type check.
Before writing anything, decide where the hook lives. Hooks go in the same settings files as the permission deny list, under a section called hooks. Which file you choose decides who gets the hook. The project settings file, settings dot json inside the dot-claude folder at the root of the repository, is the one you commit. Everyone who works in the repository gets it, and so does any future session on any machine that checks the project out. Next to it sits a local project file, settings dot local dot json, which Claude Code keeps out of Git automatically. It is for your own experiments in this one project. Then there is your personal user settings file, in the dot-claude folder in your home directory, which applies to every repository you open. Above all of these, an organization can push managed settings that the lower layers cannot override. Claude Code merges hooks from these layers.
For the type check, the project file is the right home. The check belongs to this codebase. It uses this project's compiler settings. Anyone who works here should get it. A hook that formats code the way you personally like would belong in your user file instead.
Inside that hooks section, the shape has three levels. First comes the event name, Post Tool Use. Under it comes a matcher group. That is a matcher, which picks which tools trigger it, plus a list of handlers. Each handler says what to run. For a command handler, that means the type, which is command; the command itself; and an optional timeout in seconds.
The matcher deserves a moment. If you leave it empty, or set it to a star, it matches every tool, and your hook would run after every file read and every search too. That is wasteful. A plain name matches that tool exactly. Names joined by a pipe character, Edit pipe Write, match either one. Patterns with regular expression symbols are treated as regular expressions. For this hook, Edit pipe Write is exactly right. Handlers can also carry a finer filter, an if field written like a permission rule, such as Edit with a pattern for files ending in dot t s. With it, Claude Code does not even start your command for files that do not match.
The last piece of the mechanism is how a hook talks back. Claude Code passes the hook a description of the event as JSON, a structured text format, on its standard input. That description includes which tool ran and, for an edit, the path of the file. The hook replies in two ways: the number it exits with, and the text it prints. The exit number decides what happens next.
Exit zero means everything passed. Claude Code stays quiet and the agent carries on.
Exit two is the blocking signal. For Post Tool Use, the edit has already landed on disk, so exit two cannot undo it. What it does is stop the agent's flow and hand the text the hook printed to its error stream, standard error, straight to Claude as feedback. The agent reads that text, the compiler's own error message, and makes a corrective edit. That is the loop you want. The agent repairs its own mistake before it reports done.
Any other exit number, such as one, counts as a warning that does not block. Claude Code notes it with the first line of the error text but does not interrupt Claude. Hold on to that difference between one and two. It matters later.
Building it small first
The course has a rule for new power tools: test a small interaction before you put it into a larger workflow. So the first hook checks nothing at all. It only proves that it fires.
Open Claude Code in the repository and ask it to add a Post Tool Use hook to the project settings file with the matcher Edit pipe Write. The command should append the current time and the words "hook fired" to a small log file inside the dot-claude folder. Ask it to show you the settings change before writing it. Read the diff. You should see the hooks section with Post Tool Use, a matcher of Edit pipe Write, and one command handler.
Next, confirm that Claude Code sees the hook. Then type slash hooks. That command lists every active hook, which settings file it came from, its matcher and its status. You should see yours under Post Tool Use, coming from project settings. If it is not there, the settings file probably has a JSON mistake, or you are still in the old session.
Now ask the agent for a harmless edit, such as adding a comment line to the README file. Afterward, open the log file. There should be one line with a time. Ask for a file read, like "show me the item component." No new line should appear, because reading is not Edit or Write. You have just confirmed two things: the hook fires, and the matcher filters. Neither was a guess.
If something looks wrong, Claude Code has a debug mode. Launching with claude dash dash debug, or typing slash debug inside a session, shows the event payloads, the matching decisions, the hook's output and any timeouts.
Pointing it at the type check
Now replace the logging command with the real check. A check like this is clearer as a short script in its own file than as a long string stuffed inside the settings. Put it in a hooks folder inside dot-claude and commit it with everything else.
The settings handler then just runs that script. It finds the script through a variable Claude Code provides, CLAUDE underscore PROJECT underscore DIR, which holds the project root. So the hook works no matter which folder the session is sitting in. Give the handler a timeout of a hundred and twenty seconds. The default for command hooks is six hundred seconds, ten minutes, far longer than a type check on a small app should ever need. A tighter limit means a stuck compiler cannot freeze the session for ten minutes.
The script does four things in order. It reads the JSON event from standard input. It pulls out the edited file's path using jq, a small command-line tool for reading JSON. If you do not have jq yet, your system's package manager can install it. If the file does not end in dot t s or dot t s x, the script exits zero right away. A README edit has nothing to type-check. Otherwise, it runs the project type check, the same compiler command with the no-emit option that is already in your CLAUDE.md. If the check passes, it exits zero. If it fails, it prints a short line saying TypeScript validation failed and the errors should be fixed, then prints the compiler's full output to standard error, and exits two.
Notice that the script checks the whole project, not only the edited file. That is on purpose. The note-field bug lived in a file the agent never touched. Only a whole-project check would have caught it.
The file needs permission to run as a program. You give it that once with chmod plus x on the script. Ask the agent to write the script and the settings change. Read both diffs line by line, because this is code that will run without asking you. Then make the script executable and start a fresh session.
What about the tests? Your CLAUDE.md also lists the test suite, and it is tempting to run it here too. Think about what that costs. The hook runs after every single edit. One feature can mean a dozen edits. Watch how long your suite takes. If it includes the browser journey tests from the done-checkbox feature, it is probably measured in many seconds or minutes. Multiply that by every edit, and you have made every change crawl. Plus, halfway through a change the tests often fail anyway, because the agent has not finished yet.
The type check is a different kind of check. On an app this size it is quick. And a type error halfway through usually means something really is wrong. So the sensible split for this project is to run the type check on every edit, and leave the full suite where it already is: in the definition of done, once at the end of a change. If your unit tests are fast, you could add them later as a second step. Time them first and decide from the number, not the hope.
Proving it works
A check you have not seen fail has not been tested. So make it fail on purpose.
In the fresh session, confirm with slash hooks that the type check hook is listed. Then ask for a change that you know will break the types. For example: change the function that sets an item's done flag so its argument is a string instead of a boolean, and do not update the places that call it. You are asking the agent to make a mistake on purpose.
Watch what happens. The agent edits the database client file. The hook fires. The file ends in dot t s, so the script runs the compiler. The compiler finds the callers still passing true and false. The script prints those errors and exits two. Claude Code hands the errors to the agent. You will see the agent read them and respond, often without you typing anything, usually by fixing the callers or reverting the change, since the errors say exactly where the mismatch is. Your instruction asked it to leave the callers alone, so it may stop and tell you the two goals conflict. That is fine too. Either way it did not report a clean finish while the types were broken.
Then check the opposite case, because a hook that is noisy on good work is a different failure. Ask for a correct edit, for example a clearer label on the checkbox. The hook should run and exit zero, and you should see nothing at all. Quiet on success is the design.
Now go back to the note-field change from the start of the chapter. Run it again with the hook in place. When the agent marks the note as required, the compiler complains about the shared view. The agent gets the error, fixes it, and finishes with the types clean. The step you used to remember happened without you.
Where hooks go wrong
A hook runs automatically, so its mistakes repeat automatically too. Four ways it can go wrong are worth knowing before you trust it.
The first is cost. Every second the hook takes is added to every edit. You already chose the type check over the full suite for this reason. Keep an eye on it as the app grows. If the type check starts to drag, you can use the if filter to skip it for files that cannot affect types. Or you can look at why the check got slow. Do not just raise the timeout.
The second is a failure that has nothing to do with the change. Say jq is missing on a new machine, or the dependencies were never installed, so the compiler cannot even start. If the script treats every problem as exit two, the agent gets told, after every edit, that it must fix an error it did not cause and cannot fix in the code. It may go poking at files that were fine. The answer is in how the script splits its exit numbers. Exit two should mean exactly one thing: the compiler ran and found type errors in the project. A missing tool or a broken setup should exit one instead. That shows a warning you will see, but it does not block the agent. Then you fix the machine, not the code.
The third is a repair loop. Exit two invites the agent to edit again, and that edit fires the hook again. That is the point. Usually it settles in one or two rounds. But if an error lives in a file the agent should not touch, or the agent keeps guessing wrong, you can watch it edit, fail, edit and fail. Two things keep this in bounds. The project must start out clean, so check that the type check passes before you install the hook. Otherwise every edit inherits old errors. And when you see the agent going in circles, step in. Interrupt it, read the compiler's message yourself, and give a narrower instruction. A hook is a tight feedback loop, not a promise that the agent will always get out of it.
The fourth is trust. A command hook runs with your own user permissions, on your machine, without a prompt. A hook committed to a repository will run for anyone who opens that repository in Claude Code. So treat the script like any program that runs on its own. Review it in the diff the same way you would review the app's code. Only run hooks in repositories you trust. When you pull changes, watch for edits to the hooks section or the hooks folder. Do not paste raw input from the event straight into a shell command. Read fields out with jq, as this script does. Never put secrets into a shared hook. And keep the matcher narrow, so the hook only runs where it is needed.
If you ever need the hooks out of the way, setting disable all hooks to true in your user, project or local settings turns off your custom hooks. Hooks pushed by an organization's managed settings stay on. For a single experiment, the local settings file is the place for that switch, because it never reaches the repository. Slash hooks remains the way to see what is active and where it came from.
Making it part of the project
The last step is the same as for any change in this course. Look at the full diff: the settings file with the new hooks section, and the type check script. Commit them together with a plain message, something like "Run the type check after every file edit with a project hook." Then add one line to CLAUDE.md, under the definition of done. It should say that a Post Tool Use hook in the project settings runs the type check after every edit, and that a reported type error must be fixed before the change counts as finished. The hook does not need that line to work. The line is for a future session, or a person, who wonders why errors keep appearing after edits. Now they will know where the errors come from. Commit that too.
Look at what changed. Until now, every check in the routine depended on someone remembering it. The type check no longer does. It runs whether you are at the keyboard or not, and it sends its findings to the one party able to act on them right then. That is the first piece of this workflow that can happen with nobody watching. The hosted runs, the issue-driven pull requests and the unattended releases further along all need checks like this, checks that fire because the system fires them.
The hook handles one check well. Some of the work you repeat is a method rather than a single command, like the way this course reproduces a bug before fixing it. Packaging a method so the agent can pick it up when it fits is what skills are for, and they are the next tool to earn their place.
What changed in Claude Code this fortnight
Three releases, versions two point one point two eighty-six through two eighty-eight, came out between the thirtieth of September and the second of October. A few changes touch work you are doing right now.
The first one is about hooks. In version two eighty-eight, Pre Tool Use and Permission Request hooks now fail closed. If the hook's matching runs into an error, or the event cannot be packaged up, the tool call is blocked instead of quietly allowed. Today's hook is Post Tool Use, so it is not affected. But if you ever write a Pre Tool Use hook to guard something, a broken hook will now stop work instead of letting it through. The smallest next step is to update, and then test any guarding hook by watching it block once.
The same release patched a set of shell sandbox escapes. Commands tucked inside a nested shell, the bash dash c pattern, could slip past safety prompts under the bypass-permissions mode or under shell allow-lists. Writes through symbolic links pointing outside the working folder are now blocked too. If your deny list or allow-list matters to you, and after chapter two it should, update to two eighty-eight. Then run claude dash dash version to confirm.
For the hosted work ahead, background time limits are now strictly enforced in headless and unattended runs. That means print mode with dash p, continuous integration, and the Agent SDK, which is Anthropic's toolkit for building your own agents on the same engine. Interactive sessions have no command timeouts. A long job that runs fine at your desk can be cut off when it runs unattended. When you move a job off the laptop, time it in headless mode before you trust it there. A related fix stops headless processes from dropping their stop signal when a process manager sends another signal at the same moment.
Two shortcuts are worth trying today. If you clear a half-written request with Control C, pressing the Up arrow on an empty prompt now brings it back, pasted text and images included. And the slash code-review command takes a max findings option with a number or the word all. The setting sticks between runs until you set it back to default. Try it with a small number on your next diff, to get the few findings that matter most first.
