cd /news/developer-tools/tracker-a-lightweight-self-hosted-do… · home topics developer-tools article
[ARTICLE · art-53142] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Tracker – a lightweight self-hosted document system for agents in Go

A developer released Tracker, a lightweight self-hosted document system for coding agents built in Go, using Postgres for indexing and coordination with S3 or local file storage. The tool provides leases, versioned writes, and a task queue to prevent conflicts when multiple agents edit documents simultaneously.

read6 min views1 publishedJul 9, 2026
Tracker – a lightweight self-hosted document system for agents in Go
Image: source

A tiny, coordinated, document store for a fleet of coding agents. Postgres holds the index + lease/coordination state; content blobs live in local files or RustFS (S3). Single static Go binary, low footprint, reachable by agents over the network (e.g. LAN/Tailscale/ZeroTier).

mcp/tracker_mcp.py

is an MCP server (a self-contained uv

script — no install) that exposes tracker to any coding agent: search_docs

, list_tags

, list_folios

, get_folio

, read_doc

, who_is_editing

, create_doc

, create_folio

, update_doc

(lease + version-check + release, for you), retag

(tags/metadata without a content rewrite), list_actors

, and the task tools. Configure per agent via env: TRACKER_URL

, TRACKER_ACTOR

(the agent's identity, stamped on writes), TRACKER_TOKEN

(only if API_TOKENS

is set).

Register with Claude Code:

claude mcp add tracker --scope user \
  --env TRACKER_URL=http://127.0.0.1:8080 --env TRACKER_ACTOR=claude-code \
  -- uv run --quiet --script /path/to/tracker/mcp/tracker_mcp.py

skills/tracker/SKILL.md

is the matching Claude Code skill (copy to ~/.claude/skills/tracker/

) describing when/how to consult tracker.

Agents need a shared source of truth and a way to see if a doc is already being written by another agent. This is a database problem, not a knowledge-app problem — so: Postgres + S3/Files

Leases, not advisory locks. Adoc_locks

row 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 undersha256/<hash>

(immutable, deduped) in either a local directory (STORAGE_TYPE=file

) or an S3 bucket (STORAGE_TYPE=s3

). Agents fetch them via a presigned URL or direct local URL.Task queue.tasks

withFOR UPDATE SKIP LOCKED

claiming — no two agents grab the same task.

Both Postgres and the service run via Docker Compose (no sudo needed). The tracker

container uses host networking, so it binds the loopback/LAN/Tailscale/ZeroTier IPs in LISTEN_ADDR

and reaches Postgres (and optionally S3) on the host.

cp .env.example .env          # fill in secrets (e.g. STORAGE_TYPE=file) + set API_TOKENS
docker compose up -d          # starts pgvector Postgres + tracker

Ops (the Makefile stamps the version from git into the binary):

make deploy                            # rebuild image w/ version + restart (no sudo)
make version                           # show the version that would be embedded
docker compose logs -f tracker         # logs
curl http://127.0.0.1:8080/version     # version the running binary reports

The version is git describe --tags --always --dirty

— logged at startup, served at /version

, and recorded in each backup's manifest.json

.

For local dev without a container: make build && set -a && . ./.env && set +a && ./tracker

.

Method Path Purpose
GET /healthz · /version · /openapi.yaml · /llms.txt
health, version, spec, agent index
POST · GET /docs
create (content seeds v1); list/search (?q=&mode=&kind=&tag=&view=&limit=&offset= )
GET · PUT · PATCH /docs/{id}
read {document,content_url,lock} ; write content (lease + If-Match ); relabel tags/metadata (no lease, no version bump)
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 /tasks · /tasks/claim · /tasks/{id}/complete
task queue
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).

A folio is a little collection of related documents (think: a GitHub gist). 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

; only /raw

and /lock

for those still need the /folios/{slug}/files/…

route or the UUID.

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

).

On a trusted (non-internet) network X-Actor

is self-asserted attribution, not authenticated identity. Set API_TOKENS

and bind actor→token if you need it to be tamper-proof.

State lives in two places that must be captured together: Postgres (the index) and the blobs (the content). One self-contained tarball holds both — db.dump

  • blobs/

  • manifest.json

. That tarball is the portable unit; "R2 vs S3 vs a local directory" is just where you keep it.

scripts/backup.sh                 # -> ./backups/tracker-backup-<ts>.tar.gz
scripts/backup.sh --upload        # also push to R2/S3 (set BACKUP_S3_* in .env)

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 service

The backup dumps Postgres first, then copies blobs — and since writes are blob-first, every content_key

in the dump is guaranteed to have its blob, so the tarball is always internally consistent. 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

(point S3_*

/DATABASE_URL

at that host's RustFS+Postgres), 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

It 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.

Running in "production" (lol). I've been using it heavily for several weeks. Known follow-ups:

  • pre-check lease/version before blob upload (rejected writes can leave GC-able orphans)
  • pgvector semantic search
  • scheduled backups
  • orphan/expired-lease GC
  • CI/CD, more pacakaging, etc.
  • Even simpler example MCP/skill usage

PRs welcome but I can't promise I'll get to them!

── more in #developer-tools 4 stories · sorted by recency
── more on @postgres 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/tracker-a-lightweigh…] indexed:0 read:6min 2026-07-09 ·