Self-hosted, Dolt-free task tracker (beads replacement) — REST + MCP + CLI
See the codeA self-hosted, Dolt-free replacement for beads —
one Go binary that is the single source of truth for multi-agent task tracking, reachable by
Claude Code web over a Tailscale Funnel. Same browser UI, a bd-compatible CLI, an MCP server,
and a REST API — all generated from one command registry.
beads stores issues in an embedded Dolt database and syncs them through git refs. That layer breaks
constantly and is fragile under concurrent multi-agent use. tasks replaces it with:
modernc.org/sqlite, WAL) — transaction-atomic
claims mean two agents can never claim the same task..beads/issues.jsonl losslessly and keeps exporting
the same format (optionally git-committed) as an off-machine backup. ┌──────────────── tasksd (one process) ────────────────┐
Claude Code web ──▶│ /mcp (MCP) ┐ │
tasks CLI / bd ──▶│ /api/v1/* (REST) ┼─▶ internal/api registry ─▶ core ─▶ │ SQLite (source of truth)
browser UI ──▶│ /api/issues (UI) ┘ (single source) │ │
│ web/static (embedded UI) │ ▼
└───────────────────────────────────────────────────────┘ issues.jsonl (+git backup)
Every command (ready, show, list, create, update, claim, close, dep, comment) is
declared once in internal/api/ops.go. The HTTP routes, MCP tools, and CLI subcommands are all
generated from that registry by reflection, so a field added there appears on every surface.
# 1. Build (produces bin/tasksd, bin/tasks, and a bd -> tasks symlink)
make build
# 2. Import your existing beads data (SQLite becomes the source of truth)
make import # imports ../forge-crafting-intepreters/.beads/issues.jsonl
# 3. Serve + expose publicly for Claude Code web
export TASKS_TOKEN=$(openssl rand -hex 32)
./scripts/run.sh # builds, serves on :7842, runs `tailscale funnel`
Open the UI at http://127.0.0.1:7842/. To authenticate the browser once behind the funnel, visit
https://<funnel-host>/auth?token=<TASKS_TOKEN> (sets an httpOnly cookie).
cp .env.example .env # set TASKS_TOKEN=$(openssl rand -hex 32)
docker compose up -d # static, non-root, read-only rootfs, /data volume
Dockerfile; docker-compose.yml wires a /data
volume, healthcheck, and env. Prebuilt images publish to ghcr.io/<owner>/tasks.deploy/tasksd.service runs it as a locked-down system service
(ProtectSystem=strict, NoNewPrivileges, MemoryDenyWriteExecute, …).make funnel exposes the port publicly over HTTPS; make serve-tailnet keeps it tailnet-only.Everything is configurable via TASKS_* env vars or the matching --flag
(flags override env override defaults). See .env.example for the full,
documented list. Highlights:
| Env | Flag | Default | Purpose |
|---|---|---|---|
TASKS_ADDR | --addr | 127.0.0.1:7842 | listen address |
TASKS_DB | --db | data/tasks.db | SQLite path |
TASKS_TOKEN / TASKS_TOKEN_FILE | --token / --token-file | — | bearer token |
TASKS_LOG_FORMAT | --log-format | text | text or json |
TASKS_RATE_LIMIT | --rate-limit | 20 | per-IP req/s (0 disables) |
TASKS_MAX_BODY_BYTES | --max-body-bytes | 1048576 | request body cap |
TASKS_EXPORT | --export | — | mirror store to JSONL on change |
TASKS_CORS_ORIGINS | --cors-origins | — | allowed CORS origins |
tasksd version prints build metadata; tasksd import/export are one-shot
data commands.
slog), text or json, with per-request access
logs carrying a request id (X-Request-Id, honored inbound).GET /healthz (liveness), GET /readyz
(DB ping), GET /version, GET /metrics (Prometheus text).X-Frame-Options, …),
per-IP token-bucket rate limiting, request body-size limits, optional CORS.SECURITY.md for the self-hosting hardening checklist.By default the CLI is a client —
tasksdmust be running. Start it first (make serve,./bin/tasksd, Docker, or the systemd/launchd service indeploy/). If the CLI can't reach a tasks server (or hits a different server on the port) it now says so explicitly instead of failing cryptically. On macOS,deploy/com.tasks.tasksd.plistkeeps it running via launchd. To skip the server entirely for single-user work, see Local mode below.
The CLI talks to the server over HTTP. Point it at the server and authenticate:
export TASKS_URL=http://127.0.0.1:7842 # or your funnel URL
export TASKS_TOKEN=<token>
tasks ready # claimable work
tasks show <id> # details
tasks create "Fix X" -p 1 -t bug
tasks update <id> --claim # atomically claim
tasks close <id> -r "done"
tasks dep <blocked> <blocker>
tasks --json ready # raw JSON (matches the REST shape)
Symlinking tasks as bd keeps existing beads workflows (bd ready, bd prime, hooks) working
unchanged — see make install.
For single-user work you can skip tasksd entirely and have the CLI open the SQLite
database in-process — the same engine the server runs, minus the HTTP hop. Set a db
path (via --db or TASKS_DB) and the CLI switches from remote to local mode:
export TASKS_DB=data/tasks.db # presence of a db path selects local mode
export TASKS_ACTOR=$USER # audit actor for local writes (claim/comment/close)
tasks ready
tasks create "Fix X" -p 1 -t bug
tasks update <id> --claim
tasks verify <id> # runs gate commands locally, records via core
tasks close <id> -r "done"
Same commands, same output. tasks --db <path> ready works per-invocation too. A
brand-new db has no id prefix to derive, so set one once with --prefix/TASKS_PREFIX
(e.g. tasks --db new.db --prefix proj create "…"); an existing db already stores its own.
Want the browser board over a local db? The UI lives in tasksd, not the CLI, so run:
tasks ui # or: tasks --db data/tasks.db ui
tasks ui finds the tasksd binary (next to tasks, or on PATH), starts it against the
resolved db on a free port, opens your browser, and shuts it down on Ctrl-C. Flags:
--host, --port, --token, --no-open.
Reach it from another machine by binding all interfaces (it prints the LAN URL to visit):
tasks ui --host 0.0.0.0 # open on your network (no auth — trusted LANs only)
tasks ui --host 0.0.0.0 --token <tok> # require a token; visit the printed /auth?token= URL once
A non-loopback bind without a token passes --allow-no-auth to tasksd and warns; the board is
then open to anyone who can reach the port.
Notes:
tasksd on the same db (SQLite WAL arbitrates),
but heavy simultaneous writes from both can hit a busy timeout. For a shared board, prefer
one tasksd and point clients at it.--export) is a server feature; local writes bypass it until a
tasksd next runs against the db.Short ids: any command that takes a task id accepts the short form (without the project prefix) —
tasks show w7t0 resolves to tasks-w7t0, tasks update w7t0.1 --claim, etc. A literal full-id
match always wins; the prefix is only prepended when the bare id doesn't exist.
The server exposes streamable-HTTP MCP at /mcp, guarded by the same bearer token. Register it in
Claude Code web with your funnel URL (https://<host>/mcp) and an Authorization: Bearer <token>
header. Tools: ready, show, list, create, update, claim, close, dep, comment.
The server binds 127.0.0.1 and is fronted by tailscale funnel (public HTTPS). Because the funnel
is world-reachable, set TASKS_TOKEN; requests then require Authorization: Bearer <token> (CLI,
MCP, curl) or the tasks_token cookie (browser, via /auth?token=). With no token the server runs
open (dev only) and logs a warning. For tailnet-only access instead of public, use make serve-tailnet.
Pass --export <path> (and optionally --git / --git-push) so the server mirrors the store to a
beads-format issues.jsonl after each change (debounced), giving off-machine recovery and keeping
any file-based tooling working. tasksd export --db … --out … does a one-shot export.
make test # run all tests
make cover # total coverage across packages
make cover-html # coverage.html report
make vet
| CLI / MCP tool | REST | Description |
|---|---|---|
ready | GET /api/v1/ready | Claimable work (open, unblocked), priority-ordered |
list | GET /api/v1/tasks | Filter by status/type/assignee/label |
search | GET /api/v1/search | Fuzzy text search (fzf/fuse.js-style ranking), best matches first |
tree | GET /api/v1/tree | A task's subtree (id optional — omit to render the whole forest) |
show | GET /api/v1/tasks/{id} | Full task details |
create | POST /api/v1/tasks | Create a task (id minted) |
update | PATCH /api/v1/tasks/{id} | Update fields / --claim |
claim | POST /api/v1/tasks/{id}/claim | Atomically claim |
close | POST /api/v1/tasks/{id}/close | Close with reason |
dep | POST /api/v1/deps | Add a dependency |
comment | POST /api/v1/tasks/{id}/comments | Add a comment |
UI-compatibility endpoints (GET /api/issues, GET /api/meta, POST /api/pull) mirror the old
Python beads_ui server so the existing frontend works unchanged; /api/pull is a no-op (there is
no Dolt to pull).
ready semanticsready returns open tasks with no unclosed blocks blocker, priority- then id-ordered.
Parent-child links are treated as containment, not as blockers — beads inconsistently hides a child
when its parent is open, which can bury workable leaf tasks; tasks does not replicate that quirk.
The raw dependency data is imported untouched; only this readiness view differs.
54 commits
Go
49.5%
Svelte
29.7%
TypeScript
15.7%
CSS
2.6%
Self-hosted, Dolt-free task tracker (beads replacement) — REST + MCP + CLI
See the codeA self-hosted, Dolt-free replacement for beads —
one Go binary that is the single source of truth for multi-agent task tracking, reachable by
Claude Code web over a Tailscale Funnel. Same browser UI, a bd-compatible CLI, an MCP server,
and a REST API — all generated from one command registry.
beads stores issues in an embedded Dolt database and syncs them through git refs. That layer breaks
constantly and is fragile under concurrent multi-agent use. tasks replaces it with:
modernc.org/sqlite, WAL) — transaction-atomic
claims mean two agents can never claim the same task..beads/issues.jsonl losslessly and keeps exporting
the same format (optionally git-committed) as an off-machine backup. ┌──────────────── tasksd (one process) ────────────────┐
Claude Code web ──▶│ /mcp (MCP) ┐ │
tasks CLI / bd ──▶│ /api/v1/* (REST) ┼─▶ internal/api registry ─▶ core ─▶ │ SQLite (source of truth)
browser UI ──▶│ /api/issues (UI) ┘ (single source) │ │
│ web/static (embedded UI) │ ▼
└───────────────────────────────────────────────────────┘ issues.jsonl (+git backup)
Every command (ready, show, list, create, update, claim, close, dep, comment) is
declared once in internal/api/ops.go. The HTTP routes, MCP tools, and CLI subcommands are all
generated from that registry by reflection, so a field added there appears on every surface.
# 1. Build (produces bin/tasksd, bin/tasks, and a bd -> tasks symlink)
make build
# 2. Import your existing beads data (SQLite becomes the source of truth)
make import # imports ../forge-crafting-intepreters/.beads/issues.jsonl
# 3. Serve + expose publicly for Claude Code web
export TASKS_TOKEN=$(openssl rand -hex 32)
./scripts/run.sh # builds, serves on :7842, runs `tailscale funnel`
Open the UI at http://127.0.0.1:7842/. To authenticate the browser once behind the funnel, visit
https://<funnel-host>/auth?token=<TASKS_TOKEN> (sets an httpOnly cookie).
cp .env.example .env # set TASKS_TOKEN=$(openssl rand -hex 32)
docker compose up -d # static, non-root, read-only rootfs, /data volume
Dockerfile; docker-compose.yml wires a /data
volume, healthcheck, and env. Prebuilt images publish to ghcr.io/<owner>/tasks.deploy/tasksd.service runs it as a locked-down system service
(ProtectSystem=strict, NoNewPrivileges, MemoryDenyWriteExecute, …).make funnel exposes the port publicly over HTTPS; make serve-tailnet keeps it tailnet-only.Everything is configurable via TASKS_* env vars or the matching --flag
(flags override env override defaults). See .env.example for the full,
documented list. Highlights:
| Env | Flag | Default | Purpose |
|---|---|---|---|
TASKS_ADDR | --addr | 127.0.0.1:7842 | listen address |
TASKS_DB | --db | data/tasks.db | SQLite path |
TASKS_TOKEN / TASKS_TOKEN_FILE | --token / --token-file | — | bearer token |
TASKS_LOG_FORMAT | --log-format | text | text or json |
TASKS_RATE_LIMIT | --rate-limit | 20 | per-IP req/s (0 disables) |
TASKS_MAX_BODY_BYTES | --max-body-bytes | 1048576 | request body cap |
TASKS_EXPORT | --export | — | mirror store to JSONL on change |
TASKS_CORS_ORIGINS | --cors-origins | — | allowed CORS origins |
tasksd version prints build metadata; tasksd import/export are one-shot
data commands.
slog), text or json, with per-request access
logs carrying a request id (X-Request-Id, honored inbound).GET /healthz (liveness), GET /readyz
(DB ping), GET /version, GET /metrics (Prometheus text).X-Frame-Options, …),
per-IP token-bucket rate limiting, request body-size limits, optional CORS.SECURITY.md for the self-hosting hardening checklist.By default the CLI is a client —
tasksdmust be running. Start it first (make serve,./bin/tasksd, Docker, or the systemd/launchd service indeploy/). If the CLI can't reach a tasks server (or hits a different server on the port) it now says so explicitly instead of failing cryptically. On macOS,deploy/com.tasks.tasksd.plistkeeps it running via launchd. To skip the server entirely for single-user work, see Local mode below.
The CLI talks to the server over HTTP. Point it at the server and authenticate:
export TASKS_URL=http://127.0.0.1:7842 # or your funnel URL
export TASKS_TOKEN=<token>
tasks ready # claimable work
tasks show <id> # details
tasks create "Fix X" -p 1 -t bug
tasks update <id> --claim # atomically claim
tasks close <id> -r "done"
tasks dep <blocked> <blocker>
tasks --json ready # raw JSON (matches the REST shape)
Symlinking tasks as bd keeps existing beads workflows (bd ready, bd prime, hooks) working
unchanged — see make install.
For single-user work you can skip tasksd entirely and have the CLI open the SQLite
database in-process — the same engine the server runs, minus the HTTP hop. Set a db
path (via --db or TASKS_DB) and the CLI switches from remote to local mode:
export TASKS_DB=data/tasks.db # presence of a db path selects local mode
export TASKS_ACTOR=$USER # audit actor for local writes (claim/comment/close)
tasks ready
tasks create "Fix X" -p 1 -t bug
tasks update <id> --claim
tasks verify <id> # runs gate commands locally, records via core
tasks close <id> -r "done"
Same commands, same output. tasks --db <path> ready works per-invocation too. A
brand-new db has no id prefix to derive, so set one once with --prefix/TASKS_PREFIX
(e.g. tasks --db new.db --prefix proj create "…"); an existing db already stores its own.
Want the browser board over a local db? The UI lives in tasksd, not the CLI, so run:
tasks ui # or: tasks --db data/tasks.db ui
tasks ui finds the tasksd binary (next to tasks, or on PATH), starts it against the
resolved db on a free port, opens your browser, and shuts it down on Ctrl-C. Flags:
--host, --port, --token, --no-open.
Reach it from another machine by binding all interfaces (it prints the LAN URL to visit):
tasks ui --host 0.0.0.0 # open on your network (no auth — trusted LANs only)
tasks ui --host 0.0.0.0 --token <tok> # require a token; visit the printed /auth?token= URL once
A non-loopback bind without a token passes --allow-no-auth to tasksd and warns; the board is
then open to anyone who can reach the port.
Notes:
tasksd on the same db (SQLite WAL arbitrates),
but heavy simultaneous writes from both can hit a busy timeout. For a shared board, prefer
one tasksd and point clients at it.--export) is a server feature; local writes bypass it until a
tasksd next runs against the db.Short ids: any command that takes a task id accepts the short form (without the project prefix) —
tasks show w7t0 resolves to tasks-w7t0, tasks update w7t0.1 --claim, etc. A literal full-id
match always wins; the prefix is only prepended when the bare id doesn't exist.
The server exposes streamable-HTTP MCP at /mcp, guarded by the same bearer token. Register it in
Claude Code web with your funnel URL (https://<host>/mcp) and an Authorization: Bearer <token>
header. Tools: ready, show, list, create, update, claim, close, dep, comment.
The server binds 127.0.0.1 and is fronted by tailscale funnel (public HTTPS). Because the funnel
is world-reachable, set TASKS_TOKEN; requests then require Authorization: Bearer <token> (CLI,
MCP, curl) or the tasks_token cookie (browser, via /auth?token=). With no token the server runs
open (dev only) and logs a warning. For tailnet-only access instead of public, use make serve-tailnet.
Pass --export <path> (and optionally --git / --git-push) so the server mirrors the store to a
beads-format issues.jsonl after each change (debounced), giving off-machine recovery and keeping
any file-based tooling working. tasksd export --db … --out … does a one-shot export.
make test # run all tests
make cover # total coverage across packages
make cover-html # coverage.html report
make vet
| CLI / MCP tool | REST | Description |
|---|---|---|
ready | GET /api/v1/ready | Claimable work (open, unblocked), priority-ordered |
list | GET /api/v1/tasks | Filter by status/type/assignee/label |
search | GET /api/v1/search | Fuzzy text search (fzf/fuse.js-style ranking), best matches first |
tree | GET /api/v1/tree | A task's subtree (id optional — omit to render the whole forest) |
show | GET /api/v1/tasks/{id} | Full task details |
create | POST /api/v1/tasks | Create a task (id minted) |
update | PATCH /api/v1/tasks/{id} | Update fields / --claim |
claim | POST /api/v1/tasks/{id}/claim | Atomically claim |
close | POST /api/v1/tasks/{id}/close | Close with reason |
dep | POST /api/v1/deps | Add a dependency |
comment | POST /api/v1/tasks/{id}/comments | Add a comment |
UI-compatibility endpoints (GET /api/issues, GET /api/meta, POST /api/pull) mirror the old
Python beads_ui server so the existing frontend works unchanged; /api/pull is a no-op (there is
no Dolt to pull).
ready semanticsready returns open tasks with no unclosed blocks blocker, priority- then id-ordered.
Parent-child links are treated as containment, not as blockers — beads inconsistently hides a child
when its parent is open, which can bury workable leaf tasks; tasks does not replicate that quirk.
The raw dependency data is imported untouched; only this readiness view differs.
54 commits
Go
49.5%
Svelte
29.7%
TypeScript
15.7%
CSS
2.6%