All docs
Docs · Reference
Notebooks
an append-only record a stack writes and reads
txco://notebook/ is the one place an op can keep history: an ordered, append-only record that outlives the request. The KV store holds a stack's current state; a notebook holds how it got there — task activity, a conversation, a pony's audit trail, an import's diagnostics — read back by cursor, by time window, or from the tail, and exported as newline-delimited JSON._
Task is state; notebook is history. A task record answers “where are we now?”; its notebook answers “how did we get here?”
A notebook is identified by its name inside a namespace — task/42 in www, conversation/alice in pony-paris — and holds entries:
{
"seq": 7,
"at": "2026-09-11T14:03:12.481027000Z",
"type": "command.finished",
"data": {
"cmd": "npm test",
"exit": 0
},
"object_key": "run:8f2c"
} seq is the notebook’s own order, allocated on append and never reused; at is assigned by the chassis at commit (a caller’s own timestamps go
inside data); type is a short dotted label; data is any JSON; object_key is optional and makes an append idempotent.
The ops
All five are tenant-scoped and answer at into (default _notebook).
# journal a decision — safe to run again for the same message
WHEN ._gate.audit.source != ""
EXEC "txco://notebook/append"
WITH notebook = &concat("task/", ._p.msgkey),
type = "recipient.resolved",
data = ._gate.audit,
object_key = &concat("gate:", ._p.msgkey)
# → _notebook = {seq, at, existed} | op | WITH | result |
|---|---|---|
notebook/append | notebook, type, data?, object_key?, ttl? (seconds), namespace? | {seq, at, existed} |
notebook/read | notebook, after?, since?, until?, tail?, type?, limit?, namespace? | {entries, next, cursor, count, truncated} |
notebook/export | notebook, format? (ndjson), after?, since?, until?, type?, limit?, namespace? | the body on @web.res.body + @halt; {count, next, cursor, truncated, bytes} at into |
notebook/list | prefix?, after?, limit?, namespace? | {notebooks: [{name, high_seq, ttl_secs, created_at, updated_at}], next, count} |
notebook/delete | notebook, namespace? | {deleted} |
A duplicate object_key returns the original. With at-least-once
inlets, mail redelivery and task resume, (notebook, object_key) is how a
stack says “this event is already recorded”: the second append writes
nothing and answers the original {seq, at} with existed: true — a retry
is observationally identical to the first success, so caller code needs no
special case.
Failure is branch-visible. Errors land as ._notebook.error.{code, message} and the run continues; there is never a success response without
a record. A stack that must not declare a task complete unless the
completion was journaled can say so:
WHEN ._notebook.error.code != ""
EMIT ._task.status = "journal_failed" Codes: txco_notebook_no_tenant, txco_notebook_disabled (no store on
this node), txco_notebook_invalid_arg, txco_notebook_invalid_name, txco_notebook_too_large, txco_notebook_stale_cursor, txco_notebook_store.
Reading: always oldest first
Selection varies; order never does. Every read returns entries ascending
by seq — tail = 50 selects the newest fifty and returns them oldest to
newest, so “the latest entry” is always the last element and a paged read
accumulates in order.
| param | selects |
|---|---|
after | entries after a cursor — the head walk |
since / until | entries whose at is in [since, until) (RFC 3339, any precision) |
tail | the newest N of the selection |
type | only entries of that type |
limit | the page size (default 100, node ceiling --notebook-max-read-rows) |
Two fields come back with every page. next is the cursor for the
following page and is non-empty only when the page was full — the
drain idiom, which the LOOP clause turns into one op:
EXEC "txco://notebook/read"
WITH notebook = "task/42", limit = 200, after = ._notebook.next
LOOP EVERY "2ms" UNTIL ._notebook.next == "" MAX 20
# arrays append on merge, so ._notebook.entries accumulates — in order cursor is the position after the last entry returned, whenever there was
one. It is what a poller hands back: tail = 20 for the first paint, then after = ._notebook.cursor to pick up what arrived since.
Cursors are opaque. after, next and cursor are strings the
chassis issues; never build one from a seq. A cursor carries the
notebook’s generation, so if the notebook is deleted and written again, an
old cursor answers txco_notebook_stale_cursor instead of silently reading
new entries as old ones. Start over from the beginning.
Exporting NDJSON
notebook/export writes the selection straight onto the HTTP response —
one entry per line, content-type: application/x-ndjson — and halts, so a
stack serves a log file in one op:
# 1000_PREFLIGHT — lift the query parameters once
WHEN @src == "http" && @web.req.url.path == "/task/log.ndjson"
EMIT ._q.task = @web.req.url.query.task.0,
._q.since = @web.req.url.query.since.0 WHEN ._q.task =~ /^[a-z0-9-]+$/
EXEC "txco://notebook/export"
WITH notebook = &concat("task/", ._q.task),
since = ._q.since An export is bounded by limit rows and --notebook-max-export-bytes (8 MiB); when either cuts it short, truncated is true and next — also
sent as the x-notebook-next response header — resumes it with after.
Naming and scope
- Names are
/-separated segments of[A-Za-z0-9._-](no.,.., or a segment starting with_; at most 250 bytes) — the blob grammar, so hierarchical families (task/…,conversation/…,workspace/…) work andlist WITH prefix = "task/"enumerates one. - Namespace defaults to the routed stack, with a nested inlet stack
(
core/_mail,core/_websocket) sharing the app’s — the KV rule. Passnamespaceto choose one, as a per-persona stack does with&concat("pony-", ._in.slug)._txc-prefixed namespaces are reserved. - Tenant is pinned from the request; a notebook is never addressable across tenants.
A notebook exists from its first append; list and delete see heads, read on a name that was never written is simply empty.
Retention
Unbounded by default. WITH ttl = 604800 on an append expires that entry a
week later; expired entries disappear from reads at once and are reclaimed
by a periodic sweep (--notebook-sweep-period, 10m). Pruning never
renumbers — seq only ever grows.
What a notebook is not
A notebook write is not part of the transaction of the thing it
describes: a command can finish while its command.finished append fails,
or the reverse. Notebooks are an application record, not an event-sourcing
substrate, and give no atomicity with other chassis ops — never make task
state rebuildable only from a notebook. They are not a search index, not
metrics, not a queue, and have no aggregates or cross-notebook queries.
Operating it
The store is its own database — never the runtime DB — a per-node SQLite
file by default (--notebook-db-path), or a shared backend a deployment
registers (the hosted build points every node at one Postgres). Entries
are capped at --notebook-max-entry-bytes (64 KiB); a read or export pays
fuel per MiB returned on top of the dispatch cost.
txco notebook list www task/ # notebooks under a prefix
txco notebook tail -n 20 www task/42 # newest twenty, oldest first
txco notebook read --all www task/42 # every entry, one JSON object per line
txco notebook export www task/42 > task-42.ndjson The same view is GET /v1/tenants/{t}/notebooks/{namespace}[/{name}] on
the admin API (notebook:*:read).
A worked example lives at examples/notebook-log/.