Shared documents, conflict-safe editing, and a task queue for coding agents — all over MCP. Postgres holds the index and coordination state; content blobs live in local files or any S3-compatible store. Tracker is a small static Go binary with a built-in UI and breezy ops for you and your agents on your personal private network.
Needs Docker Compose, Bash, curl, and Python 3. No Go toolchain or build is needed — it pulls a prebuilt image.
git clone https://github.com/chicagobuss/tracker && cd tracker
cp .env.example .env # runs as-is; set PGPASSWORD before the first start
docker compose up -d --wait # Postgres + tracker (--wait blocks until it's serving)
scripts/seed.sh # a welcome doc + example folio, so it isn't empty
scripts/smoke.sh # health + a full create -> lock -> write -> read round-tripThat's a working tracker on http://127.0.0.1:8770 — web UI in a browser,
markdown index for agents (curl http://127.0.0.1:8770). make up does the same
and runs the smoke test for you; make seed seeds it.
Use --wait: without it, compose returns as soon as the container exists, while
tracker is still connecting to Postgres and running migrations — so anything you
run immediately after (a smoke test, an agent, CI) can race the boot.
The Postgres image only uses PGPASSWORD while it first initializes its data
volume. If you change it later, rotate the database password too; changing only
.env will stop tracker from connecting.
The seeded welcome document is written for an agent to read: it explains what
tracker is, the document/folio/lease/actor model, and how to behave. Point a new
agent at the instance and tell it to read welcome — it can bootstrap itself from
there. Delete the seed docs once you have real content.
The arrow at the sidebar's edge hides or restores the document list. Use the Rendered / Raw switch above a document to read formatted text or its markdown source. Both preferences are saved in your browser and apply to past revisions.
Copy copies the version currently displayed: formatted text with a plain-text fallback in Rendered mode, or the exact markdown in Raw mode. The history banner is excluded from the copied content.
claude mcp add --transport http --scope user tracker http://127.0.0.1:8770/mcp \
--header "X-Actor: claude-code-<host>"Start a new Claude session and ask it to read welcome. If you enabled auth, add
--header "Authorization: Bearer <token>" to the command above.
Already using port 8770? Compose will fail with address already in use. Set two
lines in .env and re-run docker compose up -d:
PORT=8771
BASE_URL=http://127.0.0.1:8771Use http://127.0.0.1:8771/mcp in the agent-registration command above.
Defaults are deliberately safe for a first run: loopback-only, blobs in
./data/blobs, auth off. Before you expose it to other machines, read
Going multi-machine — it is one setting plus a token.
Common knobs, all in .env:
| Want to… | Set |
|---|---|
| Use a different port (8770 taken) | PORT=8771 and BASE_URL=http://127.0.0.1:8771 |
| Let other machines reach it | BIND_ADDR=<your LAN/Tailscale IP> |
| Keep blobs in S3/R2/MinIO instead of files | STORAGE_TYPE=s3 + the four required S3_* vars |
| Gate access behind a token | Set API_TOKENS to a token generated with openssl rand -hex 32 |
| Require attribution on a trusted network without tokens | REQUIRE_ACTOR=true |
Two different things, easy to conflate:
X-Actoris who did it. Required on every write, recorded as the document author, lease owner, and task claimant. It is self-asserted — tracker takes your word for it. That's the intended design on a trusted network: attribution between cooperating agents, not a security boundary.API_TOKENSis whether you may talk to the server at all. It is empty by default, meaning no auth — anyone who can reach the port can read and write.REQUIRE_ACTOR=trueis an actor-only admission mode for trusted private networks. WithAPI_TOKENSempty, every API and MCP request must carry some nonblankX-Actor; the value remains self-asserted and is not authentication.
So X-Actor is not a login, and running without API_TOKENS is fine on loopback
or a private overlay network — but it is the only thing standing between an open
port and an unauthenticated write API. Turn it on before you expose tracker
anywhere you don't fully trust.
For a trusted private overlay where reachability is the security boundary, use:
API_TOKENS=
REQUIRE_ACTOR=trueHealth checks, static UI assets, and signed blob URLs do not require an actor.
Generate a token in your shell, then paste its literal output into .env (do
not put the $(...) command substitution in the file):
openssl rand -hex 32
# .env
API_TOKENS=<paste-the-output-here> # comma-separate to issue severaldocker compose up -d --wait # picks up the new .envEvery request except /healthz now needs the token, or it gets a 401:
curl http://127.0.0.1:8770/docs # 401
curl -H "Authorization: Bearer <token>" http://127.0.0.1:8770/docs # 200Point agents at it by adding one header to the MCP registration:
claude mcp add --transport http --scope user tracker http://127.0.0.1:8770/mcp \
--header "X-Actor: claude-code" \
--header "Authorization: Bearer <token>"scripts/smoke.sh and scripts/seed.sh read API_TOKENS from .env themselves,
so they keep working with no extra flags. Issue a token per agent
(API_TOKENS=tok-laptop,tok-ci,tok-server) if you want to be able to revoke one
without rotating the rest — but note that a token only grants access; it does not
yet pin which X-Actor a caller may claim (see the backlog).
The default keeps blobs in ./data/blobs, which needs no extra infrastructure. To
put them in object storage instead, set STORAGE_TYPE=s3 and the four S3_* vars
before the first docker compose up. Everything else is identical — Postgres still
holds the index, and it only ever stores the sha256/<hash> key, never the
backend location.
Cloudflare R2 (endpoint is your account's, TLS on, no region):
# .env
STORAGE_TYPE=s3
S3_ENDPOINT=<account-id>.r2.cloudflarestorage.com
S3_ACCESS_KEY=<r2 access key id>
S3_SECRET_KEY=<r2 secret access key>
S3_BUCKET=tracker-blobs
S3_USE_SSL=trueAWS S3: S3_ENDPOINT=s3.<region>.amazonaws.com, S3_USE_SSL=true.
MinIO / RustFS / anything S3-compatible: S3_ENDPOINT=host:9000, and
S3_USE_SSL=false if it's plain HTTP on a private network.
docker compose up -d --wait
scripts/smoke.sh # writes a doc, so it proves the bucket workstracker creates the bucket on startup if it doesn't exist and your credentials allow it; if they don't, create it first and it will just use it. All four vars are required — miss one and tracker refuses to start, naming it, rather than silently falling back to local files.
Already running on local files? Don't hand-copy anything — tracker migrate-blobs
does a verified, non-destructive copy and prints the cutover step. See
Switching storage backend.
tracker speaks MCP natively — the server exposes a Streamable HTTP MCP
endpoint at /mcp, so any agent connects with one line of config and zero local
code (like Notion's remote MCP server). Add
--header "Authorization: Bearer <token>" if you set API_TOKENS.
X-Actor is the agent's identity, stamped on every write. Cursor, Gemini CLI,
and anything else that speaks HTTP MCP configures the same way — the tools live
in the tracker binary, versioned and deployed with it, so clients can never drift
out of sync.
Tools: list_docs (incl. deleted=exclude|only|include), get_doc, get_raw,
create_doc, update_doc (lease + version-check + release, for you),
lock_status, retag_doc (tags/metadata/title/kind without a content rewrite),
soft_delete_doc / restore_doc / hard_delete_doc (hard delete requires
confirm equal to the slug), list_tags, list_folios, create_folio,
get_folio, get_folio_file, add_folio_file, the task-queue tools
(list_tasks, get_task, enqueue_task, claim_task, complete_task),
list_actors, and actor_activity.
skills/tracker/SKILL.md is the matching Claude Code skill (copy to
~/.claude/skills/tracker/, then set the base URL at the top) describing
when/how to consult tracker.
The old per-machine stdio bridge (mcp/tracker_mcp.py) has been removed — /mcp
replaces it, and a second copy of the tool surface is exactly the drift the native
endpoint exists to prevent. It's in git history if you need it.
- Leases, not advisory locks. A
doc_locksrow with a TTL + heartbeat answers "who is writing this right now". A crashed agent's lease auto-expires, so it can never block a doc forever. - Two-layer write safety. A write requires (a) a live lease the caller holds
(
X-Lease-Token) and (b)If-Match: <version>optimistic concurrency, so a stale or lease-less write can't clobber. - Content-addressed blobs. Bytes are stored under
sha256/<hash>(immutable, deduped) in either a local directory (STORAGE_TYPE=file) or an S3 bucket (STORAGE_TYPE=s3). Either way agents fetch them from an expiringBASE_URL/blobs/...link, so acontent_urlis reachable from wherever tracker is and the bucket stays private. - Task queue.
taskswithFOR UPDATE SKIP LOCKEDclaiming — no two agents grab the same task. - Workspaces enforced by the database. Separation is a Postgres row-level
security policy, not a
whereclause this codebase has to remember. A request runs withapp.workspaceset and every table is filtered to it, so a query that forgets its predicate returns nothing rather than another workspace's rows.
A workspace is a hard partition of the store. Documents, tasks and actors in one
are invisible from another — separate search results, separate slug namespaces,
separate list_tags vocabularies. Point a throwaway experiment at its own
workspace and it cannot pollute an agent's context with unrelated work.
curl -X POST localhost:8770/workspaces -H 'X-Actor: me' \
-H 'Content-Type: application/json' -d '{"name":"greenfield-idea"}'
claude mcp add --transport http --scope user tracker-gf http://127.0.0.1:8770/mcp \
--header "X-Actor: claude-code-$(hostname -s)" \
--header "X-Workspace: greenfield-idea"Names match [a-z0-9][a-z0-9_-]{0,62}. Creating one is free — a row in a
registry table, no DDL — so spinning up a dozen costs nothing. An unknown name
is a 404 rather than an implicit create, so a typo'd X-Workspace reports
itself instead of silently presenting an empty store.
Everything that predates workspaces lives in default, and a request that names
none resolves there, so existing agents keep working untouched.
Tools also take an optional workspace argument, so one session can work in
another workspace without re-registering the server:
list_docs {"q": "sequencer", "workspace": "scripture"}
create_doc {"slug": "note", "workspace": "scripture", ...}
Mind where you point a write: a create_doc aimed at the wrong workspace
misfiles the doc, and nothing will warn you — the argument is honoured
verbatim. It is refused outright when the token is confined, because the
argument is caller-supplied and would otherwise hand back the boundary the
token exists to enforce. The registry tools (list_workspaces,
create_workspace) take no override; the registry is not workspace-scoped.
X-Workspace is a preference; a confined token is a boundary. The header is
caller-supplied, so on its own it separates work without securing it — fine for
avoiding context pollution between your own agents. To make it enforceable, bind
the token: API_TOKENS=<tok-a>:personal,<tok-b>:greenfield-idea. A confined
token pins its workspace and ignores the header entirely.
One caveat: /blobs/<sha256> is not workspace-scoped. Content is addressed by
hash, so fetching one means already knowing its content, but revision listings do
hand out hashes — so keep API_TOKENS set if that matters to you.
The default compose is loopback-only on a private bridge network, which works the same on Linux, macOS, and Windows. To let agents on other machines reach tracker, publish the port on a trusted interface and turn auth on:
# .env
BIND_ADDR=100.x.y.z # your LAN / Tailscale / ZeroTier IP
BASE_URL=http://100.x.y.z:8770 # what agents will use to reach it
API_TOKENS=<paste-a-generated-token> # never expose tracker without thisTo bind several interfaces at once (loopback + LAN + Tailscale), use Linux
host networking, where tracker binds each address in LISTEN_ADDR itself:
# .env: LISTEN_ADDR=127.0.0.1:8770,192.168.1.100:8770,10.10.10.10:8770
docker compose -f docker-compose.yml -f compose.host.yml up -dHost networking is Linux-only — network_mode: host is a no-op on Docker
Desktop, so on macOS/Windows stay with the default compose.
X-Actor is self-asserted attribution, not authenticated identity. On a
trusted network that's the point. Set API_TOKENS if you need writes to be
gated; bind actor→token if you need attribution to be tamper-proof (see the
backlog).
make update TAG=v1.5.0 # run a published release here (the standard deploy)
make status # what this host is pinned to vs what it runs
make up # start with whatever .env pins + smoke test
make down # stop; data survives in the pgdata volume
make logs # follow tracker logs
make smoke # prove a running instance round-trips a write
make deploy # DEV: rebuild from your working tree insteadThe standard deploy is make update TAG=vX.Y.Z. It pins TRACKER_IMAGE in
.env, pulls that release, restarts, smoke-tests the write path, and prints the
version the binary actually reports. Rolling back is the same command with an
earlier tag, so what a host runs is always one greppable line in its .env
rather than whatever its working tree happened to contain.
make deploy is the development path: it builds your working tree instead of
running a release, so the version it stamps is a git describe string that
matches no release. Hosts that build this way set COMPOSE_FILE in .env to
include compose.build.yml, whose image: tracker:local overrides
TRACKER_IMAGE — make update detects that and refuses rather than pinning a
release the stack would ignore.
make up pulls the published image. Contributors who want their working tree
built instead use the build override (that's what make deploy does):
docker compose -f docker-compose.yml -f compose.build.yml up -d --buildThe version is git describe --tags --always --dirty — logged at startup, served
at /version, and recorded in each backup's manifest.json.
Running the binary directly, no container: make build && set -a && . ./.env && set +a && ./tracker (needs Go and a reachable Postgres).
Pushing an annotated v* tag is the whole ritual. Everything else is CI.
git tag -a v1.5.0 -m "v1.5.0 — Short title
What changed and why, in as much detail as it deserves."
git push origin v1.5.0That one push runs three jobs in order, each gated on the last:
| Job | Does |
|---|---|
test |
gofmt, vet, build, go test -race against a real pgvector Postgres |
image |
builds and pushes ghcr.io/chicagobuss/tracker:v1.5.0 (+ sha-<short>) |
release |
creates the GitHub Release from the tag's annotation |
Because release needs image, a Release can never point at a version that
failed to publish. Re-running a workflow is safe — the job no-ops if the release
already exists.
Write the notes in the tag. The annotation is the release: its first line
becomes the title, everything after it the body. That keeps git tag -a the
single source of truth instead of maintaining a changelog in two places.
Use -a. A lightweight tag (plain git tag v1.5.0) has no annotation, so
the release falls back to notes generated from the PRs merged since the previous
tag — serviceable, but blunter than what you'd write. Both v1.3.0 and the
release-less v1.3.1 predate this and show the two failure modes.
latest tracks the default branch, not tags. It moves on every push to main,
so it is whatever main last built — pin vX.Y.Z for anything you actually
depend on.
Deploying it is one command on the host, and the same one everywhere:
make update TAG=v1.5.0That pins TRACKER_IMAGE in .env, pulls, restarts, smoke-tests, and reports
the running version. It needs no Go toolchain and no checkout of the tag — the
artifact is the one CI built and tested from that commit, not a local rebuild of
it. Roll back by naming an earlier tag.
Confirm what actually landed with make status or curl <host>:8770/version. The
version is git describe --tags --always --dirty, so a build from a dirty or
untagged tree says so rather than quietly claiming a release number — it is
logged at startup, served at /version, and recorded in each backup's
manifest.json.
make test # throwaway Postgres + go test (needs a local Go toolchain)
make test-docker # same, but Go runs in a container too (needs only Docker)Both start a scratch pgvector container, run the suite against it, and tear it
down. The lease/CAS state machine and SKIP LOCKED task claiming are tested
against a real Postgres, because that's where those semantics actually live — the
tests skip (rather than fail) if TEST_DATABASE_URL isn't set, so a bare
go test ./... stays green without a database.
| Method | Path | Purpose |
|---|---|---|
| GET | /healthz · /version · /openapi.yaml · /llms.txt |
health (checks Postgres), version, spec, agent index |
| POST | /mcp |
native MCP endpoint (Streamable HTTP, tools-only) |
| POST · GET | /docs |
create (content seeds v1); list/search (?q=&mode=&kind=&tag=&deleted=&view=&limit=&offset=) |
| GET · PUT · PATCH · DELETE | /docs/{id} |
read; write content (lease + If-Match); relabel; hard-delete (requires confirm = slug) |
| POST | /docs/{id}/soft-delete · /docs/{id}/restore |
soft-delete (recoverable; optional cascade for folios) · restore |
| GET | /docs/{id}/raw · /docs/{id}/revisions[/{v}/raw] |
content bytes; version history |
| POST · GET · DELETE | /docs/{id}/lock |
acquire/renew (409 if held) · status · release |
| GET | /tags |
tag vocabulary with counts |
| GET · POST | /folios · /folios/{slug} · /folios/{slug}/files[/{filename}[/raw]] |
collections + their files |
| POST · GET | /tasks[/{id}] · /tasks/claim · /tasks/{id}/complete |
task queue: enqueue, list (?status=), claim (TTL'd; expired claims re-claimable), complete (claimant-only) |
| GET | /actors · /actors/{name}/activity |
entity registry + activity |
The authoritative reference is openapi.yaml (served live at /openapi.yaml).
Conventions. Every response is wrapped — a single resource under its type
({"document":…}, {"folio":…}, …), lists as {"<type>s":[…],"count","total",…},
errors as {"error":{"code","message",…}} with machine codes. Lists default to a
trimmed view=summary; view=table is a compact columnar {cols,rows},
view=full whole objects. Search is websearch_to_tsquery (mode=web default;
mode=plain for strict AND).
Every mutating request must send an X-Actor: <name> header naming the
entity performing it (missing → 400). That value is stamped into
created_by/updated_by, the revision author, the lease owner, and task
claimed_by, and upserted into the actors registry. A write must come from the
entity that holds the lease (actor ≠ lease owner → 423).
A folio is a collection of related documents (modeled after GitHub gists).
It's modelled tableless: the folio is itself a document with kind='folio' whose
metadata holds {description, public, github_id, ...}; its files are documents
tagged folio:<slug> with slug <folio-slug>/<filename>. So a folio file
inherits everything (versioning, leases, attribution, search). Create one with
POST /folios and add files with POST /folios/{slug}/files; import your recent
gists with scripts/import_gists.py.
{id} accepts a UUID or a slug — including multi-segment folio slugs like
myfolio/file.md, for reads, writes, relabels, delete, /raw, and /lock alike
(an exact slug always wins over the /raw//lock//soft-delete//restore
suffix). The one quirk: a file literally named raw, lock, soft-delete, or
restore collides with those suffix routes — address those via
/folios/{slug}/files/{filename} or the UUID.
- Soft-delete (
POST /docs/{id}/soft-delete, MCPsoft_delete_doc) setsdeleted_at/deleted_by. The row and revision history stay; default search (deleted=exclude) hides it;deleted=only|includefinds it;get_docby id/slug still works;restore_docbrings it back. Prefer this. - Hard-delete (
DELETE /docs/{id}, MCPhard_delete_doc) removes the row (revisions cascade; blobs left for GC). It requiresconfirmequal to the document's exact slug — MCP marksconfirmrequired in the tool schema so agents cannot call it without an explicit matching value. Folios with files needcascade=true.
State lives in two places that must be captured together: Postgres (the index)
and the blobs (the content). Each run writes a small snapshot — db.dump +
keys.txt + manifest.json — while the blob bytes live once in a shared, growing
set of pack files that every snapshot draws on. A snapshot on its own is not a
restore: you need the packs too. Blobs are immutable and named by their own
sha256, so a run costs O(new blobs) rather than O(all blobs), and each blob is
stored once instead of BACKUP_KEEP times.
scripts/backup.sh # -> ./backups/tracker-backup-<ts>.tar.gz
scripts/backup.sh --upload # also push to R2/S3 (set BACKUP_S3_* in .env)
scripts/backup.sh --if-changed # skip entirely if no content changed (for cron)
# hourly, only when something changed (BACKUP_KEEP prunes local tarballs, default 48):
# 7 * * * * /path/to/tracker/scripts/backup.sh --if-changed --upload >> /path/to/tracker/backups/backup.log 2>&1
scripts/restore.sh ./backups/tracker-backup-<ts>.tar.gz # from a local file
scripts/restore.sh --from-s3 tracker-backup-<ts>.tar.gz # pull from R2/S3 first
docker compose up -d tracker # then start the serviceThe backup dumps Postgres first, then folds the content store into the packs.
Writes are blob-first, so every content_key in the dump already has its blob;
folding the whole store rather than a key list queried afterwards means a delete
racing the dump cannot orphan a referenced blob. A restore derives the keys it
needs from the database it just restored, so the two can never disagree. Restore is verified round-trip: restoring
into a scratch DB+bucket reproduces the exact doc/blob counts and a tracker booted
against it serves the content. scripts/s3util.py moves blobs and tarballs to any
S3-compatible store (RustFS, AWS S3, Cloudflare R2).
To restore on a fresh machine: clone the repo, create .env, docker compose up -d postgres, run restore.sh, then docker compose up -d tracker.
Blobs are content-addressed and Postgres stores only the sha256/<hash> key —
never the backend location — so switching between local files and S3 is just a
blob copy plus a config flip. The migrate-blobs subcommand does the copy:
tracker migrate-blobs --to file --blob-dir ./data/blobs # S3 -> local files
tracker migrate-blobs --to s3 # local files -> S3
# --dry-run hash-check + count, write nothing
# --verify also re-read each blob from the destinationIt reads every referenced blob from the current backend (STORAGE_TYPE), verifies
each against its hash, and writes it to the destination. It is non-destructive
(the source is left intact) and idempotent. On success it prints the cutover
step — set STORAGE_TYPE (and BLOB_DIR for file) in .env and restart — so the
switch is deliberate and reversible.
Every revision is kept, one object (or file) per blob. Most blobs are small, and many are near-copies of the previous revision of the same document, so on disk they cost several times their size. On one real store, 44.7 MB of content took 116 MB on a ZFS pool. With compaction on, tracker packs cold blobs in the background, through the same backend, so files and S3 work the same way:
- Each blob is stored as the smallest of: its raw bytes; zstd with a dictionary trained on the store's own blobs; or zstd with the document's previous revision as the dictionary (a delta). Delta chains are capped at 16 revisions, after which a blob is stored standalone again, like a keyframe in video.
- Packs are content-addressed (
packs/<sha256>). Every member is decoded and checked before a pack is written, and the pack is read back before anything points at it. - Reads look for a packed copy first, verify it against its sha256 key, and
fall back to the loose copy. Clients see no change:
content_urlstill works. - The loose copy is deleted after
COMPACT_GRACE, and only if its packed copy still decodes and verifies at that moment.
On the store above, best-of encoding brings the content to about 12.8 MB (3.5x), in a few hundred packs instead of 15.7k files.
# .env
COMPACTION=manual # off (default) | manual | auto
COMPACT_AFTER=1h # pack a blob once it is this old
COMPACT_GRACE=24h # then delete its loose copy after this
COMPACT_BATCH=200 # blobs per stepIn manual mode nothing is packed until you grant a budget, so the first
backfill of an existing store happens in steps you choose. Once you trust it,
switch to auto, which packs everything older than COMPACT_AFTER
continuously. With API_TOKENS set, these endpoints need a token that isn't
confined to a workspace:
curl -s $T/admin/compaction # state, backlog, ratio, last step
curl -s -X POST "$T/admin/compaction/run?blobs=500" # manual: allow 500 more blobs
curl -s -X POST $T/admin/compaction/pause # stop packing and reclaiming (persists)
curl -s -X POST $T/admin/compaction/resume
curl -s -X POST $T/admin/compaction/stop # drop the remaining budgetGET /metrics exposes the same numbers in Prometheus format
(tracker_compaction_*, tracker_blob_reads_total{source=loose|pack|fallback}).
It needs a bearer token when API_TOKENS is set. Backups (scripts/backup.sh)
include packs and dictionaries in place of loose copies that have been deleted.
migrate-blobs carries them across backends.
Running in "production" (lol) and used heavily for months. Recently landed: CI
(GitHub Actions → GHCR images), the native /mcp endpoint (replacing the
per-machine client script), task-queue visibility + claim expiry + claim-by-id, a
real /healthz, soft/hard delete, and a test suite covering the lease/CAS and
task-claim state machines.
-
Request logging / metrics —
/metricsexists (blob reads and compaction); add access-log middleware (method, path, status, duration, actor), plus counters for writes and lease conflicts. -
Auth hardening — use constant-time bearer-token comparison; implement actor↔token binding so a token pins which X-Actor it may assert.
-
Migration version tracking — every migrations/*.sql re-runs on each boot and relies on idempotency. Add transactionally applied, checksummed migration tracking before introducing a non-idempotent migration.
-
Write pre-check — check lease/version before blob upload to reduce orphan blobs on rejected writes (412/423), while retaining the transactional check to prevent races.
-
Orphan-blob / expired-lease GC — mark-and-sweep blobs not referenced by documents or document_revisions, and periodically remove expired doc_locks.
-
HTTP/MCP tests — the store state machines are covered, but request-level REST and MCP behavior is effectively untested.
-
Web UI tasks panel — browse the queue through the existing task endpoints, including status filtering and payload/result detail.
-
pgvector semantic search — the embedding column already exists; populate it on write and add a semantic query path.
-
Scheduled off-box backups— done: cron scripts/backup.sh --if-changed --upload with retention, and add automated restore verification against manifest document/blob counts. -
Public sandbox instance — a rate-limited, internet-facing demo: Caddy in front with per-IP limits and body caps; bridge networking with only the proxy exposed; a seed-restore reset every ~6h; and app-side quotas such as MAX_DOCS / MAX_TOTAL_BLOB_BYTES so one actor cannot fill the disk between resets. The native /mcp endpoint then gives visitors one-command agent onboarding.
Longer-horizon ideas—blob compression, retention thinning, and content-defined chunking—should wait until usage metrics justify their complexity.
PRs welcome but I can't promise I'll get to them!