Tandem docs
Tandem is a task queue for agent sessions. Agents propose tasks, you approve them on a board, and Claude Code, Codex or Cursor sessions claim them from the queue. Each task goes to exactly one session. It runs locally: one Go binary and one SQLite file in ~/.tandem.
Install
One command. It needs Node (for npx) and, to register itself, Claude Code on your PATH.
npx -y @jaximus/tandem-mcp setup
Restart Claude Code afterwards so it picks up the new server, then check it is connected:
claude mcp list
tandem: npx -y @jaximus/tandem-mcp - ✓ Connected
What setup does
- Registers the MCP server with Claude Code at user scope, so every project can reach it:
claude mcp add --scope user tandem -- npx -y @jaximus/tandem-mcp. Ifclaudeis not on yourPATHit prints that command for you to run later. - Appends a short Tandem section to
~/.claude/CLAUDE.md(idempotent), so phrases like “make me a sprint board” or “a task board” map tocanvas_createandepic_proposeinstead of a markdown file. - Downloads this platform’s
tandembinary from the latest GitHub release into~/.tandem/bin, starts it onhttp://127.0.0.1:7891, and opens the board.
| Flag | Effect |
|---|---|
--project | Register in the current repo’s .mcp.json (merged, never overwritten) and ./CLAUDE.md instead of user scope. |
--no-claude-md | Skip the CLAUDE.md snippet. |
--no-open | Don’t open the browser. |
--port N, --data-dir P | Server port (default $PORT, else 7891) and data directory (default $TANDEM_DATA_DIR, else ~/.tandem). |
Safe to re-run.
Cursor, Codex, other clients
Any stdio MCP client spawns the same command, npx -y @jaximus/tandem-mcp. The usual JSON form:
{
"mcpServers": {
"tandem": {
"command": "npx",
"args": ["-y", "@jaximus/tandem-mcp"]
}
}
}
The MCP server talks to the local Tandem server on 127.0.0.1:7891, and starts it itself the first time a tool call finds nothing listening, so a client that never ran setup still works. Start it by hand with npx -y @jaximus/tandem-mcp up.
Quick start
The first prompt
In any Claude Code session, ask for a board in plain words:
make me a sprint board for the auth refactor
The agent creates a canvas, proposes an epic with its tasks, and hands you the board URL. Nothing runs yet: every proposal lands as proposed. Open the board, read the plan, and approve the epic once. That one approval releases every task under it, and each task gets a ticket id like TDM-7.
Then start as many workers as you like against the same queue:
# terminal 1 and terminal 2, same repo:
claude "work through the approved Tandem queue until it's empty"
Both sessions take tasks from the same queue. On the board, a claimed task shows as executing with the session’s name, and each finished task shows its result and commit hash. A session waiting on queue_wait continues as soon as you approve more work.
Pin a repo to a canvas
For a repo that should always use the same board:
npx -y @jaximus/tandem-mcp init --write
That creates a canvas named after the folder, writes its code into the repo’s .mcp.json as TANDEM_CANVAS_CODE (the default for canvas_connect), and appends this snippet to AGENTS.md or CLAUDE.md. Without --write it prints the snippet for you to paste; --code wires an existing canvas instead of creating one.
## Tandem task queue
The shared work queue for this repo is Tandem canvas `AB3XK9QZ`.
Every session:
1. `canvas_connect` with code `AB3XK9QZ` — once per session. Pass `role`
("executor" if you'll work tasks yourself, "planner" if you'll dispatch
them to subagents) and a `name`. Keep the `session` handle it returns and
pass it as `session` on every later Tandem call.
2. `queue_next` — the approved, ready-to-work queue. That is the entry point
for work; don't go looking for it by reading the canvas.
3. Pick one task, `task_get` for its hydrated context, then `task_claim`.
The claim is atomic: if it returns `{ claimed: false, claimedBy }`,
another session won — do NOT work on that task; go back to `queue_next`.
4. Do the work. Every commit message starts with the ticket id, e.g.
`TDM-7: add rate limiter`. On long work, `task_progress` with one line
per meaningful step — it doubles as a heartbeat.
5. `task_complete` with a `result` saying what was done and where — always
include the commit hash(es) — plus `links` to the commit or PR.
How it works
Your MCP client starts tandem-mcp, a small stdio proxy that forwards tool calls over HTTP to the tandem binary on 127.0.0.1:7891. The binary stores everything in one SQLite file, ~/.tandem/tandem.db. The board in your browser connects to the same server over a WebSocket and updates live. Everything runs locally. There are no accounts.
In local mode, the person using the browser owns every canvas. Anything that connects over MCP with a canvas code is an agent. Agents propose and claim tasks. Only the human approves, rejects or deletes them.
A task’s life
| From | To | When |
|---|---|---|
proposed | approved / rejected | The human decides on the board. A rejection carries a reason the agent reads back. |
approved | executing | task_claim, exactly one winner. |
executing | done / failed | task_complete with a result and links, or status: "failed". |
approved | proposed | Its title or body was edited: it re-enters the gate. |
done | approved | A reviewer requested changes (peer policy). The claim and stale result are cleared; the reason stays on the audit trail. |
Every task has a ticket id, TDM-n. Commit messages start with it (TDM-7: add rate limiter) and each completion links to its commit, so the board shows the commit for every task.
Atomic claims
A claim is one conditional UPDATE … WHERE state = 'approved' inside a serialized transaction. When two sessions race for the same task, exactly one wins; the other gets { claimed: false, claimedBy } naming the holder, plus tapOut: true, and goes back to queue_next. Losing is normal and is not an error. Asking again for a task you already lost is refused by the MCP server without reaching the API.
Two tests in the repo check this: TestClaimRaceHasOneWinner races eight goroutines on one SQLite row, and TestConcurrentWorkerClaimsProduceOneWinner races two workers through the HTTP handler (one 200, one 409 naming the winner).
Heartbeats and expiry
task_progress stores a line on the task and extends the claim. A claim nobody has heartbeated for CLAIM_TTL_MINUTES (default 15) counts as stuck: the next task_claim on that task may take it over atomically. Expiry is lazy, with no background job; 0 disables it. board_status flags such a task with staleClaim: true. If a task takes longer than 15 minutes, call task_progress along the way.
Orchestrating subagents
A session that works tasks itself connects with role: "executor" and runs the loop: queue_next, task_get, task_claim, work, task_complete. A session that dispatches subagents connects with role: "planner" and never claims.
An agent only claims tasks it will do itself. An orchestrator dispatches subagents. It doesn’t claim tasks, doesn’t complete them for a worker, and doesn’t give a subagent its session handle; subagents get the canvas code instead. If an orchestrator claims a task and never works on it, the board shows the task in progress with nobody working on it.
The handoff block
Every task queue_next (or queue_wait) returns carries a handoff block: the canvas code, the task’s id, ticket and title, the planner’s agentId as parent, and the literal steps the worker follows. The orchestrator spawns one subagent per ready task and pastes that block in verbatim. The worker connects as an executor with parentAgentId set, claims its own task, works, and completes under the identity it claimed with. On claimed: false it takes a different ready task. To report, the orchestrator reads board_status. It reports tasks that are still executing; it doesn’t complete them.
Waiting for approval
After proposing, show the human the tellHuman line the propose call returns, so they know something is waiting for approval. Then call queue_wait instead of ending the turn. It waits on the server and returns as soon as work is approved. The status field says what happened:
| status | Means | Do |
|---|---|---|
ready | Approved tasks, each with its handoff block. | Claim one (alone) or dispatch one subagent per task. |
rejected | A ticket you proposed came back, with the reason verbatim. | Fix what the reason names and task_amend the same ticket with a note. Don’t file a fresh copy. |
timeout | Nothing was approved inside the window. Not an error. | Call it again. |
busy, unsupported | Waiter cap reached, or the API predates the endpoint. The queue was read for you. | Treat it like queue_next. |
Default wait is 25 seconds, clamped to 1–60 via timeoutSeconds; epicId narrows it to the batch you just proposed.
When nothing is running, approval can launch the orchestrator instead: tandem-mcp listen --exec '…' receives a signed task.approved webhook on 127.0.0.1:8787 and runs the command. The full recipe is docs/ORCHESTRATION.md.
Approval policies
A canvas is on exactly one policy, set by its owner in the board’s share dialog. It decides which state an agent-proposed task is born in and who may approve it.
| Policy | An agent-proposed task is born | Epic approval cascades | Who approves a task |
|---|---|---|---|
strict | proposed | No, each task keeps its own gate | Human |
epic (default) | approved if its epic is already approved, else proposed | Yes | Human |
auto | approved | n/a | Nothing waits |
peer | proposed, always | No, deliberately off | Human, or a different agent |
A task proposed with requiresApproval: true lands proposed under every policy, auto included. Agents use it to flag work outside the approved plan.
Under peer, a registered agent reviews another agent’s work with task_review: outcome: "pass" approves a still-proposed task into the ready queue; outcome: "changes_requested" with a required reason sends a done task back to approved, unclaimed. The server derives both identities itself, so an agent cannot pass its own proposal or bounce its own completion; refusals come back as data (reviewed: false with a refusal code), not errors. Approving an epic, rejecting, and bulk approval stay human on every policy. On any policy other than peer, task_review answers reviewed: false and the human gate is unchanged.
Tools
The default MCP surface has 18 tools. Every tool except the two connectors takes the session handle canvas_connect returns; pass it on every call. Where a tool takes a task id, a ticket ref (TDM-21, #21, 21) works as well as the uuid.
| Tool | What it does |
|---|---|
canvas_connect | Bind the session to a canvas by its 8-character code. Pass role (planner or executor), name and, if an orchestrator spawned you, parentAgentId: the one call connects and registers you. Returns the board url and the session handle. |
canvas_create | Create a canvas and bind to it in one step, when the user gave no code. |
agent_register | Re-register after connect: fix a rejected parentAgentId, record the model, switch role. |
context_get | A summary of the canvas in one call: your identity, document tabs, counts by kind, queue state. It doesn't return the full board. |
queue_next | The approved tasks that are ready to work. Start here. Each task includes a handoff block. |
queue_wait | Same as queue_next, but waits on the server until something is approved. Default 25 s, up to 60; optional epicId. A timeout means nothing was approved yet; call it again. |
task_find | Find a task by part of its title when you have no id. A ticket ref (TDM-21) resolves directly. |
task_get | One task with its linked notes, roadmap items and epic hydrated, plus any review block explaining why it came back. |
task_claim | Claim a task atomically (approved to executing). If another session got it first, you get { claimed: false, claimedBy }; take the next task. |
task_progress | Add one line of progress to a task you hold. It is stored on the task and also extends your claim. |
task_complete | Finish with a result (files, commit hashes) and links to the commit or PR, or status: "failed" with an error. |
task_propose | Propose one task, or many via tasks. Lands as proposed; the answer carries a tellHuman line to relay. |
task_amend | Correct, withdraw or resubmit a task you proposed while it is still proposed or rejected. |
task_review | The reviewer's verb on a peer canvas: pass approves another agent's proposal; changes_requested sends another agent's finished work back with a reason. |
epic_propose | Propose an epic and its tasks in one call. One human approval releases the batch. |
doc_write | Leave a markdown note on a document tab; a new tab name creates the tab. |
doc_read | Read one tab's notes back by name or id, scoped server-side. Keep the noteId of anything you will revise. |
board_status | Counts by state, epics and their approval state, every in-flight task with its holder and claim age. Pass epic to expand one batch. |
The raw CRUD surface (documents, notes, roadmap items, raw actions, about 80 tools) is still callable but only advertised with TANDEM_FULL_TOOLS=1 or tandem-mcp --full-tools. Per-tool arguments: docs/TOOLS.md.
CLI
All from npx -y @jaximus/tandem-mcp <command>, or tandem-mcp <command> after npm i -g @jaximus/tandem-mcp. With no command it runs as the stdio MCP server, which is what your client spawns.
| Command | What it does |
|---|---|
setup | First-run onboarding, as above. --project, --no-claude-md, --no-open, --port N, --data-dir P. |
up | Start the local server, downloading the binary on first run, and open the board. A server already answering on the port is reused, never duplicated. --port N, --data-dir P, --code CODE (open that canvas), --no-open, --foreground (attached, logs on stderr). |
open [CODE] | Open the board, or one canvas, starting the server first if it is not running. |
down | Stop the server up started. |
init | Wire the current repo: create a canvas, write .mcp.json, print the agent snippet. --write appends it to AGENTS.md / CLAUDE.md; --code CODE uses an existing canvas; --name, --dir, --force. Re-running on a wired repo reprints the snippet and changes nothing. |
listen | Local webhook listener that runs a command when work is approved. --exec (required), --port (default 8787), --secret (or TANDEM_WEBHOOK_SECRET), --events (default task.approved), --debounce. |
--full-tools | As the MCP server, also advertise the CRUD surface. |
--version, --help | Each subcommand also takes --help. |
Configuration
No configuration is required. By default the server runs in local mode and keeps everything in one directory:
| Path | What it is |
|---|---|
~/.tandem/tandem.db | Every canvas, task, note and webhook. Back it up by copying it. |
~/.tandem/secret | The auto-generated JWT secret, so tokens survive restarts. |
~/.tandem/bin/tandem | The downloaded binary. TANDEM_BIN, or a tandem on your PATH, wins over it. |
~/.tandem/images/, ~/.tandem/logs/ | Uploaded images; the detached server’s log. |
Server settings, read from the environment (or a .env next to the binary):
| Variable | Default | What it does |
|---|---|---|
TANDEM_DATA_DIR | ~/.tandem | Moves the whole directory. |
TANDEM_DB | <data dir>/tandem.db | SQLite file. :memory: gives a throwaway board. |
PORT | 7891 | HTTP port. up --port sets the same thing. |
CLAIM_TTL_MINUTES | 15 | How long an unheartbeated claim is honored before the next task_claim may take it over. 0 disables expiry. |
JWT_TOKEN_TTL | 24h | Canvas token lifetime. |
WEBHOOKS_ENABLED | true | The outbound delivery worker listen receives from. |
TANDEM_WEBHOOKS_ALLOW_PRIVATE | on locally | Whether webhooks may target loopback and private addresses. Set 0 to turn the SSRF guard back on. |
GH_STATUS_TOKEN | unset | Optional public-read GitHub token for resolving commit and PR links on the board (60 requests/hour unauthenticated, 5,000 with one). A token that can read private repos is refused. |
The server answers GET /healthz and GET /api/config ({auth, local, version}). The MCP server’s own variables: API_URL (default the local server), TANDEM_VERSION (which release up downloads, default latest), TANDEM_AUTOSTART=0 (don’t start the backend from a tool call), TANDEM_AUTOSTART_OPEN=1 (open the browser when it does), TANDEM_CANVAS_CODE, TANDEM_FULL_TOOLS, and MCP_TRACE=1 for per-call timing on stderr. Every server setting is listed in apps/api/.env.example.
To share one instance with a team, run the same binary with TANDEM_AUTH=google for Google sign-in, personal access tokens and per-account sharing; agents then set API_URL and TANDEM_TOKEN. See docs/SELF-HOSTING.md.
Troubleshooting
- The tools don’t show up after setup
- Restart Claude Code; it reads its MCP servers at launch. Then
claude mcp listshould showtandemas connected. - setup said
claudeis not on your PATH - It printed the registration command instead of running it. Run
claude mcp add --scope user tandem -- npx -y @jaximus/tandem-mcponce Claude Code is installed, or configure your client by hand as in other clients. - The server isn’t running
- The MCP server starts it on the first tool call that gets a connection refused on the loopback address (unless
TANDEM_AUTOSTART=0). To start it yourself,npx -y @jaximus/tandem-mcp up; it reuses a server already on the port rather than starting a second one.downstops the oneupstarted, and says so if none is running.curl 127.0.0.1:7891/healthztells you which case you are in. queue_nextis empty but work was proposed- Nothing is approved yet. Tasks under a still-
proposedepic are not in the ready queue; approve the epic on the board.board_statusshows what is waiting and where. task_claimreturnedclaimed: false- Another session claimed it first. This is expected. The answer names the session that holds it; take the next task from
queue_next. - A task has sat in executing for a long time
- Its session probably stopped. After
CLAIM_TTL_MINUTES(15 by default) without atask_progressheartbeat,board_statusmarks itstaleClaimand the nexttask_claimtakes it over. Sessions doing long work should report progress as they go. - An agent proposed work and then went quiet
- It is probably waiting on
queue_waitfor your approval. Approve the work on the board and it continues. Agents are supposed to show you thetellHumanline from the propose answer before they wait. listenanswers 401 or 400- 401 means the signature didn’t verify, usually because the secret is wrong (
--secrettakes priority overTANDEM_WEBHOOK_SECRET). 400 means the timestamp is more than 60 seconds off, usually because the clock drifted while the laptop was asleep. The delivery list under the board’s webhook settings shows the response.