Docs · Reference
The books
The durable journal: every state-changing bus action, appended as one row to a single SQLite table on this machine, and rendered into browsable daily markdown. Always on, nothing to configure. The bus database is the fast working copy; the books are the record it can always be rebuilt from.
Where the books live
One file: ~/.claude/hands/books.db. It is machine-wide, not per-repo — deliberately outside the per-repo coordination/<slug>/ directory the bus lives in. Every project and every kitchen on the box writes into the same table, which is what makes a cross-project question answerable with one query instead of a union over clones.
Beside it is the human-readable layer: one directory per kitchen, holding a page per day and a newest-first index.
~/.claude/hands/
books.db the record: one append-only table
books/
<project>/ portable key, derived from the git origin
<handle>/ one namespace per contributor ("kitchen")
<date>.md the day's page — derived, regenerable
README.md per-handle index, newest first
roles/<role>.md AUTHORED: standing role state
recipes/<slug>.md AUTHORED: menu itemsA kitchen is one (project, handle) pair — your line, in one repo. <project> is the repo name from your git origin, lowercased (mixed-case origins must not split one project in two), overridable with remote.project; <handle> is remote.handle, defaulting to your OS username. Page dates are UTC. hands books prints all of it — the database path, this kitchen’s project/handle, the page directory, and the event count.
Why a table, and not a log
The books used to be a git repo — NDJSON day-files, a bare local origin, a rebase with a digest-conflict resolver, a layout marker, a push debounce. Git was doing four jobs at once (append log, sync transport, conflict resolver, human-readable artifact) and only the last one was worth keeping. What forced the move was a question git could not answer at all:
“A question escalated Tuesday and answered Thursday reads answered, with no trace that it was ever escalated. The state tables hold current state; the event stream holds history — and the event stream was a file in a git repo.”Escalating a question overwrites questions.state in place, so the transition only ever existed as a journal event. Against NDJSON in a clone, “which questions were escalated last week” meant walking N day-files and filtering in memory. Against a table it is a WHERE type = ? and a window — which is hands recall, below.
What one record looks like
One row per event, keyed by (project, handle, seq), with indexes for the two questions the table exists to answer: one kitchen’s history in order, and “every X in this window” across all of them.
project TEXT NOT NULL
handle TEXT NOT NULL
seq INTEGER NOT NULL -- allocated inside the insert's own transaction
ts INTEGER NOT NULL -- epoch ms, the event's own clock
type TEXT NOT NULL -- question.escalate | task.update | message | …
agent TEXT -- expo | station-<n>, null when unattributed
data TEXT NOT NULL -- json
synced_at INTEGER -- null = not yet pushed
PRIMARY KEY (project, handle, seq)- —Written after the DB write succeeds. Every state-changing store method mirrors its action as one event once the bus write has landed — the bus DB stays authoritative. Journaling is best-effort by contract: a failed append returns null and never fails the action it mirrors — with one deliberate exception: a books file migrated past this build (or replaced by something that is not the books) fails the write loudly, naming the mismatch, because a stale build succeeding into a void is the opposite failure the best-effort contract was never meant to cover.
- —
seqis allocated in the same transaction as the insert. Two kitchens’ processes can journal at the same instant; without the transaction they read the sameMAX(seq)and collide on the primary key, dropping one event. - —Deliberately not journaled. Presence heartbeats, the wake log, board watermarks, and the GitHub PR cache — ephemeral state that shouldn’t survive a restore.
Event types
| Group | Types |
|---|---|
| Tickets | task.create, task.update, task.parked |
| Questions | question.ask, question.escalate, question.answer, question.outcome |
| To-dos | todo.create, todo.update |
| Menu | recipe.promoted, recipe.demoted, recipe.graded |
| Station state | focus.set, session_name.set, attest |
| Approvals | approval.requested, approval.settled |
| Prose & traffic | message, journal.add (commits and memory files harvested by the Stop hook), role.note, digest.note |
recipe.promoted and recipe.demoted are appended straight by hands recipe promote / demote rather than through the store — they are what makes “which recipes were on the menu which day” a derived read. Two more notes on the vocabulary:
- —
cursornever reaches the table. The store still emits it on every inbox drain, and the append path drops it: it is pure local read-position bookkeeping, and neither the pages nor the other-kitchens feed render it. Books written before that rule still replay without erroring. - —
priorities.setis retired, and the renderer still describes it. Recipes replaced it. The case stays because pages are re-rendered over the whole history — dropping it wouldn’t just stop describing new events, it would quietly delete the line from every past page on the next re-render. That was caught by diffing regenerated pages against the committed ones: four days lost the only record of what the kitchen was working toward that week.
Querying: hands recall
The read the old books could not serve, at a prompt:
hands recall --type question.escalate --since 7d
hands recall --type task.update --agent station-2 --since 24h --limit 20
hands recall --project hands --handle michael --until 2026-08-01 --json| Flag | Meaning |
|---|---|
--type T | Repeatable. Omit to see every type. Nothing matched prints the types that do exist, so a typo'd type never reads as "nothing happened". |
--since | A duration — 7d, 12h, 30m — or a bare date. Durations because every real use of this is “recently”, and date arithmetic at a prompt is friction that stops a tool being reached for. |
--until | Same vocabulary; exclusive ceiling. |
--project / --handle / --agent | Narrow to one restaurant, one kitchen, or one seat. Unfiltered, recall spans every kitchen on the machine. |
--limit | Default 50 at the CLI, capped at 1000. Newest first — callers wanting causal order want the pages. |
--json | The rows, verbatim. |
An unparseable bound is a hard failure, not a dropped filter: --since lastweek exits with an error rather than silently widening the window to all of history and answering a different question than the one asked. The MCP tool hands_recall makes the same guarantee the other way, because an agent must not be stopped mid-task by a typo: it drops the bad bound and reports the window it actually searched in the reply.
The daily pages
hands digest re-renders this kitchen’s pages from the table (optionally --date YYYY-MM-DD). The render is deterministic — same events in, same bytes out — and only writes a file it owns, so a page stamped by a newer renderer is left alone. The expo’s notes come first, then one section per agent (expo, then stations in order) with its ticket lifecycle, questions and answers, to-dos, focus changes, and menu moves — and a count of messages sent.
“Task/question/todo titles and one-line truncated results — but NO message bodies. Pages are what people share and screenshot; bodies stay in the record.”
Reading the books back
| Command | Semantics |
|---|---|
hands journal read --previous | The last page strictly before today — a Monday reading Friday’s close-out without date arithmetic. --date for a specific day. The books were write-only from the agent side until this existed. |
hands restore | Replay this kitchen’s events into a fresh bus. No fetch, no clone, no validation gate — it reads the table directly. Idempotent (explicit ids, insert-or-ignore), so running it twice converges. Sessions and the whole coordination directory are disposable by design; the books aren’t. |
hands digest | Re-render the pages. Always safe to run and always safe to skip — delete the page tree and this rebuilds it exactly. |
hands doctor | Asks the two local questions: can the books be written, and is anything in them. A books file that isn’t writable is a failure — every bus action is still succeeding and none of it is being recorded. |
restore is always scoped to one (project, handle), and that is load-bearing rather than tidy: replay inserts with explicit ids from a per-repo counter, so an unscoped read would let another handle’s ticket #7 be inserted-or-ignored over yours — a valid-but-wrong row rather than a failure. Git enforced that separation structurally, one directory per handle; a single table has to enforce it in the query.
Migrating an old journal in
NDJSON is the books’ interchange format now, not their storage — the shape a journal takes when it moves between machines, and the shape the git-backed books wrote on disk. One importer serves both:
hands books import <dir> # <dir> holds journal/<project>/<handle>/log/*.ndjson
hands books import <dir> --merge # append what's missing, for a kitchen that has pushed- —It replaces a kitchen’s rows rather than appending the difference. That makes the import exactly idempotent with no content comparison, and it is the only option that gets order right —
seqis allocated at append time, so appended events would sort after history they actually precede. - —It refuses a kitchen whose table already holds an event newer than the tree, loudly and by name, and leaves it untouched. Replacing from a stale export would silently delete everything since. One kitchen’s refusal never stops the others; the command exits non-zero so a refusal buried in a wall of ticks still gets noticed.
- —It refuses a kitchen that has already pushed, and points you at
--merge. Replacing renumbersseqfrom 1, and the cloud keys on(kitchen, seq)and ignores conflicts — so the re-push would be dropped and the two copies would diverge permanently, with every surface reporting success.--mergeappends only the events the table does not already hold, above the current maxseq, preserving each event’s ownts. The trade it makes is real and stated:seqorder stops matching causal order for the merged window. Recall and the digests order bytsanyway, and the alternative was leaving the events stranded outside the table entirely. - —Authored files come across too — roles, recipes, and anything else that isn’t a derived page or a log directory. It never overwrites: a file already at the target may be newer, and a silent clobber of accumulated judgment is worse than a skipped copy that gets reported. Leaving them behind is not hypothetical — it happened on the first real migration, and a missing role page reads to every reader as one that was never written.
Beyond this machine
The synced_at column is the whole sharing mechanism and the whole health signal. hands relay (which needs hands login) drains everything where synced_at IS NULL to hands-cc.dev in batches, oldest first so the replica fills in causal order — and marks a batch sent only after the server confirms it, because a batch marked optimistically would be gone from the queue and absent from the replica. The kitchen never listens on a port — both halves of the beat, the push and the pull below, are connections the kitchen opens itself.
count(*) where synced_at is null is a far more honest health signal than git’s ahead/behind ever was: it is per event, and it distinguishes “nothing to send” from “sending is broken”.
Other kitchens’ recent activity is read out of this machine’s books — other handles in the same project, one indexed lookup each, message bodies never rendered. What puts another machine’s events there is the relay’s pull half. Its books beat is push-then-pull: push first, so a machine sees its own work reflected in the same tick rather than a cadence later, then pull everyone else’s down from the hosted books.
Three things that pull deliberately does not do, each of which would be a silent corruption rather than a failure: it never writes rows for your own (project, handle) — your machine is authoritative for those, and the outbound queue is keyed on synced_at IS NULL, so accepting a server copy could resurrect an event you had already sent; it preserves the originating kitchen’s seq instead of allocating a new one, which is what makes re-reading an overlapping window free; and it marks pulled events synced on arrival, since a pulled event is on the server by definition.
And when you need to prove the two copies agree rather than trust the queue’s bookkeeping, hands books verify [--project <p> --handle <h>] compares the local table against the cloud copy seq by seq. synced_at cannot answer that question — the cloud’s (kitchen, seq) conflict clause can drop rows from a batch the server accepted — so this is the only honest check that a recovery actually took.