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.

By Aslan, Pet Island team · Updated Oct 26, 2026 · 7 min read

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.

LevelFileNotes
User~/.cursor/hooks.jsonApplies to every project. Relative commands resolve from ~/.cursor/.
Project<project>/.cursor/hooks.jsonChecked into the repo. Runs only in a trusted workspace. Relative commands resolve from the project root.
TeamCursor web dashboardEnterprise only, synced to every member.
EnterpriseC:\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):

~/.cursor/hooks.jsonCopy
{
"version": 1,
"hooks": {
"afterFileEdit": [{ "command": "./hooks/format.sh" }]
}
}

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:

GroupEventsCan it block?
Shell and MCPbeforeShellExecution, afterShellExecution, beforeMCPExecution, afterMCPExecutionThe two before… hooks: allow, deny or ask
Any toolpreToolUse, postToolUse, postToolUseFailurepreToolUse: allow or deny (ask is not enforced today)
FilesbeforeReadFile, afterFileEditbeforeReadFile: allow or deny
PromptbeforeSubmitPromptYes, with continue: false
Agent loopstop, afterAgentResponse, afterAgentThought, preCompactNo (stop can send a follow-up message)
SubagentssubagentStart, subagentStopsubagentStart: allow or deny (ask counts as deny)
Session and appsessionStart, sessionEnd, workspaceOpenNo
Tab completionsbeforeTabFileRead, afterTabFileEditbeforeTabFileRead: 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:

stdout of a beforeShellExecution hookCopy
{
"permission": "ask",
"user_message": "This pushes to main. Run it?",
"agent_message": "The user wants to confirm pushes."
}
  • permission is allow, deny or ask.
  • user_message is what you see in Cursor.
  • agent_message is 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:

.cursor/hooks.jsonCopy
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": "./.cursor/hooks/guard.sh",
"timeout": 30,
"failClosed": true
}
]
}
}
  • failClosed: true turns a crash, a timeout, a non-zero exit or empty output into a block. The default is false: a broken guard silently lets everything through. For a safety hook, you almost always want true.
  • timeout is in seconds. Leave enough room if your hook waits for a human: a person reading a diff needs more than five seconds.
  • matcher filters when the hook runs. For shell hooks it is matched against the full command string; for tool hooks against the tool type, such as Shell, Read, Write or MCP:<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:

  1. Bash scripts don’t run as-is. A guard.sh from a macOS teammate fails under PowerShell. Use a .ps1 script, 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 with syntax error near unexpected token '[Convert]::FromBase64String', which is PowerShell code reaching bash.
  2. Execution policy. If PowerShell refuses to run scripts, start them with powershell -NoProfile -ExecutionPolicy Bypass -File …, print the JSON, and end with exit 0.
  3. 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

  1. Open the Hooks output channel (View → Output → Hooks) or the Hooks tab in Customize. It shows which hooks loaded, every run, and errors.
  2. Check the JSON file itself. "version": 1 present, event names spelled exactly (they are case-sensitive), valid JSON.
  3. Check the path. Relative commands resolve from the project root for project hooks and from ~/.cursor/ for user hooks.
  4. Trust the workspace. Project hooks don’t run in an untrusted folder.
  5. 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.
  6. Restart Cursor. Saving reloads the file, but several forum reports were fixed only by a full restart.
  7. Know the gaps. ask and allow were reported as ignored next to the allowlist and the sandbox; subagentStart/subagentStop were 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.

KEEP READING

Answer every agent from the top of your screen.

Windows and Mac · 7 days free, then $8.99 once · $1 of every sale feeds street animals

Try Pet Island free