---
title: The log format
description: The fields every entry shares, the body of each kind, the BLAKE3 chain, an example entry, and the rules for reading, appending and versioning.
order: 80
section: Audit log
---

Each line of a session log is one complete JSON object. It has three parts: an envelope, which is the set of fields every entry carries whatever produced it; a body, whose fields depend on the kind of entry; and a fingerprint of the line's own bytes. The file needs no header and no index, which is what lets a reader follow it as it is written and lets a process that lives for five milliseconds append to it. This page is the field-by-field reference, drawn from [SCHEMA.md](https://github.com/nd7-dev/nd7-core/blob/main/docs/SCHEMA.md) and from [src/hook/event.rs](https://github.com/nd7-dev/nd7-core/blob/main/src/hook/event.rs), which is the code that writes it.

The format is a draft. nd7 writes entries with `v: 0`, and the repository says fields may change until the schema is marked `v1`.

## The envelope

The envelope is the part every entry has in common. Whatever produced an entry, now or in future, it has exactly these top-level fields, and anything specific to one agent lives in `body`.

- **`v`**: u16, required, set by: nd7. Schema version of this entry; `0` while drafting.
- **`session_id`**: string, required, set by: hook. The top-level grouping key: Claude Code's own `session_id`, verbatim.
- **`seq`**: u64, required, set by: nd7. Position in this session's log on this host, counting from 0, with no gaps. Assigned under the session's lock.
- **`ts`**: i64, required, set by: nd7. Wall-clock time when the recorder started, Unix epoch nanoseconds, UTC.
- **`host`**: string, required, set by: nd7. Hostname of the machine that produced the entry.
- **`source`**: string, required, set by: nd7. What produced the entry, and what kind of evidence it is: what an agent said it would do (`intent:`), or what the system observed happening (`effect:`). `intent:claude-code` today; `intent:codex`, `effect:es`, `effect:fanotify`, `effect:remote` are reserved.
- **`kind`**: string, required, set by: nd7. One of the seven kinds below.
- **`prev`**: string, required, set by: nd7. BLAKE3 of the previous entry's bytes, hex. At `seq` 0, BLAKE3 of the session id.
- **`body`**: object, required, set by: mixed. The kind-specific fields.
- **`hash`**: string, required, set by: nd7. BLAKE3 over this entry's bytes with `hash` absent. Always the last member.

The fields are written in exactly that order. The writer hashes the bytes it writes, so field order is part of the format rather than a detail of one implementation.

Two fields appear in SCHEMA.md but not in the code, and the code is what the file contains. `mono`, a reading of the clock that only ever counts forwards, is documented as optional and is not in the `Event` struct today. `sig`, reserved for a signature over `hash`, is likewise absent; SCHEMA.md says it is never written in Phase 1.

Why these choices, in one line each. `ts` is nd7's own because Claude Code's hook payloads carry no timestamp. Matching a future kernel-observed event to an intent event therefore means matching within a window of time rather than on an exact value. `seq` counts per host because two machines cannot agree on a gapless counter without talking to each other; a log merged from several machines uses the pair `(host, seq)` to identify an entry, and `ts` to order entries for display. `v` travels in every entry rather than once per file because a log can span an upgrade of nd7 in the middle of a session, and a reader following the file line by line has no header to consult.

## Fields every body carries

For `source: intent:claude-code`, every body starts with the common section taken from the hook payload. Several of these fields are join keys: fields stored now so that a record from somewhere else, such as a future kernel-observed event, can be matched up with this one later.

- **`cwd`**: string, required. The agent's working directory at the hook. A join key for future effect matching.
- **`transcript_path`**: string, required. Path to Claude Code's own transcript, so a reader can cross-check.
- **`hook_event_name`**: string, required. Kept even though `kind` is derived from it, so a renamed event is never lost.
- **`permission_mode`**: string, optional. `default`, `plan`, `acceptEdits`, `auto`, `dontAsk`, `bypassPermissions`.
- **`prompt_id`**: string, optional. The user turn's id, grouping that turn's tool calls.
- **`agent_id`**: string, optional. Present only inside a subagent.
- **`agent_type`**: string, optional. Present only inside a subagent, for example `Explore`.
- **`hook_ppid`**: u32, optional. The parent process id of the recorder process. A join key for future process-tree attribution.

`hook_ppid` is why the install snippet registers the hook in exec form, meaning a program name plus a list of arguments rather than one shell command line. Registered as `"command": "nd7", "args": ["record"]`, the recorder's parent is the `claude` process itself. Registered as a shell string, the parent is an intermediate `sh`, which is useless as a key.

SCHEMA.md also lists `scratchpad_dir` and `effort` as parsed but not yet stored in the body.

## The bodies

### `session_start` — hook `SessionStart`

`reason`: the payload's own `source` field — `startup`, `resume`, `clear`, `compact` or `fork` — renamed so it does not clash with the envelope's own `source`. A resume, compact or fork start on a session that already exists appends to that session's log; it does not begin a new file.

### `prompt` — hook `UserPromptSubmit`

`text`: the prompt, verbatim.

### `tool_call` — hook `PreToolUse`

- **`tool_use_id`**: string. Pairs this with its `tool_result`.
- **`tool_name`**: string. `Bash`, `Edit`, `Write`, `Read`, `mcp__…`.
- **`tool_input`**: object. The full input, verbatim. For `Bash` this includes the whole command string.
- **`argv`**: string, optional. A copy of `tool_input.command` for `Bash`, lifted to a field of its own so that code matching on commands need not know the input shape of every tool.
- **`paths`**: array, optional. File paths copied out of the input, for the tools nd7 knows.

### `tool_result` — hooks `PostToolUse` and `PostToolUseFailure`

- **`tool_use_id`**: string.
- **`tool_name`**: string.
- **`tool_input`**: object, optional. Repeated by the payload; stored by default.
- **`tool_response`**: any. The tool's response. For a failure, the `error` string.
- **`ok`**: bool. `true` for `PostToolUse`, `false` for `PostToolUseFailure`.
- **`duration_ms`**: u64, optional. Tool execution time, excluding permission prompts and `PreToolUse` hooks.

The shape of `tool_response` depends on the tool. For `Bash` it was verified as `{stdout, stderr, interrupted, isImage, noOutputExpected, bashEditDiff?}`. **There is no exit code on a success**; a non-zero exit arrives as `PostToolUseFailure` with an `error` beginning `Exit code N`. That is why nd7 registers for both events. `bashEditDiff`, when present, holds the changed files and a full unified diff of each. It is the most valuable evidence in the payload, and also the reason entries reach tens of kilobytes.

### `session_end` — hook `SessionEnd`

`reason`: `clear`, `resume`, `logout`, `prompt_input_exit` or `other`. If a log has no `session_end` entry, the session ended without Claude Code firing the hook — a crash or a kill — and a reader should say so.

### `turn_end` — hook `Stop`

`stop_hook_active` (bool) and `last_assistant_message` (optional string, the turn's final assistant text; potentially large).

### `hook` — everything else

`raw`: the full hook payload, as an object. The parser types all 33 documented events; only the six kinds above get a dedicated body, and everything else lands here.

## The hash chain

```
hash = BLAKE3(the exact bytes of this entry, with "hash" absent)
prev = the previous entry's hash
prev at seq 0 = BLAKE3(session_id)
```

The chain covers whole entries rather than bodies alone, so reordering, dropping or editing any envelope field is caught too.

"The exact bytes" is meant literally. The writer turns the entry into JSON text once, with `hash` absent, hashes those exact bytes, and then splices the hash in as the final member by replacing the closing brace with `,"hash":"…"}`. Verification undoes the splice: cut off that fixed-width ending, hash the remaining text plus one `}`, and compare. So the verifier needs no library for rewriting JSON into a standard form, and it cannot disagree with the writer about whitespace or the order of keys. RFC 8785 is a standard for writing JSON in one agreed form. It would make hashes stable across different JSON writers, at the cost of a dependency and a second pass over the entry, and it is not needed while nd7 is the only thing writing entries.

Tying the first entry's `prev` to the session id means a chain cannot be lifted out of one session and presented as another's.

Verification finds `seq` and `prev` by searching the line for the byte sequences `,"seq":` and `,"prev":"`. This is sound for two reasons. JSON escapes any quote inside a string value, so those sequences can only be the envelope's own fields. And the entry's hash has already proved the bytes are exactly what the writer produced.

## An example entry

The first entry of a session, from the repository's published test vectors in `tests/vectors/chain.json`, wrapped here for reading — in the file it is one line:

```json
{"v":0,"session_id":"3f2d1c48-9b7a-4e51-8c6d-0a1b2c3d4e5f","seq":0,
 "ts":1758240000000000000,"host":"vectors.example","source":"intent:claude-code",
 "kind":"session_start",
 "prev":"305c9ae9d25b4b1fd06fef9f3862756a1dd14b3927fca448e726c2e37d5d5336",
 "body":{"cwd":"/Users/dev/code/nd7-core",
         "transcript_path":"/Users/dev/.claude/projects/-Users-dev-code-nd7-core/3f2d1c48.jsonl",
         "hook_event_name":"SessionStart","hook_ppid":4242,"reason":"startup"},
 "hash":"03efba11a9e39d66d4c105dd511186590d69c95c9c2cc3c3d6d4044ce1816399"}
```

`prev` here is BLAKE3 of the session id, because this is entry 0. The next entry's `prev` is this entry's `hash`.

These vectors are published so that anyone writing a second implementation can check it against them. The vault's browser viewer does exactly that: it implements the byte-hash check again in JavaScript and tests it against these vectors.

## Rules for reading and appending

- **One entry per line, newline-terminated.** This is NDJSON, newline-delimited JSON: `jq`, `grep` and `tail -f` work on it directly, which is worth a great deal while the schema is still moving. The cost is size, three to five times a binary encoding before compression. The plan on record is to keep this as the everyday write path and add a packed container later, produced from the same bytes so that packing changes no hash.
- **The newline is not hashed.** An entry's hash covers the entry only. A file cut short in the middle of an entry is therefore reported as a torn tail, meaning the file does not end in a complete entry, rather than as an entry whose hash is wrong.
- **Only one append happens at a time.** Every append holds an exclusive advisory lock on the session's `lock` file, which is a lock every nd7 process agrees to respect. Two hooks running at once therefore cannot claim the same `seq`, or both link onto the same previous entry.
- **The entry is written first, `head` second.** One write to a file opened in append mode, then `head` rewritten through a temporary file and a rename. A crash between the two leaves `head` one entry behind, which the next append repairs from the end of the log.
- **A verifier can start anywhere it has an anchor**, meaning an entry it already trusts to start from. The same routine checks a whole log from the first entry, one batch before it is encrypted for a vault, and the same batch after an admin decrypts it. Given the entry before a run of entries, it checks that run and returns the new head.

## How verification fails

`nd7 verify` stops at the first fault and names it. The faults, in the order it checks for them:

- **not a sealed entry**: no trailing `hash` member, or the chain fields are not where the writer puts them. Entries written before the chain existed land here
- **hash does not match its bytes**: the entry was modified after it was written
- **sequence gap**: the `seq` field is not the expected position: an entry was removed, inserted or reordered
- **`prev` mismatch**: the entry's `prev` is not the previous entry's hash, or the log belongs to another session
- **torn tail**: the file does not end in a newline: the last write was cut short
- **head missing**: `head` is gone although the log has entries
- **head invalid**: `head` could not be parsed
- **head stale**: `head` names an entry before the last one. Benign: an interrupted append
- **truncated**: `head` names an entry after the last one: entries were removed from the end
- **head mismatch**: `head` agrees on `seq` but not on `hash`

Every fault that concerns an entry carries that entry's position, so a reader can say "intact up to N".

## Versioning and compatibility

`v` is `0` and the schema is a draft: fields may change. Once it is marked `v1`, a change within a major version may only add fields, never remove or repurpose one.

Two properties are meant to survive that change. Any hook event nd7 does not model is stored whole under `kind: hook`, so a Claude Code release that adds an event loses nothing. And because `v` travels in every entry rather than in a file header, a log may span an upgrade of nd7 in the middle of a session and still be readable, entry by entry. A later binary container is planned to coexist with NDJSON, a reader telling the two apart by the first byte: `{` for a JSON line, or a marker byte for a packed one.

The format leaves room for signing, compression, and storing repeated strings once instead of many times, through the reserved fields and the per-entry version number. None of the three is implemented today.
