Cursor hooks.json: the stop event and its stdin JSON schema
- Cursor
- AI tooling
- Developer experience
Cursor's stop hook runs when Agent finishes, errors, or is aborted. Here is how to wire it in hooks.json, what JSON arrives on stdin, and how followup_message can safely continue the loop.
Cursor hooks are small programs that run around the Agent lifecycle. They are configured in hooks.json, receive JSON on standard input, and can return JSON on standard output. Most hook events surround an action such as a shell command, an MCP call, or a file edit. The stop event is different: it fires when the agent loop is about to end.
That makes stop useful for final automation. You can record telemetry, check whether a task ended cleanly, ask Cursor to continue with a follow-up instruction, or build a bounded retry loop. The important detail is that hooks.json does not define the stdin schema. Cursor owns the payload and sends it to your command whenever the event fires.
Minimal hooks.json
A project-level hook lives at .cursor/hooks.json. For a stop hook, the smallest useful version points the stop event at a script:
{
"version": 1,
"hooks": {
"stop": [
{
"command": ".cursor/hooks/on-stop.sh",
"loop_limit": 5
}
]
}
}
Project hooks run from the project root, so .cursor/hooks/on-stop.sh is the expected path style. User-level hooks live in ~/.cursor/hooks.json and use paths relative to ~/.cursor/.
The loop_limit field matters because stop can automatically send a new user message back into the conversation. If your script always asks Cursor to continue, you have created a loop. Cursor defaults to a capped loop behavior, and setting an explicit limit makes the intended boundary obvious to future readers.
What Cursor sends on stdin
When the agent stops, Cursor starts your command and writes a JSON object to stdin. A typical stop payload looks like this:
{
"conversation_id": "cdefee2d-2727-4b73-bf77-d9d830f31d2a",
"generation_id": "26b45fb6-bdea-439c-b2dc-5e97ee00ecea",
"model": "composer-model-name",
"hook_event_name": "stop",
"cursor_version": "1.7.2",
"workspace_roots": ["/Users/me/project"],
"user_email": "me@example.com",
"transcript_path": "/path/to/transcript.jsonl",
"status": "completed",
"loop_count": 0
}
The common fields identify the conversation, generation, model, Cursor version, workspace roots, and optional user or transcript context. The fields that matter most for stop are status and loop_count.
status tells you how the agent loop ended. It can be completed, aborted, or error. completed means the agent reached a normal finish. aborted means the run was interrupted. error means Cursor ended the loop because something failed. Your hook can treat these differently: log all of them, retry only errors, or stay silent on aborts.
loop_count tells you how many automatic follow-ups this same stop hook has already triggered for the conversation. It starts at 0. If your hook returns a follow-up message, Cursor increments the count the next time the hook runs. This is the field you check before asking Agent to continue again.
A practical stdin JSON Schema
Cursor does not ask you to put this schema in hooks.json, but writing it down is useful if you want typed scripts, validation, tests, or documentation for your team:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Cursor Stop Hook Stdin Payload",
"type": "object",
"required": ["hook_event_name", "status", "loop_count"],
"properties": {
"conversation_id": { "type": "string" },
"generation_id": { "type": "string" },
"model": { "type": "string" },
"hook_event_name": { "const": "stop" },
"cursor_version": { "type": "string" },
"workspace_roots": {
"type": "array",
"items": { "type": "string" }
},
"user_email": { "type": ["string", "null"] },
"transcript_path": { "type": ["string", "null"] },
"status": {
"type": "string",
"enum": ["completed", "aborted", "error"]
},
"loop_count": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": true
}
Notice additionalProperties: true. Hook payloads can gain fields as Cursor evolves. For production hooks, it is usually better to require the fields you actually need and tolerate extra context instead of rejecting a future Cursor version because it added metadata.
What your stop hook can return
A stop hook can return a JSON object on stdout. The Cursor-native output is simple:
{
"followup_message": "Inspect the failure, fix the root cause, and run the relevant verification again."
}
When followup_message is present and non-empty, Cursor automatically submits it as the next user message. If there is nothing to do, print {} or no meaningful JSON output.
This is not the same as blocking a shell command. Earlier lifecycle hooks can allow, deny, or ask for permission around a specific action. The stop hook is about what happens after the agent loop ends. Its superpower is controlled continuation.
Minimal Bash implementation
Here is a small script that retries only after errors, and only while the loop count is below three:
#!/usr/bin/env bash
set -euo pipefail
input="$(cat)"
status="$(printf '%s' "$input" | jq -r '.status // empty')"
loop_count="$(printf '%s' "$input" | jq -r '.loop_count // 0')"
if [ "$status" = "error" ] && [ "$loop_count" -lt 3 ]; then
printf '%s\n' '{
"followup_message": "The previous run ended with an error. Inspect the failure, fix it, and verify the result."
}'
else
printf '%s\n' '{}'
fi
Make the script executable with chmod +x .cursor/hooks/on-stop.sh. If the script depends on jq, confirm it exists on machines where the hook will run. For team hooks, missing helper binaries are one of the most common sources of confusing failures.
A TypeScript shape for typed hooks
If your hook is more than a few lines, TypeScript is often more comfortable than Bash. The useful types are small:
type StopHookInput = {
conversation_id?: string;
generation_id?: string;
model?: string;
hook_event_name: "stop";
cursor_version?: string;
workspace_roots?: string[];
user_email?: string | null;
transcript_path?: string | null;
status: "completed" | "aborted" | "error";
loop_count: number;
};
type StopHookOutput = {
followup_message?: string;
};
From there, read stdin, parse JSON, decide whether to emit a followup_message, and always handle parse or runtime errors intentionally. For a telemetry hook, failing open and printing {} may be fine. For a policy hook on an earlier event, you might use failClosed; for stop, the usual risk is accidentally looping or hiding a hook bug, not allowing a dangerous command.
Good stop hook use cases
Use stop when the decision depends on the whole agent run. Examples include posting a run summary to an internal log, counting failed generations, nudging Agent to run tests if it finished without verification, asking for one more pass when a checklist is incomplete, or gathering metrics about model, status, and workspace.
Avoid using stop for things that belong closer to the action. If you want to block rm -rf, use beforeShellExecution. If you want to format files after edits, use afterFileEdit. If you want to gate MCP calls, use beforeMCPExecution. A stop hook is late in the loop by design.
Debugging tips
When a stop hook does not seem to run, first check whether Cursor loaded the hook configuration. The Hooks settings tab and Hooks output channel are the best places to look. Then simplify: remove matchers, make the command print the raw stdin payload to a temp log, and confirm the script is executable. Once the base hook fires, add validation and loop logic back one step at a time.
The mental model is short: hooks.json wires the event, Cursor sends the event payload through stdin, your script prints supported JSON to stdout, and followup_message is the lever that can continue the conversation. For the stop event, that contract is enough to build careful final checks without turning every agent run into a manual checklist.