Set up nd7 as a Claude Code hook
Recording a session means registering nd7 record as a Claude Code hook. A hook is a command you name in your settings that Claude Code runs for you at a fixed point in a session, such as before a tool call. Claude Code runs it once per event and pipes that event's JSON to it, and nd7 appends one entry per run. This page has the settings snippet, says which events are worth registering and which are deliberately left out, explains what nd7 run installs on its own (which is not the recorder), and covers what happens to Claude Code's own sandbox setting under nd7.
The settings snippet#
Put this in ~/.claude/settings.json for every project, or in a project's .claude/settings.json for one:
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 5 }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 5 }] }],
"PreToolUse": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 5 }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 5 }] }],
"PostToolUseFailure": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 5 }] }],
"Stop": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 5 }] }],
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "nd7", "args": ["record"], "timeout": 1 }] }]
}
}
Use exec form — command plus args — rather than a single shell string. Two reasons, both measured. It skips the sh -c wrapper, worth about 6 ms per hook. And it makes the recorded parent process id the claude process itself rather than an intermediate shell. That parent pid is stored as hook_ppid, and a later phase will use it to work out which process in the tree did what; a shell sitting in the middle makes it useless for that.
Which events are recorded#
Six of the seven events above get an entry shape of their own:
SessionStart: entry kindsession_startUserPromptSubmit: entry kindpromptPreToolUse: entry kindtool_callPostToolUse: entry kindtool_result, withok: truePostToolUseFailure: entry kindtool_result, withok: falseStop: entry kindturn_endSessionEnd: entry kindsession_end
PostToolUseFailure is registered because a successful PostToolUse response carries no exit code; without the failure event, a command that failed would simply be missing from the log. Stop is on by default because it marks the end of a turn, which is what lets a reader group that turn's tool calls together.
Any of the other documented events — SubagentStart, SubagentStop, Notification, PreCompact, CwdChanged and the rest — can be added the same way, and nd7 records each of them whole under kind: hook. An event nd7 has never seen loses nothing; that is what the catch-all kind is for. See The log format.
Two events deliberately left out#
MessageDisplay and PostToolBatch are not in the snippet, and the reason is on record in ADR-0006, one of the project's architecture decision records. MessageDisplay fires once per batch of assistant text as it streams, and was 37% of all entries in one test session. PostToolBatch repeats every tool response in a batch that PostToolUse has already recorded. In the largest session measured, 1487 entries over about ten hours, the two together were 702 entries — roughly half. Neither carries anything the later sandboxing or undo phases need.
Anyone who wants them can still register them, and they still land as kind: hook when they do.
Notes on the hook configuration#
From the Claude Code hooks reference, as recorded in the README:
- Omitting
matchermatches every tool.timeoutis in seconds. - A hook that exits non-zero (other than 2) or times out does not block the agent; the action proceeds.
nd7 recordnever prints to standard output, so it cannot tell Claude Code to block or change anything, even by accident. - All
SessionEndhooks together get 1.5 seconds, which is why that one has a shorter timeout. - Hook payloads carry no timestamp. nd7 stamps each entry when it starts, before reading standard input.
The recorder's own cost is about 5 ms of wall-clock time per run on the reference machine, of which roughly 3.4 ms is the cost of starting a process at all, which nd7 cannot influence. That cost does not grow as the log grows. Benchmarks has the numbers and the method.
Asynchronous hooks (async: true) are not used. Claude Code does not wait for them to finish, so an entry could be appended after a later event's hook had already run, which would put the log out of order. Non-interactive sessions (claude -p, which runs one prompt and exits) also kill asynchronous hooks when they shut down. A synchronous hook with a tight timeout is simpler and, at this cost, just as unnoticeable.
What nd7 run installs by itself#
nd7 run claude installs exactly one hook, and it is not the recorder. It passes that hook inline with --settings: a PreToolUse hook, matching the Bash tool, that runs nd7 hook-prefix. nd7 hook-prefix rewrites each Bash command so that it runs through nd7-exec. That is the sandbox mechanism, described in How the macOS sandbox works.
So the two halves are set up separately:
- Sandboxing needs nothing in your settings.
nd7 run claudearranges it per session. - Recording needs the settings entries above, once.
They are independent: you can record without sandboxing, sandbox without recording, or do both. Running both means Claude Code fires two PreToolUse hooks per Bash call — nd7 record, which appends an entry and prints nothing, and nd7 hook-prefix, which rewrites the command.
Claude Code's own sandbox setting#
The same --settings flag that installs the Bash hook also passes "sandbox": { "enabled": false }.
The reason is in ADR-0007. Claude Code's own per-command sandbox wraps each Bash call in sandbox-exec. A profile is a set of rules the kernel enforces on a process and on everything that process starts, and the kernel refuses to apply a second profile inside one that is already in force. When that was measured, the failure showed up in one of two ways, depending on the outer rules: either every Bash call exited with code 71, or Claude Code's sandbox failed to start and silently switched itself off for the session. Turning it off explicitly means that refusal never costs you a broken tool call. The outer profile would refuse it either way.
Two consequences, both stated in the README:
- Claude Code's
/sandboxsettings have no effect under nd7. - Claude Code's network filtering by hostname is lost. nd7's rules filter by port rather than by hostname, and nd7 has no proxy of its own.
If you register the recorder but do not use nd7 run, none of this applies: recording changes nothing about how Claude Code behaves.
Reading a session back#
There is no reader command; nd7 sessions and nd7 show were planned and dropped. Use ordinary tools on the file:
ls ~/.local/state/nd7/sessions/
tail -f ~/.local/state/nd7/sessions/<session-id>/events.ndjson \
| jq -c '{seq, kind, ev: .body.hook_event_name, tool: .body.tool_name}'
nd7 verify <session-id>