On this pageInstall

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

  1. 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. If claude is not on your PATH it prints that command for you to run later.
  2. Appends a short Tandem section to ~/.claude/CLAUDE.md (idempotent), so phrases like “make me a sprint board” or “a task board” map to canvas_create and epic_propose instead of a markdown file.
  3. Downloads this platform’s tandem binary from the latest GitHub release into ~/.tandem/bin, starts it on http://127.0.0.1:7891, and opens the board.
FlagEffect
--projectRegister in the current repo’s .mcp.json (merged, never overwritten) and ./CLAUDE.md instead of user scope.
--no-claude-mdSkip the CLAUDE.md snippet.
--no-openDon’t open the browser.
--port N, --data-dir PServer 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

FromToWhen
proposedapproved / rejectedThe human decides on the board. A rejection carries a reason the agent reads back.
approvedexecutingtask_claim, exactly one winner.
executingdone / failedtask_complete with a result and links, or status: "failed".
approvedproposedIts title or body was edited: it re-enters the gate.
doneapprovedA 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:

statusMeansDo
readyApproved tasks, each with its handoff block.Claim one (alone) or dispatch one subagent per task.
rejectedA 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.
timeoutNothing was approved inside the window. Not an error.Call it again.
busy, unsupportedWaiter 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.

PolicyAn agent-proposed task is bornEpic approval cascadesWho approves a task
strictproposedNo, each task keeps its own gateHuman
epic (default)approved if its epic is already approved, else proposedYesHuman
autoapprovedn/aNothing waits
peerproposed, alwaysNo, deliberately offHuman, 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.

ToolWhat it does
canvas_connectBind 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_createCreate a canvas and bind to it in one step, when the user gave no code.
agent_registerRe-register after connect: fix a rejected parentAgentId, record the model, switch role.
context_getA summary of the canvas in one call: your identity, document tabs, counts by kind, queue state. It doesn't return the full board.
queue_nextThe approved tasks that are ready to work. Start here. Each task includes a handoff block.
queue_waitSame 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_findFind a task by part of its title when you have no id. A ticket ref (TDM-21) resolves directly.
task_getOne task with its linked notes, roadmap items and epic hydrated, plus any review block explaining why it came back.
task_claimClaim a task atomically (approved to executing). If another session got it first, you get { claimed: false, claimedBy }; take the next task.
task_progressAdd one line of progress to a task you hold. It is stored on the task and also extends your claim.
task_completeFinish with a result (files, commit hashes) and links to the commit or PR, or status: "failed" with an error.
task_proposePropose one task, or many via tasks. Lands as proposed; the answer carries a tellHuman line to relay.
task_amendCorrect, withdraw or resubmit a task you proposed while it is still proposed or rejected.
task_reviewThe 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_proposePropose an epic and its tasks in one call. One human approval releases the batch.
doc_writeLeave a markdown note on a document tab; a new tab name creates the tab.
doc_readRead one tab's notes back by name or id, scoped server-side. Keep the noteId of anything you will revise.
board_statusCounts 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.

CommandWhat it does
setupFirst-run onboarding, as above. --project, --no-claude-md, --no-open, --port N, --data-dir P.
upStart 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.
downStop the server up started.
initWire 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.
listenLocal 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-toolsAs the MCP server, also advertise the CRUD surface.
--version, --helpEach subcommand also takes --help.

Configuration

No configuration is required. By default the server runs in local mode and keeps everything in one directory:

PathWhat it is
~/.tandem/tandem.dbEvery canvas, task, note and webhook. Back it up by copying it.
~/.tandem/secretThe auto-generated JWT secret, so tokens survive restarts.
~/.tandem/bin/tandemThe 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):

VariableDefaultWhat it does
TANDEM_DATA_DIR~/.tandemMoves the whole directory.
TANDEM_DB<data dir>/tandem.dbSQLite file. :memory: gives a throwaway board.
PORT7891HTTP port. up --port sets the same thing.
CLAIM_TTL_MINUTES15How long an unheartbeated claim is honored before the next task_claim may take it over. 0 disables expiry.
JWT_TOKEN_TTL24hCanvas token lifetime.
WEBHOOKS_ENABLEDtrueThe outbound delivery worker listen receives from.
TANDEM_WEBHOOKS_ALLOW_PRIVATEon locallyWhether webhooks may target loopback and private addresses. Set 0 to turn the SSRF guard back on.
GH_STATUS_TOKENunsetOptional 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 list should show tandem as connected.
setup said claude is not on your PATH
It printed the registration command instead of running it. Run claude mcp add --scope user tandem -- npx -y @jaximus/tandem-mcp once 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. down stops the one up started, and says so if none is running. curl 127.0.0.1:7891/healthz tells you which case you are in.
queue_next is empty but work was proposed
Nothing is approved yet. Tasks under a still-proposed epic are not in the ready queue; approve the epic on the board. board_status shows what is waiting and where.
task_claim returned claimed: 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 a task_progress heartbeat, board_status marks it staleClaim and the next task_claim takes 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_wait for your approval. Approve the work on the board and it continues. Agents are supposed to show you the tellHuman line from the propose answer before they wait.
listen answers 401 or 400
401 means the signature didn’t verify, usually because the secret is wrong (--secret takes priority over TANDEM_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.