---
title: Set up nd7 as a Claude Code hook
description: The settings.json snippet, which hook events are recorded, what nd7 run installs by itself, and how this interacts with Claude Code's own sandbox setting.
order: 90
section: Audit log
---

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:

```json
{
  "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 kind `session_start`
- **`UserPromptSubmit`**: entry kind `prompt`
- **`PreToolUse`**: entry kind `tool_call`
- **`PostToolUse`**: entry kind `tool_result`, with `ok: true`
- **`PostToolUseFailure`**: entry kind `tool_result`, with `ok: false`
- **`Stop`**: entry kind `turn_end`
- **`SessionEnd`**: entry kind `session_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](/docs/log-format).

## Two events deliberately left out

`MessageDisplay` and `PostToolBatch` are not in the snippet, and the reason is on record in [ADR-0006](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0006-store-frame-content-verbatim-and-leave-two-events-unregistered), 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 `matcher` matches every tool. `timeout` is in seconds.
- A hook that exits non-zero (other than 2) or times out does **not** block the agent; the action proceeds. `nd7 record` never prints to standard output, so it cannot tell Claude Code to block or change anything, even by accident.
- All `SessionEnd` hooks 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](/docs/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](/docs/sandbox).

So the two halves are set up separately:

- **Sandboxing** needs nothing in your settings. `nd7 run claude` arranges 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](https://github.com/nd7-dev/nd7-core/blob/main/docs/DECISIONS.md#adr-0007-sandbox-claude-code-with-one-seatbelt-floor-and-a-trampoline-and-refuse-every-other-profile). 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 `/sandbox` settings 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:

```sh
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>
```

See [How the audit log works](/docs/audit-log).
