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:
{
"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:
{
"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
:
{
"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:
{
"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:
{
"continue": false,
"userMessage": "absolutely not",
"permission": "deny"
}
Then you will see this:

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:
{
"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:
{
"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!
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:
{
"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:
{
"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.
{
"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:
{
"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
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
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!