Cursor hooks: a complete guide, and why they sometimes don’t fire
Cursor hooks let a script watch or block what the agent does: every shell command, MCP call, file read and edit. They are powerful, and they fail in quiet ways. This is everything we learned wiring them up for real.
SHORT ANSWER
Put a hooks.json with "version": 1 in ~/.cursor/ (all projects) or .cursor/ in a repo, point each event at a command, and print JSON to stdout. Use beforeShellExecution and beforeMCPExecution to allow, deny or ask, and set failClosed: true if a broken hook must block instead of letting the command through. When a hook seems ignored, open the Hooks output channel first: most problems are a path, a workspace that is not trusted, invalid JSON, or an event the Cursor CLI does not send yet.
What Cursor hooks are
A hook is a command Cursor runs at a fixed point in the agent loop. Cursor sends a JSON object describing what is about to happen on the command’s standard input, waits, and reads a JSON answer from its standard output. Some hooks only observe (log the edit, format the file). Others decide: a beforeShellExecution hook can let a command run, stop it, or ask you first.
That makes hooks the right tool for three jobs: guardrails (never run rm -rf or git push --force without a human), automation (format or lint after every edit), and visibility (log or forward what the agent does so you know when it needs you).
Where hooks.json lives
Cursor reads hooks from four places and runs all of them. Every matching hook from every source runs, and Cursor merges their answers.
| Level | File | Notes |
|---|---|---|
| User | ~/.cursor/hooks.json | Applies to every project. Relative commands resolve from ~/.cursor/. |
| Project | <project>/.cursor/hooks.json | Checked into the repo. Runs only in a trusted workspace. Relative commands resolve from the project root. |
| Team | Cursor web dashboard | Enterprise only, synced to every member. |
| Enterprise | C:\ProgramData\Cursor\hooks.json (Windows), /Library/Application Support/Cursor/hooks.json (macOS), /etc/cursor/hooks.json (Linux, WSL) | Managed by IT. |
When answers disagree, the strictest one wins: a deny from any source beats ask, and ask beats allow. The smallest working file looks like this (it is the example from Cursor’s docs):
version must be 1. Cursor watches the file and reloads it when you save. If a change does not take effect, restart Cursor. That alone fixes a surprising number of reports on the forum.
The events you can hook
The current list from Cursor’s docs, grouped by what they are for:
| Group | Events | Can it block? |
|---|---|---|
| Shell and MCP | beforeShellExecution, afterShellExecution, beforeMCPExecution, afterMCPExecution | The two before… hooks: allow, deny or ask |
| Any tool | preToolUse, postToolUse, postToolUseFailure | preToolUse: allow or deny (ask is not enforced today) |
| Files | beforeReadFile, afterFileEdit | beforeReadFile: allow or deny |
| Prompt | beforeSubmitPrompt | Yes, with continue: false |
| Agent loop | stop, afterAgentResponse, afterAgentThought, preCompact | No (stop can send a follow-up message) |
| Subagents | subagentStart, subagentStop | subagentStart: allow or deny (ask counts as deny) |
| Session and app | sessionStart, sessionEnd, workspaceOpen | No |
| Tab completions | beforeTabFileRead, afterTabFileEdit | beforeTabFileRead: allow or deny |
Every event receives a few common fields (conversation_id, generation_id, model, hook_event_name, cursor_version, workspace_roots) plus its own. beforeShellExecution gets the full command and its cwd; stop gets a status of completed, aborted or error.
Allowing, denying and asking
A deciding hook prints one JSON object. For the shell hook, that looks like this:
permissionisallow,denyorask.user_messageis what you see in Cursor.agent_messageis what the agent is told, so it can change course instead of retrying the same command.
Exit codes matter as much as the JSON. Exit 0 means “use my JSON”. Exit 2 blocks the action, the same as deny. Any other exit code is treated as a failed hook, and the action goes ahead, unless you set failClosed. For permission hooks, invalid JSON or JSON that does not match the schema blocks the action.
Cursor’s docs list ask as supported for beforeShellExecution and beforeMCPExecution. In March 2026 Cursor staff wrote on the forum that hooks only enforced deny and that allow and ask were “basically ignored”, especially with the command allowlist or the sandbox on. We have not found a fix announcement. If a guardrail must hold, return deny, not ask, and test it in your own version.
failClosed and timeout
Each hook entry can carry options next to command:
failClosed: trueturns a crash, a timeout, a non-zero exit or empty output into a block. The default isfalse: a broken guard silently lets everything through. For a safety hook, you almost always wanttrue.timeoutis in seconds. Leave enough room if your hook waits for a human: a person reading a diff needs more than five seconds.matcherfilters when the hook runs. For shell hooks it is matched against the full command string; for tool hooks against the tool type, such asShell,Read,WriteorMCP:<tool_name>.
There is a trade-off. In July 2026 a user on macOS hit “MainThreadShellExec not initialized”: Cursor’s own shell host was not ready, and because the hook was fail-closed, the agent’s shell was blocked. Cursor staff said the fix had shipped and recommended keeping failClosed: true. If you see that error on an older build, “Developer: Reload Window” was the workaround.
Hooks on Windows
On Windows, the Cursor desktop app runs hook commands through PowerShell and pipes the JSON in. A PowerShell script reads it from $input; any other program reads standard input as usual. Three things trip people up:
- Bash scripts don’t run as-is. A
guard.shfrom a macOS teammate fails under PowerShell. Use a.ps1script, a compiled program, or call your interpreter explicitly (python .cursor/hooks/guard.py). A forum thread from January 2026 shows a Git Bash setup failing withsyntax error near unexpected token '[Convert]::FromBase64String', which is PowerShell code reaching bash. - Execution policy. If PowerShell refuses to run scripts, start them with
powershell -NoProfile -ExecutionPolicy Bypass -File …, print the JSON, and end withexit 0. - GUI programs return too early. This one cost us a day. If your hook is a Windows program built for the GUI subsystem, PowerShell starts it and moves on without waiting. Cursor reads empty output, so the hook looks like it did nothing (or, with
failClosed, blocks everything). Build the hook as a console program, or wrap it so PowerShell waits for it to exit.
Project-level hooks also need a trusted workspace. A Cursor staff member listed what to check when they do nothing: the Hooks log line “Hook N produced no output”, Settings → Hooks showing the workspace as trusted, and errors such as spawn, ENOENT, EPERM or ExecutionPolicy in the console.
Hooks in the Cursor CLI
The Cursor CLI (cursor-agent) reads the same ~/.cursor/hooks.json and .cursor/hooks.json, but it has never sent every event the editor sends. In January 2026 only the two shell hooks fired. By April, Cursor staff confirmed beforeShellExecution, afterShellExecution, afterFileEdit, postToolUse, stop and sessionStart worked in the CLI, while afterAgentResponse and afterAgentThought still did not (confirmed again in May). Any event not on that list: test before you rely on it.
Cloud agents are stricter still: user-level hooks from ~/.cursor/hooks.json don’t run there at all.
When a hook doesn’t fire: a checklist
- Open the Hooks output channel (View → Output → Hooks) or the Hooks tab in Customize. It shows which hooks loaded, every run, and errors.
- Check the JSON file itself.
"version": 1present, event names spelled exactly (they are case-sensitive), valid JSON. - Check the path. Relative commands resolve from the project root for project hooks and from
~/.cursor/for user hooks. - Trust the workspace. Project hooks don’t run in an untrusted folder.
- Run the hook by hand. Pipe a sample JSON into it and look at stdout. Anything other than one JSON object (a stray
echo, a warning) breaks parsing. - Restart Cursor. Saving reloads the file, but several forum reports were fixed only by a full restart.
- Know the gaps.
askandallowwere reported as ignored next to the allowlist and the sandbox;subagentStart/subagentStopwere reported as not firing in some versions; the CLI skips some events. Commits from the built-in Source Control panel are not intercepted: there is no pre-commit hook event.
Hooks, auto-run and Auto-review
Hooks are separate from Cursor’s own approval settings (Settings → Agents → Approvals & Execution). Auto-run and Auto-review decide whether Cursor asks you in its panel; your hook adds its own decision on top. That is why “my hook said ask, but the command just ran” is such a common report: another permission path already allowed it.
One more thing we learned the hard way: with Auto-review, Cursor can ask about some commands in its own window. A hook never sees that question. It happens inside Cursor, not in the hook pipeline.
Where Pet Island fits
If what you want from hooks is “show me every risky command and let me decide without switching windows”, that is what Pet Island does with Cursor. It installs the hooks for you (beforeShellExecution and beforeMCPExecution, with failClosed on so a dead helper never lets a command through) and puts each request on a small island at the top of your screen with the full command. You allow or deny it there; Cursor’s own agent waits.
Commands and file edits from the Cursor editor work from the island. Questions and plans show on the island, and you answer them in Cursor. In the Cursor CLI, commands work from the island; other events depend on what the CLI sends. Uninstalling removes only the entries Pet Island added; your own hooks stay.
FAQ
Do Cursor hooks work on Windows?
Yes. The desktop app runs them through PowerShell and passes the JSON on standard input ($input in a PowerShell script). Bash scripts written for macOS need a PowerShell or Python version, and GUI-subsystem programs must be wrapped so PowerShell waits for them.
Why does my hook return ask but the command still runs?
Cursor staff said in March 2026 that hooks only reliably enforced deny; allow and ask could be overridden by the allowlist or the sandbox. Use deny for anything that must not run, and check the Hooks output channel to see what your hook actually returned.
What does failClosed do?
With failClosed set to true, a crash, timeout, non-zero exit or empty output blocks the action. Without it, a broken hook lets the action go ahead.
Where do I see hook errors?
In the Hooks output channel (View → Output → Hooks) and the Hooks tab in Customize.
Do hooks work in the Cursor CLI?
Partly. The CLI reads the same hooks.json files. As of May 2026 Cursor staff confirmed the shell hooks, afterFileEdit, postToolUse, stop and sessionStart, but not afterAgentResponse or afterAgentThought.
Can a hook stop a git commit from the Source Control panel?
No. Cursor has no pre-commit hook event, and commits made through the built-in Source Control UI are not intercepted. Use a normal git pre-commit hook for that.
Sources
Every setting and behavior on this page was checked against these pages on October 26, 2026. Agents change fast; if something no longer matches, the official page wins.
- Cursor Docs: Hooks
- Cursor forum: The cursor hooks did not execute as expected (staff: only deny enforced)
- Cursor forum: beforeShellExecution allow/ask ignored, allow-list takes precedence
- Cursor forum: Fail-closed hook and MainThreadShellExec not initialized
- Cursor forum: Malformed JSON from a hook silently allowed the command (fixed)
- Cursor forum: Cursor CLI doesn’t send all hook events
- Cursor forum: Project-level hooks fail to execute on Windows
- Cursor forum: Project hooks fail on Windows with Git Bash
- Cursor forum: subagentStart and subagentStop never fire