6.6 KiB
Cursor Hook Stdin Shape — Reference
This file documents the JSON payloads the Cursor IDE sends to the
MemPalace hook scripts in hooks/cursor/. It exists so a future
contributor does not have to re-discover the schema by writing a
probe hook.
Source: cursor.com/docs/hooks.md,
fetched 2026-05-27. Cursor's hook system is documented as a stable
v1 schema ({"version": 1, ...} at the top of hooks.json).
If you suspect Cursor has changed the payload shape since that fetch
date, re-verify against the upstream docs and update both this file
and hooks/cursor/lib/common.sh::mempal_parse_stdin. The hook
scripts deliberately ignore fields they do not consume, so adding
new fields is non-breaking.
Common fields (all events)
Every hook receives these on stdin in addition to its event-specific fields. Source: docs section "Common schema → Input (all hooks)".
{
"conversation_id": "string",
"generation_id": "string",
"model": "string",
"hook_event_name": "string",
"cursor_version": "string",
"workspace_roots": ["<absolute path>"],
"user_email": "string | null",
"transcript_path": "string | null"
}
Field notes (verified):
conversation_idis the stable per-conversation ID. The Cursorstopevent does not carry asession_id— onlyconversation_id. MemPalace keys its counter files on this. CursorsessionStartdoes carry asession_id, and the docs note it is "same asconversation_id".generation_idchanges every user message. We do not use it.transcript_pathmay benullif the user has disabled transcripts in Cursor settings. The hooks degrade gracefully when the value is empty.workspace_rootsis normally a single-entry array but multi-root workspaces are supported; MemPalace uses index[0].
Event-specific fields
stop (consumed by mempal_save_hook_cursor.sh)
{
"status": "completed" | "aborted" | "error",
"loop_count": 0
}
loop_countindicates how many times this stop hook has already triggered an automatic followup for this conversation (starts at 0). Whenloop_count > 0we know our own previousfollowup_messageis currently being processed — the save hook returns{}so the agent can finish. Equivalent to Claude Code'sstop_hook_active.- The per-script
loop_limit(default 5 for Cursor hooks, configurable via theloop_limitfield on the hook entry inhooks.json) is defense-in-depth on top of our own check. The examplehooks.jsoninexamples/cursor/setsloop_limit: 1.
Allowed output fields (only):
{ "followup_message": "<text to auto-submit as next user turn>" }
preCompact (consumed by mempal_precompact_hook_cursor.sh)
{
"trigger": "auto" | "manual",
"context_usage_percent": 85,
"context_tokens": 120000,
"context_window_size": 128000,
"message_count": 45,
"messages_to_compact": 30,
"is_first_compaction": true
}
Critical constraint: preCompact is documented as observational only. It cannot block compaction and its allowed output fields are limited to:
{ "user_message": "<short message shown to the user when compaction occurs>" }
There is no followup_message and no decision: block on
this event — unlike Claude Code's PreCompact. MemPalace works
around this by:
- Running
mempalace minesynchronously inside the hook so the verbatim transcript lands in the palace before compaction summarises it. - Dropping a
cursor_<conversation_id>.pendingmarker that the nextstopinvocation reads and uses to force a save followup regardless of its counter.
sessionStart (consumed by mempal_wake_hook_cursor.sh)
{
"session_id": "<unique session identifier>",
"is_background_agent": true,
"composer_mode": "agent" | "ask" | "edit"
}
session_id equals conversation_id on this event (docs are
explicit about this).
Allowed output fields:
{
"env": { "<key>": "<value>" },
"additional_context": "<text added to conversation's initial system context>"
}
additional_context is the field MemPalace uses. The schema also
accepts continue and user_message but the docs explicitly note
"current callers do not enforce them; session creation is not
blocked even when continue is false". We do not emit either.
Environment variables (all hooks)
Cursor sets these env vars on every hook execution; the hook scripts fall back to them when JSON parsing fails for any reason.
| Variable | Description |
|---|---|
CURSOR_PROJECT_DIR |
Workspace root (= workspace_roots[0]) |
CURSOR_VERSION |
Cursor version string |
CURSOR_USER_EMAIL |
Authenticated user email (if logged in) |
CURSOR_TRANSCRIPT_PATH |
Conversation transcript path (if transcripts on) |
CURSOR_CODE_REMOTE |
"true" if running in a remote workspace |
CLAUDE_PROJECT_DIR |
Alias for CURSOR_PROJECT_DIR (Claude compat) |
Exit code semantics
Cursor interprets command-hook exit codes as follows (docs "Hook Types → Command-Based Hooks → Exit code behavior"):
0— success, use the JSON output.2— block the action (equivalent topermission: "deny").- Other — hook failed; action proceeds (fail-open by default).
MemPalace hooks always exit 0 and emit either {} (no-op) or a
valid JSON response. We never use exit code 2; nothing MemPalace
does should ever block an agent action.
Working directory contract
- User hooks (
~/.cursor/hooks.json) run from~/.cursor/. - Project hooks (
.cursor/hooks.json) run from the project root.
The MemPalace hooks always resolve their sibling lib/common.sh via
BASH_SOURCE[0] so the working directory does not matter for the
script's own loading — only the command path in hooks.json needs
to point at the absolute location of the script.
Transcript file format (out of scope)
The format of the file at transcript_path is not documented by
Cursor as of the fetch date above. MemPalace deliberately does not
parse it: the save hook counts stop invocations (each one
corresponds to one assistant turn) and hands the transcript to
mempalace mine, which has its own normaliser layer.
If you need to consume the transcript directly, probe its shape with
a throw-away hook that does cat > /tmp/cursor-transcript-sample.txt
and inspect the output — there is no shortcut.