Documentation
The record, exactly.
Everything needed to write your own verifier without asking us anything. The format is open; the bytes are pinned; the refusal codes are stable.
Record format
A record is a single JSON document declaring
agent-work/record@0. It holds a session
envelope, an ordered list of steps, the tip of the hash chain, and one
signature over all of it. Nothing else is required, and unknown
top-level fields are rejected rather than ignored.
| Field | Type | Meaning |
|---|---|---|
| version | string | Always agent-work/record@0. |
| session | object | id, agent, goal, cwd, startedAt, endedAt. Timestamps are passed in, never read from the clock, so the same run always produces the same bytes. |
| steps | array | Ordered steps, seq starting at 0. Kinds: exec, edit, read, write, tool_call, claim, note, gate. |
| stepCount | integer | Committed inside the signature so truncation is refused cryptographically, not by convention. |
| chainTip | hex | The last step's hash. |
| signature | object | alg, envelope, publicKey (44-byte SPKI in hex), value (64-byte signature in hex). |
| signerIdentity | object? | Optional. Binds the signing key to a certificate chain. |
A step
Inputs and outputs are stored as SHA-256 digests, never as bodies — tool payloads carry credentials, and a record is a document you forward to other people.
{
"seq": 2,
"at": "2026-08-11T09:15:48Z",
"kind": "exec",
"summary": "Ran the test suite — 231 passed, 0 failed",
"data": { "command": "npm test", "exitCode": 0 },
"inputsSha256": [], "outputsSha256": [],
"dossier": null,
"prev": "9f2c…", // the previous step's hash, null at seq 0
"hash": "1a77…"
}
The hash chain
Each step commits to the one before it, so removing, reordering or
inserting a step breaks every link after it. The link lives
inside the hashed step rather than being applied as an outer
prefix — that is what keeps records readable by the reference
agent-work verifier.
// canonicalize = JCS (RFC 8785): keys sorted, no whitespace, integers only step.hash = sha256(canonicalize(step without its own "hash" field)) step.prev = previous step's hash // null when seq === 0 chainTip = last step's hash
Checking the chain needs no key and no network. It answers one question — has this file been edited since it was sealed — and it answers it on its own.
The bytes we sign
The signature covers a canonical envelope, not a raw concatenation, so a crafted domain string cannot inject a delimiter. Reproduce these bytes exactly and any Ed25519 library will verify the signature.
domain = "logbook/record@0/v1" timestamp = record.session.endedAt payload = { version, session, steps, stepCount, chainTip, publicKey, [signerIdentity] } signedBytes = canonicalize({ v: 1, domain, payload, timestamp }) recordId = sha256("logbook/id/v1" || signedBytes)
publicKey inside the payload is the same
SPKI hex string carried in signature.publicKey,
which stops a signature being replayed under a different key.
Floats are refused outright: a record is integers and strings, so its
canonical form is reproducible on every platform.
Verifying
The verifier is free, offline, and has no dependencies. It exits 0 on success and non-zero with a code on every refusal. It parses fully before it runs any cryptography, so a malformed file is a clean rejection rather than an ambiguous one.
# check a record — no account, no network logbook-verify record.json # with a trusted timestamp and an identity policy logbook-verify record.json \ --tsa-roots roots.pem \ --identity-roots identity-roots.pem \ --trusted-identities trusted.json
The checker on the front page is the same logic reimplemented in the browser against these rules. It agrees with the command-line tool byte for byte — if it ever stops agreeing, one of the two is wrong and both should be distrusted.
Refusal codes
Every refusal names its cause. The verifier fails closed: anything it does not fully recognise is refused, never passed with a warning.
| Code | What happened |
|---|---|
| NOT_JSON | The file is not valid JSON. |
| SHAPE | Required fields are missing, or the version is unknown. |
| STEP_OUT_OF_ORDER | A step's seq does not match its position. |
| CHAIN_BROKEN | A step was altered, removed, reordered or inserted. |
| TIP_MISMATCH | chainTip does not match the last step's hash. |
| SIGNATURE_INVALID | The chain is intact but the signature does not match those bytes. |
| ATTEST_KEYID_MISMATCH | An attestation envelope cites a different key than the record's. |
| RECORDER_DIGEST_MISMATCH | A recorder attestation does not describe this record's recorder. |
| NO_ED25519 | Browser only: no Ed25519 support, so no verdict was reached. Not a pass. |
Recording a run
Three ways in, in ascending order of effort. All three produce the same document.
1 · A hook — no application code
The harness fires the hook, so the agent cannot choose what to leave out.
// .claude/settings.json
{ "hooks": {
"PreToolUse": [{ "hooks": [{ "command": "node record-step.mjs" }] }],
"PostToolUse": [{ "hooks": [{ "command": "node record-step.mjs" }] }]
} }
2 · Wrap any command
Agent-neutral: Codex, Cursor, aider, a CI job, a deploy script.
logbook-wrap --key .keys/dev.key --out record.json -- npm run deploy
3 · The library
import { ClaudeRunRecorder } from "logbook"; const run = new ClaudeRunRecorder({ agent: "claude-opus-5", goal: "add rate limiting" }); await run.wrap({ tool: "Bash", input }, fn); const { record } = await run.seal({ privateKey });
Threat model, in short
A valid signature means the bytes are unchanged since sealing and were signed by that key. It does not mean the agent's work was correct, and it does not make the file unchangeable — it makes changes visible.
The weakest link is capture, not cryptography: nothing can record what the harness never observed. Harness-side capture and recorder attestation narrow that gap; neither closes it, and we do not claim they do. The limits section states the full ceiling.
Something here wrong or unclear? hello@rotairo.com — corrections to the spec are the most useful mail we get.