tristanmatthias/tasks

Self-hosted, Dolt-free task tracker (beads replacement) — REST + MCP + CLI

Go

0

54 commits

updated Sep 15, 2026

See the code

See what people are saying

SourceMessageScoreDate

Git-bug: Distributed, offline-first bug tracker embedded in Git

To be fair I was also unhappy with current tooling and made my own [0] in a day or so. I think this tool would be a hard thing to monetise unfortunately in 2026. It’s just… so easy to make stuff now. [0] https://github.com/tristanmatthias/tasks

0

Sep 26, 2026

README

tasks

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

Why

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:

  • SQLite as the single source of truth (pure-Go modernc.org/sqlite, WAL) — transaction-atomic claims mean two agents can never claim the same task.
  • One server, many surfaces: the browser UI, REST, MCP (for Claude Code web), and the CLI all talk to the same process. No Dolt, no ref sync, no "which copy is right".
  • Tighter control than beads: every write is validated (status/type/priority enums, required fields) uniformly across all surfaces.
  • beads-compatible data: imports an existing .beads/issues.jsonl losslessly and keeps exporting the same format (optionally git-committed) as an off-machine backup.

Architecture

                    ┌──────────────── 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.

Quick start

# 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).

Or with Docker

cp .env.example .env          # set TASKS_TOKEN=$(openssl rand -hex 32)
docker compose up -d          # static, non-root, read-only rootfs, /data volume

Deploy

  • Docker — a static multi-arch image (scratch base, non-root uid, read-only rootfs) is built from the Dockerfile; docker-compose.yml wires a /data volume, healthcheck, and env. Prebuilt images publish to ghcr.io/<owner>/tasks.
  • systemd — deploy/tasksd.service runs it as a locked-down system service (ProtectSystem=strict, NoNewPrivileges, MemoryDenyWriteExecute, …).
  • Tailscale — make funnel exposes the port publicly over HTTPS; make serve-tailnet keeps it tailnet-only.

Configuration

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:

EnvFlagDefaultPurpose
TASKS_ADDR--addr127.0.0.1:7842listen address
TASKS_DB--dbdata/tasks.dbSQLite path
TASKS_TOKEN / TASKS_TOKEN_FILE--token / --token-file—bearer token
TASKS_LOG_FORMAT--log-formattexttext or json
TASKS_RATE_LIMIT--rate-limit20per-IP req/s (0 disables)
TASKS_MAX_BODY_BYTES--max-body-bytes1048576request 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.

Observability & hardening

  • Structured logging (slog), text or json, with per-request access logs carrying a request id (X-Request-Id, honored inbound).
  • Ops endpoints (unauthenticated): GET /healthz (liveness), GET /readyz (DB ping), GET /version, GET /metrics (Prometheus text).
  • Middleware: panic recovery, security headers (CSP, X-Frame-Options, …), per-IP token-bucket rate limiting, request body-size limits, optional CORS.
  • Graceful shutdown on SIGINT/SIGTERM with a configurable drain timeout.
  • See SECURITY.md for the self-hosting hardening checklist.

Using the CLI

By default the CLI is a client — tasksd must be running. Start it first (make serve, ./bin/tasksd, Docker, or the systemd/launchd service in deploy/). 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.plist keeps 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.

Local mode (no server)

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:

  • No auth — anyone who can read the db file can mutate it. That's the point (local, single-user); don't expose the file to untrusted users.
  • Concurrency is safe with a running 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.
  • JSONL/git backup (--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.

MCP for Claude Code web

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.

Auth

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.

Backup

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.

Development

make test          # run all tests
make cover         # total coverage across packages
make cover-html    # coverage.html report
make vet

Command reference

CLI / MCP toolRESTDescription
readyGET /api/v1/readyClaimable work (open, unblocked), priority-ordered
listGET /api/v1/tasksFilter by status/type/assignee/label
searchGET /api/v1/searchFuzzy text search (fzf/fuse.js-style ranking), best matches first
treeGET /api/v1/treeA task's subtree (id optional — omit to render the whole forest)
showGET /api/v1/tasks/{id}Full task details
createPOST /api/v1/tasksCreate a task (id minted)
updatePATCH /api/v1/tasks/{id}Update fields / --claim
claimPOST /api/v1/tasks/{id}/claimAtomically claim
closePOST /api/v1/tasks/{id}/closeClose with reason
depPOST /api/v1/depsAdd a dependency
commentPOST /api/v1/tasks/{id}/commentsAdd 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).

Notes on ready semantics

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

Contributors

tristanMatthias

54 commits

tristanmatthias/tasks

Self-hosted, Dolt-free task tracker (beads replacement) — REST + MCP + CLI

Go

0

54 commits

updated Sep 15, 2026

See the code

See what people are saying

SourceMessageScoreDate

Git-bug: Distributed, offline-first bug tracker embedded in Git

To be fair I was also unhappy with current tooling and made my own [0] in a day or so. I think this tool would be a hard thing to monetise unfortunately in 2026. It’s just… so easy to make stuff now. [0] https://github.com/tristanmatthias/tasks

0

Sep 26, 2026

README

tasks

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

Why

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:

  • SQLite as the single source of truth (pure-Go modernc.org/sqlite, WAL) — transaction-atomic claims mean two agents can never claim the same task.
  • One server, many surfaces: the browser UI, REST, MCP (for Claude Code web), and the CLI all talk to the same process. No Dolt, no ref sync, no "which copy is right".
  • Tighter control than beads: every write is validated (status/type/priority enums, required fields) uniformly across all surfaces.
  • beads-compatible data: imports an existing .beads/issues.jsonl losslessly and keeps exporting the same format (optionally git-committed) as an off-machine backup.

Architecture

                    ┌──────────────── 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.

Quick start

# 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).

Or with Docker

cp .env.example .env          # set TASKS_TOKEN=$(openssl rand -hex 32)
docker compose up -d          # static, non-root, read-only rootfs, /data volume

Deploy

  • Docker — a static multi-arch image (scratch base, non-root uid, read-only rootfs) is built from the Dockerfile; docker-compose.yml wires a /data volume, healthcheck, and env. Prebuilt images publish to ghcr.io/<owner>/tasks.
  • systemd — deploy/tasksd.service runs it as a locked-down system service (ProtectSystem=strict, NoNewPrivileges, MemoryDenyWriteExecute, …).
  • Tailscale — make funnel exposes the port publicly over HTTPS; make serve-tailnet keeps it tailnet-only.

Configuration

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:

EnvFlagDefaultPurpose
TASKS_ADDR--addr127.0.0.1:7842listen address
TASKS_DB--dbdata/tasks.dbSQLite path
TASKS_TOKEN / TASKS_TOKEN_FILE--token / --token-file—bearer token
TASKS_LOG_FORMAT--log-formattexttext or json
TASKS_RATE_LIMIT--rate-limit20per-IP req/s (0 disables)
TASKS_MAX_BODY_BYTES--max-body-bytes1048576request 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.

Observability & hardening

  • Structured logging (slog), text or json, with per-request access logs carrying a request id (X-Request-Id, honored inbound).
  • Ops endpoints (unauthenticated): GET /healthz (liveness), GET /readyz (DB ping), GET /version, GET /metrics (Prometheus text).
  • Middleware: panic recovery, security headers (CSP, X-Frame-Options, …), per-IP token-bucket rate limiting, request body-size limits, optional CORS.
  • Graceful shutdown on SIGINT/SIGTERM with a configurable drain timeout.
  • See SECURITY.md for the self-hosting hardening checklist.

Using the CLI

By default the CLI is a client — tasksd must be running. Start it first (make serve, ./bin/tasksd, Docker, or the systemd/launchd service in deploy/). 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.plist keeps 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.

Local mode (no server)

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:

  • No auth — anyone who can read the db file can mutate it. That's the point (local, single-user); don't expose the file to untrusted users.
  • Concurrency is safe with a running 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.
  • JSONL/git backup (--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.

MCP for Claude Code web

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.

Auth

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.

Backup

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.

Development

make test          # run all tests
make cover         # total coverage across packages
make cover-html    # coverage.html report
make vet

Command reference

CLI / MCP toolRESTDescription
readyGET /api/v1/readyClaimable work (open, unblocked), priority-ordered
listGET /api/v1/tasksFilter by status/type/assignee/label
searchGET /api/v1/searchFuzzy text search (fzf/fuse.js-style ranking), best matches first
treeGET /api/v1/treeA task's subtree (id optional — omit to render the whole forest)
showGET /api/v1/tasks/{id}Full task details
createPOST /api/v1/tasksCreate a task (id minted)
updatePATCH /api/v1/tasks/{id}Update fields / --claim
claimPOST /api/v1/tasks/{id}/claimAtomically claim
closePOST /api/v1/tasks/{id}/closeClose with reason
depPOST /api/v1/depsAdd a dependency
commentPOST /api/v1/tasks/{id}/commentsAdd 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).

Notes on ready semantics

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

Contributors

tristanMatthias

54 commits

Languages

Go

49.5%

Svelte

29.7%

TypeScript

15.7%

CSS

2.6%