Vikas Tiwari

← Writing

Cursor Hooks: scripts that sit on the agent loop

9 min read
  • Cursor
  • Developer experience
  • AI tooling

Agentic editors do a lot in a short time: they read files, run shell commands, call MCP tools, and stream edits back into your repo. That speed is the point—but it also means you may want guardrails, hygiene, or telemetry that kick in automatically, without you clicking through the same checklist on every session. Cursor’s hooks system is the extension point for that: short-lived processes that observe or steer the loop at named stages.

Conceptually, a hook is just a command Cursor runs for you. It receives a JSON payload on standard input, may print JSON on standard output, and exits with a status code that tells Cursor whether it succeeded, failed, or (for some hooks) explicitly blocked an action. The official documentation describes this as JSON over stdio in both directions; you can treat it like a tiny CLI contract between your repo—or your home directory—and the product.

Hooks are not the same as editor snippets or lint rules. They run around the agent loop itself: for example before or after a shell command, before an MCP tool runs, after a file edit, when a session starts, or when the agent stops. That placement matters because you can decide whether you are purely observing (logging, metrics) or participating in permission decisions (allow, deny, ask the user). The docs catalogue each event, the fields on the wire, and which response fields are honored.

Agent Chat and Cmd+K share one family of events—session lifecycle, generic tool hooks, shell and MCP surrounds, file read and edit, prompt submission, compaction, stop, and hooks that fire after assistant text or thinking blocks. Inline Tab completions use a separate, smaller set so you can treat autonomous Tab differently from an explicit agent task. If you only configure Agent hooks, Tab keeps its defaults; if you care about secrets in Tab reads, you wire Tab-specific hooks there.

Configuration lives in hooks.json. You can scope hooks to a single repository with .cursor/hooks.json at the project root, or globally under ~/.cursor/hooks.json for everything you open. Project hooks run with the working directory set to the project root, so script paths should look like .cursor/hooks/format.sh—not the ./hooks/... style that is meant for user-level hooks running from ~/.cursor/. Getting that wrong is the first thing to fix when a hook “does nothing.”

Cursor merges hook definitions from every applicable layer—enterprise, team, project, and user—and runs matching hooks from all sources. When outputs conflict, higher-priority sources win. For teams, that means you can check baseline guardrails into git while platform administrators add fleet-wide policies; for solo developers it usually means one hooks.json in the repo plus maybe a personal formatter in your home directory.

Each hook entry is either a command hook (default) or a prompt hook. Command hooks are the flexible route: shell, Python, TypeScript with Bun, anything that reads stdin and prints JSON. Prompt hooks hand a natural-language policy to a fast model and expect a structured allow or deny style answer—useful when you want a lightweight gate without maintaining parsers for every edge case. Timeouts, optional matchers, failClosed for security-sensitive before-read or before-MCP paths, and loop limits for stop-style automation are all knobs on the definition, not hard-coded in your script.

Matchers deserve a mention because they keep hooks cheap. Instead of running an audit script on every tool call, you can attach a matcher so it only runs for Shell, or only when the command line matches curl or kubectl, or only when a subagent of type explore starts. The documentation maps which matcher field applies to which hook; leaning on that avoids turning every save into a fork storm.

Exit codes for command hooks follow a predictable contract: zero means “consume my JSON output,” exit code two blocks the action in the same spirit as returning a deny permission, and other failures are treated as hook errors—by default the action proceeds, which is friendlier for formatters but not what you want for a compliance gate. For the latter, failClosed flips the default so a crashed or timed-out hook denies instead of allowing.

Common patterns in the docs mirror what teams actually ask for: format or lint after edits, append structured lines to an audit log on shell and MCP boundaries, redact or block reads of sensitive paths, require explicit approval before network-heavy commands, or use the stop hook to count failures and optionally enqueue a follow-up user message for bounded retries. None of that replaces code review, but it reduces “oops the agent ran curl against prod” from a surprise to a policy decision you configured once.

Ecosystem vendors are starting to ship integrations that speak the same hook surface—governance around MCP, secrets scanning, dependency installs, and similar—so you may be able to buy depth instead of only scripting it. Whether you integrate a partner or roll your own, the integration boundary stays the same: stdin JSON, stdout JSON, clear permissions.

If you are adopting hooks, start small: one afterFileEdit formatter in a repo-level hooks.json, confirm it shows up in Cursor’s Hooks settings tab and the Hooks output channel, then add a beforeShellExecution matcher for the commands you actually worry about. When you need the full event matrix, input schema, and Windows versus macOS paths for enterprise distribution, treat https://cursor.com/docs/hooks as the canonical reference and work from there.