Mission control for the work you hand to AI coding agents.
A local-first kanban for delegating tasks to AI coding agents. Queue a prompt, hand it off, watch what's running with live timers, review the result, and ship — all in your browser. Nothing leaves your machine.
The manual board is agent-agnostic — the
agentlabel on a card is free text, so you can track work you hand to any tool. The automated orchestration layer (dispatcher, PR flow, review gate, session-resume) is built specifically around Claude Code (claude -p … --output-format json/--resume); pointing it at another CLI means giving up the Claude-specific pieces (JSON result parsing, independent review gate, revise-in-place session resume).
- Why
- Features
- Tech stack
- Getting started
- Architecture
- Testing
- Design system
- Agent orchestration
- Privacy
- Contributing
- Code of Conduct
- License
If you drive more than one AI agent at a time, the bottleneck stops being writing prompts and becomes tracking them: what's queued, what's actually running, what's waiting on your review, and what you can copy-paste again next week. Agent Task Board is a focused board for exactly that loop — prompt-first cards in four lanes that mirror the delegation lifecycle:
| Lane | Meaning |
|---|---|
| Queued | Drafted prompts, not yet handed off |
| Running | Handed to an agent, work in flight (live elapsed timer) |
| Review | Agent finished, needs your eyes |
| Done | Reviewed, merged, shipped (shows time-to-done) |
- Prompt-first cards — each card's payload is the reusable prompt you give the agent, shown in monospace with one-click copy.
- Four-lane delegation flow — Queued → Running → Review → Done, with zero-padded counts and a per-lane status colour.
- Drag-and-drop — reorder within a lane or move across lanes (pointer + full keyboard support via dnd-kit).
- Move buttons —
‹ ›on every card for quick, touch-friendly lane changes. - Live timers — Running cards tick up in real time; Done cards show how long the work took (tabular figures, no jitter).
- PR-aware cards — when a task opens a pull request, the card surfaces a one-click PR link field; result text is expandable (show more/less) with clickable links.
- Independent review gate (opt-in) — before a PR opens, a fresh agent that didn't write the code reviews the diff alongside the repo's own checks (
lint/typecheck/test) and iterates fixes until the change clears a confidence gate — so you only ever review PRs that already passed. See Independent review gate. - Send a PR back to fix (revise + resume) — a Review card whose PR has failing CI or a merge conflict can be sent back for another pass that updates the same PR (never opens a new one): from the card's "Send back for revision" button or Telegram
/revise <id> <fix>. The dispatcher reopens the existing branch, resumes the original agent session (so it keeps its implementation context), merges the latest base, and force-free pushes the fix. See Revise a PR. - Terminal-state cleanup — the merge-watcher advances a merged PR's card to Done and deletes a card whose PR you closed without merging, pruning the stale
atb/<id>branch either way — so rejected work and dead branches don't pile up. - Compact Done lane + archive — Done cards collapse to a one-line summary; archive finished cards behind a per-lane reveal (with restore) so the board never bloats.
- Search — filter across titles, prompts, agents, tags, and notes instantly.
- Local-first by default — the board lives in
localStorage. No account, no telemetry. - Agent orchestration (opt-in) — switch to a server-backed live board and let real agents work the queue: an MCP server to enqueue by talking to an agent, a dispatcher that claims tasks (with optional concurrency), routes each by
agentlabel and a/reposlash command (orrepo:tag) to the right repo, and — for code tasks — opens a pull request automatically in an isolatedgit worktreebefore the card lands in Review. A Telegram bot is your control surface. See Agent orchestration. - Run insights (live board) — an in-board Insights panel (pulse icon in the header, live mode only) reads the dispatcher's run history: total runs, success rate, average duration, total agent cost (USD), per-status / per-repo / per-agent breakdowns, and a recent-runs list with review scores, per-run cost, and PR links. It's the UI over the same data as the
npm run historyCLI. See Run insights. - Per-task cost — the dispatcher captures each run's
total_cost_usdfrom Claude Code's JSON output (implementer plus every review-gate reviewer/fixer run) and writes it back: a cost chip on the card, a Total cost KPI in the Insights panel, and atotal costline innpm run history. So the review gate's extra spend is finally visible, not just warned about. - Which model ran it — alongside the cost chip, the card shows the model that implemented the task (e.g.
Sonnet 5,Opus 4.8), parsed from Claude Code'smodelUsageoutput. Pin a route to a specific model with--modelinroutes.json(see Routing); left unpinned, it just reports whatever yourclaudeCLI's configured default is. Tasks completed before this feature (or before a control-plane restart picks up a routing change) won't show a model chip — it isn't backfilled. - Automated dependency updates — Dependabot opens weekly grouped PRs for npm and GitHub Actions bumps (majors stay ungrouped for individual review).
- Export / Import — back up or move your board as a JSON file.
- Undo — deletes and board-clears are undoable from a toast.
- Keyboard shortcuts —
nto add a task,/to focus search,⌘↵to save,Escto close. - Accessible & responsive — labelled controls, visible focus rings, reduced-motion support, and a horizontal-scroll layout on mobile.
- Next.js 16 (App Router, Turbopack) + React 19
- TypeScript
- Tailwind CSS v4
- dnd-kit for drag-and-drop
- Vitest + Testing Library for the core logic
- Visual language vendored from dragonfly-ds — a dark, editorial design system (black canvas, hairline grid, single orange-red accent, three-face type system)
npm install
npm run dev
# open http://localhost:3000The board seeds itself with a sample set of tasks on first visit. Clear it (trash icon) to start from an empty board; Load sample board brings the demo back.
| Command | Description |
|---|---|
npm run dev |
Start the dev server (Turbopack) |
npm run build |
Production build |
npm run start |
Serve the production build |
npm run lint |
ESLint |
npm run typecheck |
tsc --noEmit |
npm run check:agent |
node --check every agent/**/*.mjs (syntax guard for the ESM orchestration scripts) |
npm run test |
Run the Vitest suite |
npm run test:coverage |
Run the Vitest suite with a coverage summary (v8) |
npm run test:watch |
Vitest in watch mode |
npm run agents |
Bring up the whole control plane: board + dispatcher + Telegram bot (details) |
The board is split into pure, framework-free logic and a thin React layer, which keeps the core fully unit-testable.
lib/
types.ts Domain types (Task, Status, BoardState)
board.ts Pure reducer: add / update / delete / move / commitDrag / claimNext / setResult
columns.ts Lane metadata (labels, hints, colours)
time.ts Timer & relative-time formatting
storage.ts localStorage persistence + JSON export/import
seed.ts Sample board
boardEngine.ts Engine contract + EMPTY sentinel + mode flag
localEngine.ts Local-first engine (localStorage)
apiEngine.ts Live engine (server-backed, polls /api/board)
useBoard.ts useSyncExternalStore hook; selects the engine by mode
insights.ts Pure display helpers + types for the Insights panel (/api/history)
server/ store.ts (file-backed, mutex-guarded), auth.ts, parse.ts ← server-only
components/
ds/ Vendored dragonfly-ds primitives (Panel, Text, Button, Rule) + tokens
BoardApp.tsx Orchestrator: state, search, modals, toasts, shortcuts
Board.tsx DndContext + drag logic
TaskCard.tsx The prompt-first card (renders the agent result)
InsightsPanel.tsx Run-history dashboard modal (live mode; polls /api/history)
app/api/ Route Handlers: board, tasks, claim, tasks/[id]/result, history
agent/
launch.mjs One command: board + dispatcher + watcher (`npm run agents`)
dispatcher.mjs Claim → route by agent → run → report (to board + Telegram)
merge-watcher.mjs PR merged → Review→Done; closed → delete card; prunes the branch (polls `gh`)
scout.mjs Improvement scout: scan ~/code, rank ideas, queue the single best (`npm run scout`)
mcp-server.mjs MCP stdio server exposing board tools
telegram-bot.mjs Inbound: messages → queued tasks
launchd/ macOS LaunchAgent installers: whole control plane (persistent) + scout (every 2h)
lib/ api.mjs (board client), telegram.mjs, prs.mjs, routes.mjs (routing + repo/PR helpers), git.mjs (worktree-isolated PR flow), scout.mjs (scan/rank helpers), scout-memory.mjs (cross-run scan ledger), message.mjs
BoardState is modelled as a flat tasks map plus ordered id-lists per column — the canonical multi-container shape — so reorders and cross-lane moves are simple array splices and dnd-kit's arrayMove slots in cleanly. The same pure reducer in board.ts backs both the browser (localEngine) and the server store, so the local and live boards behave identically.
npm run test # 306 tests: reducer (incl. archive + revise send-back), claim/result, storage, time, insights display helpers, server store, agent routing/PR/repo-slug/auto-requeue/revise helpers, review-gate helpers, merge/close + branch-prune PR helpers, /revise + /cancel Telegram helpers, scout scan/rank/propose + incremental-scan memory/backlog helpers, run-history, Telegram message parsing, orphaned-task requeue-on-restart, and the git-flow integration suite (real worktree/PR/revise against a local origin)
npm run typecheck
npm run lint
npm run buildThe reducer (move/commitDrag timestamping, atomic claimNext, setResult, reconcile/repair), storage round-trips, time formatting, and the server store (FIFO + concurrent-claim atomicity) are all covered. The impure git/PR flow (agent/lib/git.mjs) — normally verified by hand — also has an integration suite (agent/lib/git.integration.test.mjs) that drives the real functions against a throwaway repo with a bare local origin (a fake gh on PATH stands in for GitHub): worktree isolation, branching off origin/<base>, the hydration-artifact exclusion that keeps node_modules/.env out of PRs, and the revise merge/conflict path. UI flows (create, move, copy, delete+undo, search, persistence, drag-and-drop) were verified in the browser, and the full agent loop (enqueue → dispatch → run → review) plus the MCP server were verified end-to-end against a live server.
The interface is built on a vendored copy of dragonfly-ds (in components/ds/): a dark editorial system with a black canvas, a faint 10%-white hairline grid, a single orange-red accent (#fa4c14), and a three-face type system (Fraunces / Inter / JetBrains Mono, self-hosted via next/font). Four sparing status hues — one per lane — are layered on top.
100% client-side. Your tasks and prompts live only in your browser's localStorage and are never sent anywhere. Use Export to keep a backup.
In API mode the board is a small HTTP server, open by default (local/trusted-network posture). Setting AGENT_TOKEN switches on uniform bearer-token auth: every /api route — GET/POST/DELETE /api/board, POST /api/tasks, PATCH/DELETE /api/tasks/:id, POST /api/claim, POST /api/tasks/:id/result, POST /api/tasks/:id/cancel — returns 401 without a valid Authorization: Bearer <token> header. Tokens are compared in constant time (timingSafeEqual). The agent-side clients (dispatcher, merge-watcher, MCP server, Telegram bot, control-plane launcher) all read the same AGENT_TOKEN env var and send it automatically.
The browser UI in API mode needs the token too: set NEXT_PUBLIC_AGENT_TOKEN to the same value (e.g. in .env.local). Note that a NEXT_PUBLIC_* var is inlined into the client JavaScript bundle at build time — anyone who can load the UI can read it. AGENT_TOKEN therefore protects the API from other processes and hosts on the network, not from someone you serve the UI to; for real multi-user exposure put the board behind a reverse proxy with proper auth. Leaving AGENT_TOKEN unset keeps the whole API open — the local-first default.
Bind to localhost. npm start runs next start, which listens on 0.0.0.0 — reachable from anywhere on your network. Since the local-first default leaves the API open, prefer npm run start:local (next start -H 127.0.0.1) so the board is only reachable from your own machine. If you do need to expose it beyond localhost, set AGENT_TOKEN and put it behind a reverse proxy with proper auth — don't rely on the open default on a shared network.
Beyond the manual board, Agent Task Board can run a full delegate → dispatch → run → review loop where real agents pull work off the queue. The pieces:
Telegram ──▶ MCP / API ──▶ [ Queue ] ──▶ Dispatcher ──▶ runner (claude -p)
(you) (enqueue) (claim+route) │
▲ ▼
└──────────── "picked up by X" / result ◀────── Review lane (your approval)
- You enqueue — talk to the Telegram bot (or any MCP client via the MCP server, or
POST /api/tasks). Your message becomes a queued task. - The dispatcher claims it atomically (one task → one agent), routes it to the right runner by the task's
agentlabel, and notifies Telegram "🟢 picked up → Claude Code". - The runner executes the prompt (e.g.
claude -p), and the dispatcher posts the output back to the Review lane and to Telegram "✅ done" / "❌ failed". - You review in the board (live mode shows cards move on their own and renders the agent's output on the card) and approve to Done — drag the card, or
PATCH /api/tasks/:id {"status":"done"}/ the MCPmove_task. - Or let it auto-complete. For tasks that open a pull request (label them
commit-push, or have the runner print agithub.com/.../pull/Nurl), the merge-watcher pollsghand moves the card to Done by itself the moment the PR is merged — so "merge to master" is the approval.
One command — bring up the board (in api mode) and the dispatcher, with labelled output and a clean Ctrl-C that stops everything:
npm run agents # board + dispatcher (dry-run) + merge-watcher + inbound bot
npm run agents -- --execute # let the dispatcher actually run runners + open PRs
npm run agents -- --concurrency 3 # run up to 3 tasks at once (each PR task is worktree-isolated)
npm run agents -- --no-telegram # don't run the built-in inbound bot
npm run agents -- --prod # serve a production build (run `npm run build` first)
npm run agents -- --no-board # attach to an already-running board (BOARD_URL)
npm run agents -- --no-watcher # don't run the merge-watcherThe built-in inbound bot is auto-on whenever
TELEGRAM_BOT_TOKENis set — every message becomes a queued task. Pass--no-telegramto disable it (do that when an external front door like hans / telegram-claude-agent owns inbound on the same bot — two pollers on one token409; give the board its own bot token to run both).BOARD_URLis the single port knob: set it to e.g.http://localhost:3738and the board binds that port.
Run persistently (macOS) — keep the whole control plane (board + dispatcher + watcher + bot) alive across logins and crashes with a LaunchAgent; it provides its own board, so nothing else need run (don't also npm run agents by hand — two boards clash on the port). Logs land in .data/controlplane.{out,err}.log:
npm run agents:install # dry-run; add `-- --execute --prod` to run runners off a prod build
npm run agents:uninstallOnce installed, manage it with launchctl (label com.davidcjw.agent-task-board.controlplane) — no reinstall needed. kickstart/bootout/bootstrap/print are subcommands of the built-in /bin/launchctl, not separate binaries (launchctl kickstart …, not a kickstart on your PATH):
LABEL=com.davidcjw.agent-task-board.controlplane
launchctl print "gui/$(id -u)/$LABEL" | grep -E "state =|pid =" # status (running? pid?)
launchctl kickstart -k "gui/$(id -u)/$LABEL" # restart in place
launchctl bootout "gui/$(id -u)/$LABEL" # stop (KeepAlive won't auto-revive a manual bootout)
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/$LABEL.plist # start again from the installed plist
tail -f .data/controlplane.out.log .data/controlplane.err.log # follow logsOr run the pieces by hand:
# 1. run the board in live mode (.env.local)
echo "NEXT_PUBLIC_BOARD_MODE=api" > .env.local
npm run dev # serves the UI + the /api routes
# 2. dispatch agents (dry-run by default — reports the command, runs nothing)
npm run dispatcher # add --execute to actually run runners
# add --once to drain the queue and exit
# 3. (optional) merge-watcher: move Review → Done when a task's PR is merged
npm run watcher # polls `gh` every WATCHER_INTERVAL ms (default 30s)
# 4. (optional) Telegram control surface
TELEGRAM_BOT_TOKEN=… npm run telegram
# 5. (optional) MCP server, so you can enqueue by chatting with an agent
npm run mcp
# 6. (optional) run-history stats — summarize every task the dispatcher finished
npm run history # human summary; add --json for the raw objectRun history. Each time the dispatcher finishes a task (success, failure, or cancel) it appends one JSON line to .data/history.jsonl — id, title, agent, repo, final status, elapsed ms, the review score (if the review gate ran), the run cost in USD (implementer + review-gate runs), and the PR url (if opened). It's best-effort (a failed write never breaks the dispatch loop) and gitignored via the .data/ rule. npm run history reads that log and prints totals, success rate, average duration, total cost, and counts by status / repo / agent; an empty or absent log prints a clean zero-state.
The live board lives in a single JSON file (<BOARD_DATA_DIR>/board.json, defaulting to .data/board.json), so a wipe is one bad write away. Two zero-dep scripts make it recoverable:
npm run backup # snapshot board.json → .data/backups/board-<timestamp>.json
npm run backup -- --keep 50 # keep the newest 50 (default 20; or set BOARD_BACKUP_KEEP)
npm run restore # list available backups
npm run restore -- --latest # atomically restore the newest snapshot over board.json
npm run restore -- --from board-2026-07-05T12-34-56.789Z.json # restore a specific onebackup writes a timestamped copy and prunes to the newest N (default 20, override with --keep <n> or BOARD_BACKUP_KEEP); it exits cleanly with a notice if no board file exists yet. restore refuses a file that doesn't parse to a valid board state, and won't overwrite a non-empty current board without --force; the restore itself is atomic (temp file + rename). Both honour BOARD_DATA_DIR, so pointing them at a custom data dir just works. Backups land under the gitignored .data/.
The same run-history data is also available in the board (live mode only). A pulse icon in the header opens the Insights panel, which polls GET /api/history and shows:
- KPIs — total runs, success rate, average duration, and total agent cost (USD).
- Breakdowns — runs by status (lane-coloured), by repo, and by agent, as ranked mini-bars.
- Recent runs — the latest finished tasks with a status dot, repo, review score (when the review gate ran), run cost, duration, and a one-click PR link.
The panel is a thin read-only view — the numbers come from .data/history.jsonl via summarizeHistory (the same pure helper behind npm run history), so the CLI and the UI never drift. The button only appears on a live (server-backed) board, since the local-first board keeps no server-side history. GET /api/history honours the same AGENT_TOKEN auth as every other route.
Copy .env.example to .env and fill in what you need.
| Method & path | Purpose |
|---|---|
GET /api/board |
Full board (the live UI polls this) |
POST /api/tasks |
Create a task |
PATCH /api/tasks/:id |
Update / move a task |
DELETE /api/tasks/:id |
Delete a task |
POST /api/claim |
Atomically claim the oldest queued task → Running |
POST /api/tasks/:id/result |
Post an agent's result → Review |
GET /api/history |
Run-history summary + recent records (the Insights panel polls this) |
POST /api/reconcile |
Requeue tasks stranded in Running (worker died) → Queued (control plane calls once on boot) |
When AGENT_TOKEN is set, every /api route requires Authorization: Bearer <token> and returns 401 otherwise; when unset the API is open. The browser UI in API mode sends NEXT_PUBLIC_AGENT_TOKEN. See Securing API mode.
The dispatcher routes each claimed task to a runner by its agent label using agent/routes.json (falls back to routes.example.json):
{
"default": { "command": "claude", "args": ["-p", "{prompt}", "--output-format", "json"], "cwd": "{repo}", "pr": true },
"Claude Code": { "command": "claude", "args": ["-p", "{prompt}", "--output-format", "json"], "cwd": "{repo}", "pr": true,
"review": { "iterations": 2, "threshold": 95, "checks": ["lint", "typecheck", "test"] } },
"knowledge-base": { "command": "claude", "args": ["-p", "{prompt}", "--agent", "knowledge-base"], "cwd": "." }
}Placeholders {prompt} {title} {id} {agent} {tags} are substituted per task; commands run without a shell (safe with arbitrary prompt text). A cwd of "{repo}" makes one route serve every repo: a task tagged repo:<name> (set with the Telegram slash command /my-app, or a #repo:my-app tag) runs in <AGENT_REPO_BASE>/<name> (default ~/code). Code routes set "pr": true to open a PR automatically (see below); subagent routes use claude --agent <name> and a literal cwd.
Pinning the implementer's model: add "--model", "<alias-or-id>" to a route's args to fix which model runs its tasks:
Use a full id (claude-opus-4-8, claude-sonnet-5) to pin an exact version, or a rolling alias (opus, sonnet, fable) to always track the latest release under that name. Left unset, the run just inherits claude's own configured default. Whichever model actually ran is reported back and shown as a chip on the card, alongside its cost. (This is separate from the review gate's REVIEW_MODEL, which only pins the reviewer/fixer passes.)
⚠️ Safety: the dispatcher is dry-run by default — it reports the command it would run and touches nothing. Pass--execute(orAGENT_EXECUTE=1) only when you're ready for agents to run commands and edit repos on your machine. Results always land in Review for your approval, never straight to Done.
Any code route ("pr": true) acting on a repo:-tagged task opens a PR automatically — the old commit-push label is folded in, so you don't need a special label. The flow is dispatcher-driven and isolated: the agent only edits files, then the dispatcher itself runs the task in its own git worktree (branch atb/<id>, under AGENT_WORKTREE_DIR), commits, pushes, and runs gh pr create --fill. Because each task gets its own worktree, your main checkout is never touched and concurrent tasks (--concurrency N) — even on the same repo — never collide. The worktree is hydrated with a node_modules symlink + copied .env so builds/tests still work.
The agent's prompt (implementPrompt) also confines it to the worktree: work only in the current directory, never cd, never run git, and ignore any absolute path in the task (e.g. /Users/…/code/<repo>/…) — acting instead on the matching path inside the worktree. This is a real backstop: a --dangerously-skip-permissions agent will otherwise obey an absolute path embedded in a task (e.g. one the scout wrote) and edit/commit in your real checkout, leaving the worktree empty — so no PR opens, the task skips Review straight to Done, and stray commits accumulate under ~/code. The scout's scan prompt is the matching source-side fix (it emits repo-relative paths only, never git steps).
Telegram:
/my-app add a health-check endpoint→ worktree onatb/<id>→ PR opened → card sits in Review with a one-click PR link.
The merge-watcher (agent/merge-watcher.mjs, started by npm run agents) then polls every PR found on a Review card via gh and acts on the PR's fate: merged → move the card to Done (Telegram "🎉 merged → Done"); closed without merging (you rejected it) → delete the card. Either way it prunes the now-stale atb/<id> branch so dead branches don't accumulate. On a merge it also fast-forwards the local checkout (git pull --ff-only in the task's repo: dir) so your working copy — and the scout, which fingerprints local HEAD to decide what to scan — reflect the merged commit instead of re-proposing already-shipped work; a dirty or diverged checkout is left untouched. So merging the PR to master is the approval and closing it is the rejection; you never touch the board. Detection is detached from how the card was created — any Review card whose result contains a github.com/.../pull/N url is watched.
- Set the poll interval in
.envviaWATCHER_INTERVAL(ms, default30000), ornpm run watcher -- --interval 60000. - Point the task at the repo you want changed — the Telegram slash command
/<name>(or a#repo:<name>tag) maps to<AGENT_REPO_BASE>/<name>— and make sureghis authenticated there. - Branch pruning is guarded to
atb/*heads (never a hand-made PR's branch); an already-deleted branch is a harmless no-op.
A PR sitting in Review isn't a dead end. When CI fails or main moves under it, send it back for another pass that updates the same PR — no new PR, no force-push:
- From the board — a "Send back for revision" button on Review cards opens a dialog for your correction.
- From Telegram — bare
/reviselists the revise-able cards;/revise <id> <correction>sends one back. The id is right there: every "in Review" notification includes a ready-to-use↩️ send back: /revise <id> <fix>line, and the PR body carries aBoard task: <id>footer.
The dispatcher then reopens the existing atb/<id> branch in a worktree at the same path, resumes the original agent session (claude --resume — so it recalls why it wrote the code, not just what) with your correction, merges the latest base (resolving conflicts), and fast-forward-pushes — the open PR just gains a new commit. Session capture is automatic (Task.sessionId); if a PR predates it, revise falls back to a fresh agent working from the checked-out branch. Implementation: runWithRevise + createReviseWorktree/finishRevise (agent/lib/git.mjs), routed by isRevise/resumeRoute/revisePrompt (agent/lib/routes.mjs).
Before a PR ever opens, the dispatcher can run an independent review-and-fix loop inside the same worktree — so the PRs that reach your Review lane have already cleared an automated check. A fresh agent process (no context from the agent that wrote the code — genuinely independent, since each claude -p is one-shot) reviews the diff alongside the repo's own checks, and a fixer iterates until the change passes or a cap is hit.
implementer edits → [ loop: run checks → independent reviewer → fixer ] → open PR
The gate passes only when all three hold: the repo's checks are green and the reviewer reports zero blocking findings and its confidence ≥ the threshold. (The confidence number alone isn't trusted — an LLM's self-reported confidence isn't calibrated — so it's anchored to the hard signal of real lint/typecheck/test runs.) If the cap is reached without passing, the PR still opens, but flagged — the unresolved findings + confidence are folded into the card's result and the Telegram message shows ⚠ Flagged: … needs a closer human look.
Turn it on two ways (off by default, so existing routes are unchanged):
// per route in agent/routes.json — bare flag uses the defaults…
"Claude Code": { "command": "claude", "args": [...], "cwd": "{repo}", "pr": true, "review": true }
// …or tune the knobs:
"Claude Code": { …, "review": { "iterations": 2, "threshold": 95, "checks": ["lint", "typecheck", "test"] } }# …or force it on for EVERY pr:true route, no routes.json edit:
AGENT_REVIEW=1 npm run agents -- --execute # AGENT_REVIEW=0 forces it off| Knob | Default | Meaning |
|---|---|---|
iterations |
1 |
Max fix rounds (so ≤ iterations + 1 review passes) before opening a flagged PR |
threshold |
90 |
Minimum reviewer confidence (%) to pass the gate |
checks |
auto | Which package.json scripts to run; omit to auto-detect lint/typecheck/test (build excluded — too slow) |
Cost: each enabled task spawns up to
2 × (iterations + 1)extraclauderuns (reviewer + fixer per round) plus the check commands, so keepiterationsmodest. Review only runs for tasks that would open a PR (pr: trueroute + arepo:tag); plain questions and subagent tasks are never gated. That extra spend is no longer invisible — the reviewer/fixer runs are summed into the task's per-task cost alongside the implementer's.
Implementation lives in agent/lib/review.mjs (pure helpers unit-tested in review.test.mjs), wired into the PR flow in agent/dispatcher.mjs.
Every runner the dispatcher spawns is bounded by AGENT_TIMEOUT (default 1200000 ms = 20 min, per runner invocation). When a run overruns, the dispatcher SIGKILLs it. What happens next depends on how the run ended:
| Scenario | What the dispatcher does | Where the card lands |
|---|---|---|
| Runner times out (1st time) | Auto-requeues once — moves the card back to Queued, stamps an auto-retry tag, and pings Telegram ⏱ Timed out … auto-requeued. For a PR task the half-finished worktree is discarded (no PR opened) so the retry starts clean. |
Queued (re-attempted) |
| Runner times out (2nd time) | The auto-retry tag makes requeue one-shot — a second timeout is not retried. |
Review, flagged ❌ (human takes over) |
| Runner exits non-zero (genuine failure) | No retry — a real error won't fix itself on a re-run. | Review, flagged ❌ |
| Runner can't start (command missing, etc.) | No retry; the start error is the result. | Review, flagged ❌ |
| PR task, agent edited files | Dispatcher commits → pushes → opens the PR. | Review with a 🔗 PR link → merge-watcher moves it to Done on merge |
| PR task, no diff produced | No PR opened (no file changes). |
Review / Done per the run's own error flag |
| Review gate enabled, cap reached unpassed | PR still opens, but flagged (findings + confidence folded in). | Review, marked ⚠ Flagged |
| Dispatcher throws unexpectedly | safeProcess catches it and reports Dispatcher error: …. |
Review, flagged ❌ |
Key properties:
- One clean retry, never a loop. A timeout gets exactly one fresh attempt; the
auto-retrytag (pure decision inshouldRequeue, unit-tested) guarantees a second timeout escalates to a human instead of retrying forever. - Timeouts don't leak partial PRs. Because the PR flow bails before committing when the implementer times out, the retry branches fresh off the default branch with no remote branch to collide with.
- Only timeouts retry. Genuine failures (non-zero exit, start errors) go straight to Review — re-running them would just waste another
AGENT_TIMEOUT. - The retry is FIFO-fair. A requeued card keeps its original
createdAt, soclaimNext(oldest-first) picks it up next. - Tune the budget with
AGENT_TIMEOUTin.env. A task that's legitimately longer than 20 min should get a bigger budget or be split — a single very long agent run is also more likely to wander. - Restarts resume interrupted work. A control-plane restart (crash, reboot,
launchctl kickstart, KeepAlive respawn) kills whatever the dispatcher was running mid-task. On the next bootagent/launch.mjscallsPOST /api/reconcilebefore starting the dispatcher, which requeues every task stranded in Running back to Queued (purerequeueOrphaned, unit-tested — keepscreatedAtfor FIFO, clears the stale claim/start stamps). It's safe because a fresh boot has no live workers yet, so nothing in Running can be genuinely in-flight. Without this, an interrupted task would sit in Running forever (the dispatcher only ever claims Queued tasks).
Note: the review gate's reviewer/fixer runs and
npm runchecks have their own budgets (AGENT_TIMEOUTperclauderun,REVIEW_CHECK_TIMEOUTper check, default 5 min), so a reviewed PR task's total wall-clock can exceed a singleAGENT_TIMEOUT. Auto-requeue is scoped to the implementer run timing out, not the reviewer/fixer.
Changed your mind mid-run? Stop an in-flight task from Telegram:
/cancel— cancels the running task. If several are running, it shows an inline picker (one button per task:🛑 title · repo · 4m)./cancel <id>— cancel a specific one by id prefix./cancel all— stop everything that's running.
The cancelled card moves straight to Done with a 🛑 Cancelled by you marker (it is not auto-requeued). Under the hood the bot and dispatcher are separate processes, so /cancel writes a cancelRequestedAt flag to the board; the dispatcher polls for it (AGENT_CANCEL_POLL, default 3s) and process-group-kills the agent — so claude's child processes die too, not just the wrapper. For PR tasks the kill lands before anything is committed or pushed, so no half-finished branch leaks; the isolated worktree is torn down as usual. With concurrent agents the signal is per-task, so only the one you pick is stopped.
The dispatcher consumes tasks; the scout (agent/scout.mjs, npm run scout) produces them. Every couple of hours it scans every repo under AGENT_REPO_BASE (~/code) for high-leverage improvements — infra, dev tooling, features, fixes, docs, even a brand-new project when the win is big — ranks them, and proposes only the single best one to you on Telegram with Yes/No buttons. Tap ✅ and it's queued for the dispatcher; tap ❌ (or ignore it) and nothing runs. You stay the gatekeeper — no backlog of suggestions, and nothing queued behind your back.
Excluding repos or subdirectories. Two env knobs:
AGENT_REPO_IGNORE— comma-separated list of whole repos to drop from scanning (names match separator-insensitively; a*glob matches the raw name, e.g.AGENT_REPO_IGNORE=archive, design-systems, third-party-repos). This is a hard exclude and is global — an ignored repo also disappears from the Telegram/slug//usemenu (the dispatcher can still run a task that already carries an explicit#repo:<name>tag). Because the scan is one level deep, excluding a top-level folder keeps its whole subtree out — so ignore the parent (e.g.design-systems) rather than globbing the nested package names.SCOUT_IGNORE_PATHS— comma-separated, repo-relative subdirectories to keep the scout out of inside repos you still scan (e.g.some-repo/vendor, some-repo/.next). This is a soft hint added to the scan prompt, not a hard fence.
scan only CHANGED repos → score + rank ideas into a backlog → propose the best on Telegram → ✅ → queue → dispatcher → PR → Review
↑ quiet day? skip the scan, propose the next backlog idea
Run it on demand from Telegram with /scout (or /scout full to force a full sweep) — you don't have to wait for the 2-hourly timer. Plain /scout is always the cheap incremental scan (changed repos only) — it passes --incremental so an on-demand tap never balloons into a ~30-min all-repo sweep; the periodic 24h full sweep stays owned by the scheduler and by explicit /scout full. The bot launches the same one-shot scout, which sends its own Yes/No proposal just like a scheduled run; if it finds nothing it says so. It refuses to start a second scan while one is running or while a proposal is still awaiting your answer. This only works when the control-plane bot owns Telegram inbound (the default) — if an external front door polls the same bot token (--no-telegram), add the command there too.
While a proposal is unanswered it pauses — the next scheduled run (or a /scout) skips scanning so ideas never pile up. If you don't reply for 24h the offer auto-expires and the next run scans afresh; a tap on a stale/superseded message is politely rejected. The pending offer lives in .data/scout-pending.json, and the control-plane Telegram bot is what acts on your tap — so keep the control plane running.
It remembers between runs (.data/scout-memory.json) so a 2-hourly scan isn't a cold re-read of all ~90 repos each time — which is most of the cost, since on a normal day only a few repos change:
- Skips unchanged repos. Each run fingerprints every repo by its HEAD commit (
+dirtywhen there are uncommitted changes) and deep-scans only the repos that moved (or are new). A repo whose SHA is unchanged is skipped entirely. - Keeps a backlog. A scan ranks many ideas but only the best is proposed each run; the rest are kept as a ranked backlog. So when nothing changed, the run skips the model entirely and still proposes a stored idea (the #2–#5 from an earlier scan) — you keep getting suggestions on quiet days at zero scan cost. Before each propose the backlog is re-filtered against the current repo list, so a stored idea for a repo you've since moved (e.g. into
archive/), deleted, or added toAGENT_REPO_IGNOREis dropped instead of being re-pitched. - Retires only what you accept. Proposing an idea drops it from the backlog so a quiet run moves on to the next one, but only an idea you ✅ accept is permanently suppressed (deduped by a repo+title key, so reworded duplicates collapse). A ❌ or ignored idea is not retired — a later scan can resurface it. Fresh scans for a repo supersede that repo's stale backlog ideas, and the backlog + accepted titles are fed back into the prompt as a "don't repeat these" list.
- Revisits everything periodically. A full sweep is forced every 24h (tunable via
SCOUT_FULL_SCAN_MS, or--full) so an unchanged-but-improvable repo still gets a fresh look and a stale ledger can't permanently hide a repo.
It runs a fresh claude -p over the workspace and asks it to score each idea on three 1–10 axes — impact, confidence, ease — but the ranking is computed in code, not taken from the model: score = impact × confidence × ease (the classic ICE score), so it's deterministic and reproducible. The winner becomes a task three ways:
- Existing repo (the idea's
repomatches a folder under~/code, separator-insensitively) → arepo:<name>tag, so it flows through the normal worktree → PR → Review path. - New project (
category: new-project) → a prompt that scaffolds a fresh~/code/<slug>(withgit init); no PR, lands in Done for you to inspect. - Cross-repo / workspace idea → runs against the whole workspace; no PR.
Every scout task carries a scout tag for provenance.
Every scout task carries a scout tag for provenance. (With no Telegram configured the scout falls back to pushing the top idea straight to the board, the original behavior.)
npm run scout # scan → rank → propose the top idea on Telegram (board + bot must be up)
npm run scout -- --dry-run # scan → rank → print the ranking + task input, propose nothing (memory untouched)
npm run scout -- --full # ignore the memory ledger and scan every repo
npm run scout -- --print-prompt # dump the scan prompt and exit (no model call)Run it every 2 hours automatically with a LaunchAgent (macOS) — it fires at 08:00–22:00 and stays silent overnight:
npm run scout:install # every 2h, 08:00–22:00
npm run scout:install -- --start 9 --end 21 --every 3 # retune the window
npm run scout:install -- --minute 15 # fire at :15 past
npm run scout:uninstall # remove itThe scout runs no board of its own — on your ✅ the control-plane bot POSTs the task to whatever board is up, so keep the control plane running (npm run agents:install) to field the taps and accept the queue. Pure scan/rank/propose helpers are unit-tested in agent/lib/scout.test.mjs and the cross-run memory ledger in agent/lib/scout-memory.test.mjs; env knobs: SCOUT_MODEL, SCOUT_TIMEOUT (ms, default 30 min), AGENT_REPO_IGNORE, SCOUT_IGNORE_PATHS (see Excluding repos or subdirectories above).
- MCP server (
agent/mcp-server.mjs) exposesadd_task,list_tasks,get_board,claim_next,report_result,move_task. Point Claude Desktop / Claude Code at it (npm run mcp) and queue work by talking to an agent. Config example is in the file header. - Telegram bot (
agent/telegram-bot.mjs) turns messages into tasks:"[Claude Code] fix the flaky test #bug"→ a queued task taggedbugforClaude Code. Pick the repo a code task runs in with a slash command: a leading/<repo>(e.g./my-app add a health endpoint) targets one task, and/use <repo>sets a sticky default for the chat so plain messages inherit it (/useshows it,/use offclears it). The repo list is read straight from the folders underAGENT_REPO_BASEand registered as a/-autocomplete menu on startup — create a new repo and it becomes a command automatically, nothing to maintain (slugs match separator-insensitively, so/democratizing_clauderesolvesdemocratizing-claude). A#repo:<name>tag still works as a fallback; precedence is/<repo>>#repo:tag > sticky default./cancelstops a running task,/revise <id> <fix>sends a Review PR back for another pass,/scoutkicks off the improvement scout on demand, and/pause//resumestop and restart claiming of new work (see those sections above). Send/idto get your chat id forTELEGRAM_CHAT_ID(where the dispatcher posts notifications).
Already run a Telegram → claude -p bot (e.g. telegram-claude-agent)? Use it as the front door and let this board's dispatcher do the work — no edits to the bot needed if it runs claude --dangerously-skip-permissions --print:
- Register the MCP server for your bot's claude (once) so it can enqueue:
Any
claude mcp add -s user agent-task-board -e BOARD_URL=http://localhost:3000 \ -- node /abs/path/agent-task-board/agent/mcp-server.mjs
claudesession now hasadd_task(and the bot auto-allows it under--dangerously-skip-permissions). - Send dispatcher notifications to that bot's chat — in
.env, setTELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_IDto the same bot + chat, so "picked up" / results show up in your existing conversation. - Run the board + dispatcher:
npm run dev # board API + queue npm run dispatcher -- --execute # claims → runs claude -p → reports to Telegram
- Tell your bot "queue a task for Claude Code to …" → its claude calls
add_task→ the dispatcher runs it and reports back. (Optionally add a line to the bot's persona telling it to useadd_taskwhen you say "queue / dispatch".)
The server-backed board persists to a JSON file (
.data/board.json) and the agent layer needs a long-running process, so live mode is for local / self-hosted use — the public demo deployment stays local-first.
Contributions are welcome! Please open an issue first to discuss what you'd like to change.
- Fork the repo
- Create a feature branch (
git checkout -b feature/your-feature) - Make your change, adding tests for any new logic in
lib/ - Make sure
npm run lint,npm run typecheck,npm run test, andnpm run buildall pass - Commit (
git commit -m 'feat: describe change'), push, and open a pull request
This project follows the Contributor Covenant v2.1. By participating you agree to uphold a welcoming, harassment-free environment.
Distributed under the MIT License. See LICENSE for details.

