●ctx0.1.0

Concepts

ctx is a key-value store whose keys form a tree, whose every change is numbered and kept, and whose changes you can follow. Seven ideas cover all of it.

Keys make a tree

A key is a dotted path, project.lighthouse.status, and its value is any JSON: an object, an array, a string, a number, a boolean or null. There are no schemas and no types to declare. Prefixes are conventions that agents share, not rules the store enforces; the agent conventions list them.

Keys, entries and the tree they formTop: the key project.lighthouse.status split at its dots into three segments: project, the conventional prefix; lighthouse, the project's name; status, the field. A key has at most 32 segments and 512 bytes, each segment made of letters, digits, underscore and hyphen. Bottom: two stored entries, machine.atlas holding an object and machine.atlas.quirks holding a string. Reading machine.atlas returns one JSON tree in which the node's own value sits under the @ field and its child appears as quirks. project prefix by convention . lighthouse name your repository . status field anything you like ≤ 32 segments ≤ 512 bytes [A-Za-z0-9_-]+ TWO STORED ENTRIES machine.atlas {"role":"gpu builder", "owner":"maya"} machine.atlas.quirks "Nix builds capped at 8 jobs; /scratch is wiped nightly" ctx get machine.atlas ONE JSON TREE { "@": { "role": "gpu builder", "owner": "maya" }, "quirks": "Nix builds capped…" } its own value its child
Keys and the tree.A key can hold a value and have children. Reading a key returns its whole subtree as one JSON document, with the key's own value under "@".
ctx get machine.atlas{  "@": {    "role": "gpu builder",    "owner": "maya"  },  "quirks": "Nix builds capped at 8 jobs; /scratch is wiped nightly"}

On the command line a value is parsed as JSON when it parses, and stored as a string when it does not. --string forces a string.

ctx set agent.heron.note hello && ctx set agent.heron.count 3 && ctx set agent.heron.text --string 3 && ctx get agent.heronagent.heron.note: rev 18 (v1)agent.heron.count: rev 19 (v1)agent.heron.text: rev 20 (v1){  "count": 3,  "note": "hello",  "text": "3"}

One counter, every version kept

The store has one revision counter, like etcd. Every change to any key takes the next number, so revisions order everything that ever happened. Each entry records the revision of its last change, its version (writes since it was created), its writer and the time. Nothing expires: history keeps every version, and a delete is kept as a tombstone.

One revision counter for every keyA horizontal axis of global revisions from 1 to 16. Four keys each have a lane: project.lighthouse.status changes at revisions 1 and 2, project.lighthouse.decisions at 3, flow.release.state at 9, 10 and 11, and flow.deploy.state at 14, 15 and 16. Every change, to any key, takes the next number. A callout on flow.release.state at revision 11 shows its entry metadata: rev 11, version 3, writer wren, and history keeping versions 9 and 10. project.lighthouse.status project.lighthouse.decisions flow.release.state flow.deploy.state 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 global rev head rev 11 · version 3 writer wren history keeps 9 and 10 every change to any key takes the next number; nothing is reused or expires
Revisions.The numbers come from the transcripts on this site: one store, four keys, sixteen changes.

That makes the past readable. history lists a key's versions; any read takes --rev to see the store as it was.

ctx history project.lighthouse.status2  project.lighthouse.status  just now  wren  {"state":"review","pr":31,"branch":"wren/flaky-ingest"}1  project.lighthouse.status  just now  wren  {"state":"open","pr":31,"branch":"wren/flaky-ingest"}ctx get project.lighthouse.status --rev 1 --compact{"state":"open","pr":31,"branch":"wren/flaky-ingest"}

Writing a value a key already holds is a no-op: no new revision, nothing for watchers. Agents can report status as often as they like.

ctx update project.lighthouse.status '{"state":"review"}'project.lighthouse.status: unchanged (rev 2)

Writes, and writing safely

OperationDoes
setReplaces the value.
updateMerges a JSON merge patch (RFC 7386): objects merge, null removes a field, anything else replaces. An absent key is patched as if it held null.
appendAdds to an array, creating it if absent; max_items keeps only the newest. Appending to a non-array is an error.
deleteRemoves a key, or with recursive its whole subtree; each removed key takes a revision.
batchApplies several of the above in order, atomically: all or none. Each still takes its own revision.

Every write takes an optional if_rev: the revision you expect the key to have. If anything changed it since you read it, the write is refused with a conflict and exit code 4. if_rev: 0 means the key must not exist, which makes a claim or a lock.

Two writers race; compare-and-set picks oneA sequence between three lifelines: wren, the ctx daemon and heron. Both wren and heron read flow.release.state and see the value testing at revision 9. Wren writes approved with if-rev 9; the daemon applies it as revision 10. Heron then writes rejected with if-rev 9; the daemon refuses with a conflict because the current revision is 10, and heron re-reads before deciding again. wren ctx daemon heron testing · rev 9 testing · rev 9 both read flow.release.state set "approved" if_rev 9 rev 10 set "rejected" if_rev 9 conflict: current rev 10 re-read, then decide
Compare-and-set.No lock server and no leases: the loser learns it lost, re-reads and decides again.
ctx set flow.release.state '"approved"' --if-rev 9flow.release.state: rev 10 (v2)# heron read the same rev 9ctx set flow.release.state '"rejected"' --if-rev 9ctx: conflict on `flow.release.state`: expected rev 9, current rev 10next: re-read the key and retry with its current --if-rev

A batch carries one condition per write, so you can advance a state machine, log the step and release a lock in one move; see the release recipe.

Watching never misses a change

A watch follows a pattern from a revision. Patterns are an exact key, flow.release.state; one level of children, flow.release.*; or a whole subtree, flow.** (which includes flow itself), also written flow.>.

A watch: subscribe, replay, ready, liveA watcher asks for flow.deploy.** since revision 13. The daemon first subscribes to the live feed so nothing committed meanwhile is lost, then replays the matching changes 14, 15 and 16 from the database, sends a ready event carrying the head revision, and then streams live changes as they commit, with a heartbeat every 15 seconds. A watcher that falls behind the live feed is caught up from the database, and the CLI reconnects after a restart from the last revision it saw. REPLAYED FROM HISTORY LIVE, AS THEY COMMIT 14 building 15 deploying 16 done ready 17 18 later changes heartbeat 15 s … 1 subscribe first 2 replay after 13 3 ready · head 16 4 live until you stop Behind the live feed? Caught up from the database, in order, with no gaps. Disconnected? ctx watch reconnects and resumes from the last revision it saw.
A watch stream.Subscribing before replaying is what closes the gap between history and live changes.

Three ways to use it:

ctx watch flow.deploy.state --until '"done"' --timeout 30s{"type":"change","rev":14,"key":"flow.deploy.state","op":"set","value":"building","version":1,"writer":"ci","at":"2026-10-17T16:58:24.513Z"}{"type":"change","rev":15,"key":"flow.deploy.state","op":"set","value":"deploying","prev_rev":14,"version":2,"writer":"ci","at":"2026-10-17T16:58:25.124Z"}{"type":"change","rev":16,"key":"flow.deploy.state","op":"set","value":"done","prev_rev":15,"version":3,"writer":"ci","at":"2026-10-17T16:58:25.732Z"}# exit 0, the moment "done" committed

Polling works too: ctx changes 'flow.**' --since 7 returns what changed after revision 7 and where to continue from. Agents get the same feed as messages through subscriptions.

Fresh enough to trust

An entry is a claim by its writer at a time. Reads can drop or flag old claims and fit a budget, which matters when the result goes into a prompt.

OptionEffect
--max-age 7dOmits entries not updated within the age.
--stale-after 7dKeeps older entries but marks them STALE.
--max-bytes 8000Drops the oldest entries until the output fits, and says how many it dropped.
--since-rev 20Only entries changed after revision 20.
--format mdMarkdown with each entry's age and writer, ready for a system prompt. Also json, ndjson and raw.
ctx get project.lighthouse --format md --stale-after 1s## ctx: project.lighthouse (3 entries, 3 stale)- `project.lighthouse.conventions` (STALE, 9m ago, heron): "Tests: cargo nextest; never commit generated fixtures"- `project.lighthouse.decisions` (STALE, 9m ago, heron): [{"what":"Retry ingest twice, then fail the batch","by":"heron"}]- `project.lighthouse.status` (STALE, 8m ago, deploy-bot): {"state":"merged","branch":"wren/flaky-ingest"}ctx get project.lighthouse --format md --max-bytes 300## ctx: project.lighthouse (1 entry, 2 older omitted)- `project.lighthouse.status` (8m ago, deploy-bot): {"state":"merged","branch":"wren/flaky-ingest"}ctx: 2 older entries omitted (--max-bytes)

Search looks through keys and values. Every write is indexed for keywords in the same transaction, so it is findable at once. With an embeddings endpoint configured, entries are also embedded in the background and become findable by meaning a moment later.

ModeFinds
ftsKeywords, ranked by BM25 (SQLite FTS5). Every word must match; the last may be a prefix. The default without embeddings.
semanticEntries closest in meaning to the query, by cosine similarity of embeddings from any OpenAI-compatible endpoint.
hybridBoth lists fused by reciprocal rank; each hit says whether it matched by keyword, meaning or both. The default with embeddings.
ctx search nightly scratchmachine.atlas.quirks  46s ago  Nix builds capped at 8 jobs; /[scratch] is wiped [nightly]

If the embeddings endpoint fails, hybrid search keeps answering from keywords and ctx status shows the error.

One token, named writers

A ctx instance has one bearer token, and anyone holding it can read and write everything. There are no per-subtree permissions: an instance serves one purpose, and another purpose gets another instance. Every change records a writer, which the client declares: --writer, else CTX_WRITER, else CACO_AGENT_ID, else user@host. It identifies, it does not authenticate.

Values are readable by every token holder and kept in history forever. Never store secrets, tokens or private data in ctx.