by Scott Chacon

6 min read

Deep Dive into the new Cursor Hooks

Cursor's latest release has a new beta lifecycle hooks feature that lets you control and customize how Cursor works. Let's check it out.

Deep Dive into the new Cursor Hooks

Cursor Hooks Deep Dive

Recently we got invited by the Cursor team to help them refine and give early feedback on the new hooks system that they've been working on, which we've used to implement our GitButler/Cursor integration. I figured I would take a minute to write up how it works, what it might be used for, and what it's capable of.

(Beta) Hooks in Cursor 1.7

Technically, the Cursor hooks system is currently in beta, so the APIs may change, but here's what it looks like today.

There are currently 6 lifecycle hooks that can run any number of specified commands when any of these points in a running task is hit:

  • When the prompt is first submitted (beforeSubmitPrompt)
  • Before a shell execution runs (beforeShellExecution)
  • Before an MCP execution runs (beforeMCPExecution)
  • Before a file is read (beforeReadFile)
  • After a file is edited (afterFileEdit)
  • When the task is completed (stop)

Setting up hooks

Before we dive into the details, how are hooks defined?

They are found in a combined set of locations, looking for a hooks.json file:

  • Your local project ([project]/.cursor/hooks.json)
  • Your "enterprise" configuration (/etc/cursor/hooks.json)
  • Your user's global config (~/.cursor/hooks.json)

All hooks specified in all of those places will try to be run - so if you have two executables defined for a stop hook in your project's local config and one in your user's global config, all three will be executed.

The hooks.json file looks like this:

JSON
{
  "version": 1,
  "hooks": {
    "afterFileEdit": [
      {
        "command": "hooks/audit.sh"
      },
      {
        "command": "but cursor after-edit"
      }
    ],
    "stop": [
      {
        "command": "but cursor stop"
      }
    ]
  }
}

This will run two hooks at the afterFileEdit step - in this case audit.sh is found in .cursor/hooks/audit.sh relative to the hooks.json file that defines this hook.

The Hooks

Ok, now let's look at the lifecycle hooks that are currently available.

beforeSubmitPrompt

The first thing to always run will be the beforeSubmitPrompt which essentially tells the hook what the prompt was before starting. This hook should be called when the user submits a prompt string, but before the Cursor agent sends it to the model.

Here is an example of what will be sent to stdin for whatever executable this hook runs:

JSON
{
  "conversation_id": "668320d2-2fd8-4888-b33c-2a466fec86e7",
  "generation_id": "490b90b7-a2ce-4c2c-bb76-cb77b125df2f",
  "prompt": "do something super duper awesome",
  "attachments": [
    {
      "type": "file",
      "file_path": "path/to/open/file.rb"
    }
  ],
  "hook_event_name": "beforeSubmitPrompt",
  "workspace_roots": ["/Users/schacon/projects/cc-hooks-example"]
}

So you can see some cool stuff. Clearly the "prompt" is there, also there are "attachments", which is the context of what the user was looking at or had added as context manually.

It also has a conversation and generation id, which help you figure out if the chat context has changed.

The "conversation" is different for each new "chat" you do, and the "generation" is different for each new prompt you type within that chat. So for example, in the GitButler hooks, we create a new branch for each "conversation" and a commit for each "generation".

Finally it has the "workspace roots", which generally will only be one path, but as VS Code can have multi-root setups, can be an array.

This hook is useful for systems like GitButler who need the prompt context in order to help write good commit messages eventually.

In the beta version, you cannot do much here other than record this information - Cursor doesn't respect any output json here currently as far as we can tell, such as stopping the task or adding context. (This is different than the beta spec, so maybe it will change in the future)

beforeShellExecution

Before executing any shell command, Cursor can run a hook to see if it's OK to run it.

When executing a beforeShellExecution hook, it passes something like this to stdin:

JSON
{
  "conversation_id": "668320d2-2fd8-4888-b33c-2a466fec86e7",
  "generation_id": "490b90b7-a2ce-4c2c-bb76-cb77b125df2f",
  "command": "git status",
  "cwd": "",
  "hook_event_name": "beforeShellExecution",
  "workspace_roots": ["/Users/schacon/projects/cc-hooks-example"]
}

So most of the same information, but also including the command run. In this case however, the hook will respect json sent to stdout, which is of this format:

JSON
{
  "continue": true,
  "permission": "allow|deny|ask",
  "userMessage": "Message shown to the user",
  "agentMessage": "Message shown to the AI agent"
}

So for example if my script returns this:

JSON
{
  "continue": false,
  "userMessage": "absolutely not",
  "permission": "deny"
}

Then you will see this:

absolutely not

absolutely not

This allows you to block certain commands, stop the execution of the task entirely, communicate with the user or if you do allow it to continue, provide extra information to the agent.

Pretty cool.

beforeMCPExecution

In addition to hooks running before shell commands, you can run the same thing before any MCP calls it tries to do. It's input is similar:

JSON
{
  "server": "<server name>"
  "tool_name": "<tool name>"
  "tool_input": "<json params>"
  "url": "string"
  // OR
  "command": "<full command+args>"
}

So for example, before calling the GitButler MCP server (if you were using our MCP server instead of our hooks handler for some reason) would provide something like this to the script:

JSON
{
  "conversation_id": "cdefee2d-2727-4b73-bf77-d9d830f31d2a",
  "generation_id": "63feaa30-ae88-4e47-b6c7-70ee4c39980c",
  "tool_name": "gitbutler_update_branches",
  "tool_input": "{\"changesSummary\": \"Added a README to the project\", \"currentWorkingDirectory\": \"/Users/schacon/projects/cc-hooks-example\", \"fullPrompt\": \"add a README to the project\"}",
  "command": "but",
  "hook_event_name": "beforeMCPExecution",
  "workspace_roots": ["/Users/schacon/projects/cc-hooks-example"]
}

So command is the MCP server command, tool_name is the MCP tool call, and tool_input is an escaped json of the paramaters that tool requested. You get the idea.

This can also return the same JSON as the beforeShellExecution hook, so if it denies the MCP call, you'll see this:

Get the MCP outta here!

Get the MCP outta here!

beforeReadFile

Before reading a file and sending the contents of that file to an LLM endpoint, you can filter the contents with beforeReadFile. For example, you can enable redaction of secrets or make sure anything else you don't want to send to an external server isn't sent.

The input looks like this:

JSON
{
  "conversation_id": "668320d2-2fd8-4888-b33c-2a466fec86e7",
  "generation_id": "490b90b7-a2ce-4c2c-bb76-cb77b125df2f",
  "content": "#!/bin/bash\n\necho 'my_github_access_token'\n",
  "file_path": "leaks/github_tokens.sh",
  "hook_event_name": "beforeReadFile",
  "workspace_roots": ["/Users/schacon/projects/cc-hooks-example"]
}

This hook also will respect output JSON of the same format, so you can tell it to allow or deny the file reading (or at least, having Cursor send the file contents to the LLM).

Check out Michael Feldstein's actual example of how to deny reading files that contain GitHub API keys on GitHub.

afterFileEdit

After Cursor is done modifying a file, it can tell a hook what it changed in which file.

It doesn't tell you the diff, it provides you with the old string of the file and the new string of the file, so it can pass quite a lot of data to stdin. Here is an example of stdin you get when updating a README:

JSON
{
  "conversation_id": "cdefee2d-2727-4b73-bf77-d9d830f31d2a",
  "generation_id": "23681cf0-a483-49ab-9748-36044efcef52",
  "file_path": "README.md",
  "edits": [
    {
      "old_string": "# OLD README",
      "new_string": "# NEW README"
    }
  ],
  "hook_event_name": "afterFileEdit",
  "workspace_roots": ["/Users/schacon/projects/cc-hooks-example"]
}

Like the beforeSubmitPrompt hook, this is informational only - you cannot communicate to the user, agent or stop the agent with json output here.

stop

Finally, there is a stop hook for when the task is completed. GitButler uses this as a signal for when to commit the generated work.

JSON
{
  "conversation_id": "cdefee2d-2727-4b73-bf77-d9d830f31d2a",
  "generation_id": "26b45fb6-bdea-439c-b2dc-5e97ee00ecea",
  "status": "completed",
  "hook_event_name": "stop",
  "workspace_roots": ["/Users/schacon/projects/cc-hooks-example"]
}

Really the only unique field here is status, which can be "completed", "aborted" or "error".

Some Use Cases

Ok, great. So what can we do with this?

Well, besides something like GitButler being able to hook into the agent's lifecycle to take over the version control parts, you can also use it for things like:

  • Auto-formatting or linting after a file edit
  • Sensitive command blocking, like blacklisting an rm -rf
  • Access controls to restrict changes to critical files or directories
  • Prompt validation to analyze or sanitize user prompts before processing
  • Auto documentation, for example firing off a background process to re-document affected files
  • Storing session transcripts to automatically keep all the transcripts of everything you've done in Cursor somewhere

An Example Implementation

For a very quick example, let's implement a script to display a notification whenever Cursor finishes a task:

JSON
{
  "version": 1,
  "hooks": {
    "stop": [
      {
        "command": "osascript -e 'display notification \"Nailed it\" with title \"Cursor\" subtitle \"AI assistant\" sound name \"Glass\"'"
      }
    ]
  }
}

Now after every task is done, you get a little popup:

Nailed it

Nailed it

Debugging hooks

There is a hooks output channel in Cursor that will give you a ton of debugging information so you can see if your json is malformed, what it tried to run, etc.

You can get there by going to "Output channels" and choosing "Hooks".

Hooks output channel

Hooks output channel

Why Hooks?

You may ask yourself "why use hooks when I can use rules?". And you can use rules or even MCP servers to try to do a lot of this.

However, hooks are generally deterministic programs that can always be known to do the same thing the same way. Rules and MCP calls and parameters will almost by definition be non-deterministic and could be called or interpreted in any way at any time, as they are run by the LLM.

In addition to that, rules and MCP services are also by definition slower, since they need to be run by the LLM in serial and the paramaters need to be determined by further LLM calls. For hooks, as the GitButler implementation demonstrates, version control stuff can be kicked off into another process and dealt with in the background rather than waiting for your LLM to struggle with a series of obscure Git commands or determine which options to call them with.

So go forth and build some cool stuff with Cursor's hooks!

Scott Chacon

Written by Scott Chacon

Scott Chacon is a co-founder of GitHub and GitButler, where he builds innovative tools for modern version control. He has authored Pro Git and spoken globally on Git and software collaboration.

Stay in the Loop

Subscribe to get fresh updates, insights, and
exclusive content delivered straight to your inbox.
No spam, just great reads. 🚀