Skip to the format

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.

FieldTypeMeaning
versionstringAlways agent-work/record@0.
sessionobjectid, agent, goal, cwd, startedAt, endedAt. Timestamps are passed in, never read from the clock, so the same run always produces the same bytes.
stepsarrayOrdered steps, seq starting at 0. Kinds: exec, edit, read, write, tool_call, claim, note, gate.
stepCountintegerCommitted inside the signature so truncation is refused cryptographically, not by convention.
chainTiphexThe last step's hash.
signatureobjectalg, envelope, publicKey (44-byte SPKI in hex), value (64-byte signature in hex).
signerIdentityobject?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.

CodeWhat happened
NOT_JSONThe file is not valid JSON.
SHAPERequired fields are missing, or the version is unknown.
STEP_OUT_OF_ORDERA step's seq does not match its position.
CHAIN_BROKENA step was altered, removed, reordered or inserted.
TIP_MISMATCHchainTip does not match the last step's hash.
SIGNATURE_INVALIDThe chain is intact but the signature does not match those bytes.
ATTEST_KEYID_MISMATCHAn attestation envelope cites a different key than the record's.
RECORDER_DIGEST_MISMATCHA recorder attestation does not describe this record's recorder.
NO_ED25519Browser 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.