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 namespacetask/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}
opWITHresult
notebook/appendnotebook, type, data?, object_key?, ttl? (seconds), namespace?{seq, at, existed}
notebook/readnotebook, after?, since?, until?, tail?, type?, limit?, namespace?{entries, next, cursor, count, truncated}
notebook/exportnotebook, format? (ndjson), after?, since?, until?, type?, limit?, namespace?the body on @web.res.body + @halt; {count, next, cursor, truncated, bytes} at into
notebook/listprefix?, after?, limit?, namespace?{notebooks: [{name, high_seq, ttl_secs, created_at, updated_at}], next, count}
notebook/deletenotebook, 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 seqtail = 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.

paramselects
afterentries after a cursor — the head walk
since / untilentries whose at is in [since, until) (RFC 3339, any precision)
tailthe newest N of the selection
typeonly entries of that type
limitthe 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 and list 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. Pass namespace to 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/.

Edit this page · View as markdown