Cursor — hooks.json v1, event mapping, import fidelity
Verification basis: the adapter is implemented and asserted against payloads captured from a real
cursor-agentrun (crates/ctxlake-hook/tests/fixtures/cursor-verified/). That capture corrected five fields the original implementation had inferred wrongly — all five failing silently — and the corrections are pinned by tests that were each verified to fail against the old behaviour. Of the three runtimes, Cursor's field mapping is the one grounded in observed traffic rather than documentation.
Cursor Agent CLI is near-parity with Claude Code: capture, injection, blocking, and MCP are all supported through the same shape of mechanism. "Near," not "full," because two things genuinely differ — session-boundary detection and import fidelity — and both are called out explicitly below rather than smoothed over.
Config file and mechanism
ctxlake install merges hook entries into hooks.json — project-scoped .cursor/hooks.json or user-scoped ~/.cursor/hooks.json — under schema version 1 ("version": 1). Like Claude Code's settings file, each event key holds an array of hook command entries, and the installer appends to that array rather than replacing it, writing a .bak first (invariant 8). MCP servers are registered separately in .cursor/mcp.json / ~/.cursor/mcp.json, merged the same way.
Each hook runs ctxlake-hook as a short-lived child process with the event payload as JSON on stdin — the same transport as Claude Code, and the same reason it satisfies invariant 1: no persistent connection, no socket opened by the hook itself.
Event mapping
| Cursor hook | ctxlake EventType | Notes |
|---|---|---|
beforeSubmitPrompt | Prompt | The human's turn; can inject context ahead of the prompt or block a bad submission. |
beforeShellExecution | (pre-capture only) | Path/command pre-check point — used for the same denylist-based blocking Claude Code does at PreToolUse. |
beforeReadFile | (pre-capture only) | Pre-check for the path denylist (Redactor::is_denied_path) before a read executes. |
beforeMCPExecution | (pre-capture only) | Same pre-check role, scoped to MCP-originated tool calls. |
afterFileEdit | ToolCall | The primary capture point for file-editing tool calls: paths touched, and a diff-derived result. |
stop | Assistant | The assistant's final message for the turn. |
Two gaps against Claude Code's mapping, both worth naming plainly rather than papering over with a best-effort mapping:
- No dedicated
SessionStartevent. Cursor's hook set has no documented equivalent — session boundaries are inferred from thecursor-agentprocess lifecycle (the first hook event of a new invocation) rather than an explicit event. Briefing injection therefore happens at the firstbeforeSubmitPromptof a session, not at a true session-start point. - No dedicated pre-compaction event, the same gap
envelope.rsdocuments for Hermes. Cursor's own context management is opaque from the hook surface, soEventType::Compactis never emitted for a Cursor session — there is no moment to catch before that history is gone.
Capability matrix
| Capability | Support | Mechanism |
|---|---|---|
| Capture | Yes | afterFileEdit + stop + the pre-check hooks above |
| Briefing injection | Yes | beforeSubmitPrompt context injection |
| Block | Yes | beforeShellExecution / beforeReadFile / beforeMCPExecution can deny before execution |
| Fail-closed | Supported by the runtime; used narrowly, same posture as Claude Code | Path-denylist blocking only; capture failures fail open |
| Compaction marker | No — named gap above | — |
| MCP | Yes | ctxlake mcp runs as a stdio child registered via .cursor/mcp.json |
Coexistence with other hook consumers
hooks.json's array-per-event structure exists for the same reason Claude Code's does: more than one tool can hold an entry on the same event without one clobbering the other. ctxlake install appends; ctxlake uninstall removes only the entries it added, matched by a stable marker, leaving anything else in the file untouched.
Import fidelity: metadata and prompts only, and why
ctxlake import --runtime cursor backfills whatever history is already on disk from past Cursor sessions — but only metadata and prompt text, not full transcripts or tool call bodies. This is a deliberate refusal, not a missing feature, and the reason is the storage format itself.
Cursor's local session state lives in a SQLite database structured as an opaque blob store:
CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB);Everything that matters — the actual conversation, tool calls, diffs — sits inside the data column in a serialized, internal format, versioned by a schemaVersion that Cursor can and does move without notice across releases. There is no published schema for what's inside a blob, no stability contract on its layout, and no way to tell from the database alone whether a given blob's internal format matches the version a reverse-engineered parser was built against.
We deliberately do not reverse-engineer it. A parser built by inspecting today's blob layout would silently produce garbage — or worse, plausible-looking-but-wrong data — the moment Cursor ships an update that changes it, with no version check that could catch the drift before it corrupts an import. Bronze is immutable (../concepts.md): a bad import isn't a bug you patch forward, it's bad data sitting permanently in sessions/. ctxlake import --runtime cursor reads only what's safe and stable to interpret from outside the blob — row-level metadata (timestamps, workspace and session identifiers) and prompt text where it's recoverable in a readable form — and leaves everything inside data uncaptured rather than guess at it.
The practical effect: backfilled Cursor history in sessions/ has real prompts and real timing, but no reconstructed tool-call detail. Live capture going forward, through the hook mapping above, has no such gap — this limitation is specific to backfilling history that already existed before ctxlake install ran.
What the live capture corrected
The adapter was first written from Cursor's documentation. Five fields were wrong, and every one of them failed silently — no error, just a field quietly missing from every Cursor envelope. They are recorded here because the same class of mistake is easy to reintroduce:
| Field | First assumption | What Cursor actually sends |
|---|---|---|
| working directory | cwd | cwd exists but arrives empty; the path is workspace_roots[0] |
| tool result | tool_output is a string | a JSON-encoded string containing {"output": ..., "exitCode": N} |
| exit code | not read at all | nested inside that decoded object |
| duration | duration_ms, integer | duration, a float in milliseconds |
| client version | not read at all | cursor_version |
The exit code is the one that mattered. Friction detection — "abandoned after 4 failed cargo test runs" — is built entirely on exit codes, so while this was wrong that signal worked on Claude Code and was blind on Cursor, with nothing anywhere to say so.
The duration bug is the instructive one. The field name was read correctly, but the value was parsed with an integer accessor, which yields nothing for 1089.021. The hand-written fixture covering it used the integer 42 — so the test passed, and agreed with the bug. That is the failure mode captured fixtures exist to prevent.
user_emailis dropped, deliberately. Every Cursor payload carries the operator's email address. Stored verbatim it would publish that address to everyone else who can read the fleet's lake. The envelope has no field for it and should not gain one; a test asserts it never appears in a serialized envelope.
Next steps
- claude-code.md — the reference mapping this one is compared against
- ../import.md — import fidelity across all three runtimes
- ../architecture.md — the worked trace this mapping feeds into