Reference

What Tandem can do

The capability inventory — everything Tandem actually does, by pillar. Nothing here is planned, in progress or nearly done: each line was read out of the code, and honest limits get their own section rather than being quietly rounded up.

01

The task queue

The execution primitive is a task row. One table serves tasks and epics; six states and their legal transitions are enforced by the server, not by convention.

Six states
proposedapproved / rejectedexecutingdone / failed. failed → approved exists only as a human re-queue, never as an agent move.
Ticket ids
A per-canvas counter allocated by an atomic reservation that hands back n consecutive numbers in one round trip, so a batch insert cannot collide. TDM- is a render prefix; only the integer is stored.
Refs work anywhere an id does
TDM-21, tdm-21, #21, 21 and the raw uuid all resolve to the same task. An unknown but well-formed ref answers task_not_found, not “invalid id”.
Epics
A named batch that tasks point back into. Epics are never claimed and never executed — epic_propose creates the container and its tickets in one call, so a human approves once instead of ticket by ticket.
The ready queue is FIFO
Approved agent tasks, oldest first. There is no priority field — a queue that can be jumped is a queue nobody trusts.
Ticket quality warnings
Five non-blocking codes — no_surface_named, no_done_condition, may_exceed_one_sitting, context_not_linked, context_duplicated — evaluated on the ticket's text before any write, and again on the board while a human edits it.

Claims, leases and contention

Claiming is atomic
One conditional update, so of two racing claimers exactly one gets a row back. Losing is normal and is answered as data — the loser is re-read to be told who holds it and why it lost.
A 15-minute lease
Overridable via CLAIM_TTL_MINUTES; 0 disables expiry entirely.
Expiry is lazy
Nothing sweeps. A lapsed lease looks live until a rival asks for the task, at which point a second atomic update rebinds it — no background job to fall behind.
Heartbeats extend the lease
task_progress restamps the lease, holder-only, in SQL — but deliberately does not move the claim's start time, so “working for 40 minutes” stays honest.
Generation fencing
Each fresh lease mints a per-task counter. Presenting a superseded generation on a later write is refused with one shape — 409 plus a reason — from every write path: complete, fail, progress, patch, move, release, requeue, delete.
The contention trail
Recorded on the task, capped at 20 events with repeats coalesced, and read back as two strictly separated kinds: raced (a claim lost at the door) versus fenced (a write refused because the lease was superseded).
The tap-out contract
Every losing path carries tapOut: true with a reason and a next step — one boolean to branch on instead of prose — and a per-process ledger refuses a second attempt at a task you already lost without even racing.

Board reads

A real long poll
queue_wait parks server-side (1–60s, default 25s) and returns the instant work is approved. Waiters are an in-process registry woken by a signal — there is no database polling behind it.
Epic rollup
Counts, activity window, drained flag, summary and the list of bounced tickets — all derived at read time. Compact by default, per-ticket lines on request.
Cheap state reads
The default canvas read is a per-kind count summary, not the canvas. Projecting specific kinds is a query parameter; the full dump is the explicit escape hatch.
Inbound CI status
A build system can move a ticket with its own vocabulary — started · progress · completed · failed — and lands on the board as external rather than as a generic agent.
02

Approval gates

Agents propose; humans promote. A canvas sits on exactly one policy, set by the owner alone, and birth-time approval is stamped by the server from provenance the caller cannot forge.

requiresApproval overrides the policy
A task flagged requiresApproval lands proposed under every policy, auto included — the agent's own “I deviated, look at this” signal.
An unreadable policy falls back to strict
Fail closed. “We could not tell” resolves to “a human decides”.
Approval provenance is server-derived
human, agent:<name>, policy:epic, policy:auto — so a reader can tell a cascade from a decision. There is no approvedBy body field anywhere in the API.
The epic cascade
Batch-approves an epic's proposed tasks, detached after the response and idempotent, so an approve retry repairs a failed cascade. Off for strict and peer.
Reject stays human-only
On every policy. So does bulk approve and born-approved — a single conditional update cannot see per-row authorship, so it is not offered to agents at all.

The content gate

Approval binds to content
Content is title and body only. Editing an approved or executing task reverts it to proposed, clears the claim, and audits the reversion — you cannot approve one ticket and have another one execute.
Finished work is locked
Editing a done or failed ticket is refused, including via the side door of a combined state-plus-content patch.
Non-content writes are silent
Progress, links, assignee, linked context and epic membership never cost an approval. The audit trail itself is server-owned.
Gate metrics
A pre-work intervention rate derived from state and the audit trail, per canvas and per epic — and it ships its own caveat text in the same response, explicitly not a target.
03

Peer review

Off by default. On a peer canvas the gate moves from the human to a different registered agent — one verb, two outcomes, and a short list of things it deliberately cannot do.

pass
Approves a still-proposed task into the ready queue. Both identities are server-derived: the reviewer from the request's auth context, the proposer from the stored provenance.
changes_requested
Sends done work back to the queue with a required reason, stored verbatim — so whoever picks it up next reads why without asking anyone.
No self-review, ever
You cannot pass your own proposal or bounce your own completion. Unknown provenance on either side fails closed.
Refusals are data
A stable code plus a next step, surfaced as reviewed: false rather than an error — peer_self_approval, rework_self_review, rework_reason_required and a dozen more.
Cross-model review
Optionally require the reviewer to be running a different model. Ids are normalised for case, provider prefix and variant suffix. The model is self-asserted, so this is an honesty rail, not a guarantee — and it says so on every refusal.
A reviewer cannot end a task
Both of its moves are reversible with one human click. Rejection is not, so it stays with the person whose project it is — along with epics, bulk approval and born-approved.
04

Orchestration

What turns an approved queue into running work: paste-ready handoffs, a wait that is not a poll, and webhooks that can start a process on your machine.

Handoff blocks
queue_next attaches a paste-ready brief to every ready task, so an orchestrator dispatches one subagent per task without composing anything. It carries the canvas code, never the caller's session handle — a worker on the planner's handle claims as the planner and the fleet tree collapses.
Connect and register in one call
canvas_connect takes role, name, model and parentAgentId, upserting on name so re-registering returns the same identity. A bad parent never fails the connect — it is reported as data.
Waiting shows on the board
A timeout keeps the identity marked waiting for a grace window and pushes a live ping, so a parked agent is visibly parked rather than indistinguishable from a hung one.
The MCP server starts the backend
The first canvas_connect against a machine with nothing listening starts the Tandem binary detached and retries — an agent can bring the board up on its own. TANDEM_AUTOSTART=0 turns it off.
Five webhook events
task.approved, task.completed (done or failed — there is no task.failed), task.claim_expired, task.returned, task.rejected. Claiming, deletes, payload edits, release and requeue are deliberately silent.
Signed, retried, dead-lettered
HMAC-SHA256 over timestamp and raw body, 60s replay window, constant-time compare. Four attempts at 1m / 10m / 1h, then dead-lettered; a stable delivery id is the dedupe key across retries.
SSRF guard in the dialer
Post-DNS and pre-connect, so DNS rebinding is caught — loopback, RFC1918, link-local, CGNAT and multicast are blocked, and redirects are never followed. Off in local mode, where the only receiver is a tandem-mcp listen on the same machine; strict opt-in under TANDEM_AUTH=google.
Webhook config is human-only by construction
Browser session and canvas-owner, so a canvas token or agent credential fails. There is no MCP tool for any of it: an agent must not be able to point the canvas at an endpoint it controls.
tandem-mcp listen
A loopback-only receiver that turns an approval into a process. Trailing-edge debounce folds an epic-approval burst into one trigger, and single-flight means events landing mid-run merge into one pending batch instead of forking a second orchestrator.
05

Documents and context

A canvas is a bag of named documents that humans edit live and agents read scoped. The point is that an agent picking up a ticket gets its context in one call.

Named document tabs
notes, roadmap, folder. Deleting a folder returns its children to the root instead of destroying them.
doc_write / doc_read
Naming a tab that does not exist creates it; passing a note id rewrites in place instead of appending a second copy. The read is scoped server-side, so reading one tab never drags the rest of the canvas along.
Live markdown, no edit/read toggle
An unfocused note renders; clicking in turns the same box into editable source at that caret. Remote pushes are adopted only while unfocused, so an agent's edit never yanks text out from under you.
One-call briefing
context_get returns identity, the briefing doc, document tabs, per-kind counts and the approved queue — and with a task id, that one task hydrated with its linked notes and roadmap items.
Freshness is derived, never stored
Only the verification timestamp is persisted; fresh / aging / stale / unknown is computed at read time, and unknown is explicitly not the same as stale.
Verified is not updated
Vouching for content is a separate act from editing it — a body patch never re-certifies text nobody re-read.
Stale context is shown, never filtered
A stale note stays in the bundle carrying a visible [stale — verified 21d ago] annotation. Hiding it would just make the agent confidently ignorant.
Review feedback comes back on the ticket
One block derived at read time — outcome, reason verbatim, who and when — on task_get, on the epic rollup and in the context bundle.
06

Provenance and receipts

When your collaborators are machines, “who wrote this” is the authorization boundary. Tandem answers it structurally rather than by asking.

Three authorship shapes
human — the browser's session, which no agent path can mint. agent:<id> — the identity is client-asserted but the classification is server-owned, so an agent can never emit a bare human. anonymous — a valid canvas token with neither.
Unknown is not backfilled
Rows predating provenance render nothing at all, rather than being quietly assigned an author that was never recorded.
Completion evidence
task_complete appends commit, PR and branch links — appended, never replaced, so the record of a reworked ticket keeps both attempts.
GitHub links resolve live
Merged, open, draft, checks-ok, failing. Structurally read-only: the route is GET-only and every outbound URL is constructed server-side from a parsed owner/repo/ref, so the caller's URL is never dereferenced.
Unknown renders nothing
When the status cannot be resolved the board shows no chip — absence of a claim, not a claim of absence.
Audit trail and progress log
Content edits that cost an approval are kept distinct from human state moves and reviewer bounces; every heartbeat is an append-only entry rendered as a log on the ticket page.
07

The web workspace

React, Vite and Tailwind, embedded in the server binary, with hand-rolled history routing and no router library. The board's job is that one glance answers “what is waiting, what is claimed, what landed”.

Board

Five columns
Proposed · Ready · Working · Done · Closed, where failed and rejected share the last lane.
Epics are a lens, not cards
A left timeline with state chip, progress, drain time and inline approve/reject on proposed epics. Scope persists per canvas; aged-out epics collapse away.
No drag-and-drop for state
On purpose: a state change is a decision, not a gesture, and has to be as available to a keyboard as to a mouse. Drag exists elsewhere — tabs, docs and roadmap reordering.
Bulk triage
Shift-range, cmd-toggle, arrow stepping and a touch select mode, over an id set that survives live pushes. One shared reject reason, asked once, with an undo.
Search reaches past the current scope
A ticket ref is an address that wins outright; otherwise the same ranked ladder the MCP task_find uses, with out-of-scope hits reported rather than silently omitted.
A proposed epic renders as a plan to review
Plan digest, the plan's own ticket order, and per-ticket quality lines from the same implementation the MCP server runs at propose time.
Card flight
A moving card is cloned into a fixed ghost so the animation is not clipped at the column boundary it is crossing, then lands with a glow — and is skipped entirely under reduced motion.
Lease chips
live / slipping / stale, keeping “last heard from” and “lapses at” as separate facts. Board and fleet derive one identical lease from one function.

Fleet, presence and realtime

The fleet tree
Roster and activity feed in one panel, nesting subagents under the orchestrator that spawned them. Agents holding a claim or recently online are shown; the rest fold behind a disclosure.
Vendor identity is read, never guessed
From the declared model — claude, codex, openai, gemini, the raw string, a bare registered agent, or external for something holding work that never registered.
Follow an agent
Per-canvas, device-local. Moves are coalesced one at a time and buffered while you type — busy is drawn at focus, not at the keyboard.
Push, not poll
One socket with version-gated state application, a bounded outbound queue and a reconnect budget. The connection chip is hidden while connected and is the only signal that a push-only board has gone stale.
Notifications that cannot be muted into silence
Muting silences popups — never the log, never the badge.
A mock backend
The entire UI runs with no server at all, for design work and demos.

Theming, mobile and other canvas modes

Light / dark / system
Tokens are RGB channels flipped by one class, so alpha utilities follow automatically. An inline boot script reads the preference before first paint, so there is no flash.
Mobile is additive, not a copy
The drawer passes the same components as desktop and shares its view state, so the choice is continuous across breakpoints. Safe-area insets, a 48px touch target, and a reduced-motion opt-out on drawer animation.
Three canvas modes
welcome, docs, roadmap — markdown notes with live autosave and nestable roadmap goals, next to the board itself.
08

Local by default, shareable by flag

Local mode has no accounts at all: the browser on your machine is the owner, and agents join a canvas with its code. Everything below the first two rows exists for a self-hosted, shared instance and turns on with one environment variable.

One binary, one file
A Go server with the web bundle embedded and SQLite at ~/.tandem/tandem.db. No environment is required to start it; the JWT secret is generated into the data dir on first run so tokens survive restarts.
Canvas codes
Eight characters over a 32-character ambiguity-free alphabet — no 0/O, no 1/I/L — from a cryptographic source. The code is the view capability an agent needs; ownership stays with the browser.
TANDEM_AUTH=google
Google sign-in validated against Google's rotating keys, accounts, a dashboard per user. Off by default; the header degrades cleanly without it.
Visibility and access (self-hosted)
Public or private, with a public role and per-user grants. Role changes apply live over the socket without a reconnect, and a revoked viewer's socket is closed.
Personal access tokens (self-hosted)
Only the hash is stored and the plaintext is shown exactly once. Least-privilege by construction: the role is still resolved per canvas.
A full OAuth 2.1 authorization server (self-hosted)
Metadata discovery, dynamic client registration, PKCE, authorization code with refresh rotation, resource indicators — with a consent screen and a revoke-app list in account settings, for MCP clients that connect by URL.
The session handle
canvas_connect returns a handle the model carries back on every later call, so the binding survives a transport reset without a reconnect.
09

The MCP surface

Published as @jaximus/tandem-mcp: a stdio MCP server that is also the launcher for the local binary. The default manifest is an 18-tool intent facade, because a CRUD manifest costs a large slice of the context window it is supposed to be helping.

Eighteen tools, advertised by default
canvas_connect canvas_create agent_register context_get queue_next queue_wait task_find task_get task_claim task_progress task_complete task_propose task_amend task_review epic_propose doc_write doc_read board_status
The CRUD surface is still there
Forty-odd tools covering documents, notes, roadmap items, raw actions and agents — each with a batch variant — callable always, but advertised only behind an explicit opt-in. When opted in the manifest is additive, facade first.
Instructions ship with the manifest
The executor loop, the approval gate, the peer exception and the orchestrator rule are served with the tool list, so a client reads them before its first call.
up / open / down / setup
The same package finds or downloads this platform's binary from the GitHub release, verifies it against SHA256SUMS, starts it detached, waits for /healthz and opens the board. setup also registers the server with Claude Code at user scope.
A pinned canvas rewrites the tool description
tandem-mcp init writes the code into a project-scoped config, and the manifest then names this project's canvas — the model reads the manifest, not the process environment.
Zero to connected in one command
tandem-mcp init creates the canvas, merges (never clobbers) your MCP config, prints the code and board URL, and can append an AGENTS.md snippet teaching the queue-first workflow. Re-running is a no-op.
Per-call tracing
MCP_TRACE reports duration, the API time inside it, and ok/error — so a slow tool call is attributable rather than merely slow.
10

Operations

What it takes to run the thing and know whether it is healthy — including the parts that are deliberately small.

In-memory metrics
A bounded ring over a five-minute window, keyed by route patterns and never by concrete ids, with every counter a process-wide scalar — which is exactly why the endpoint can be open.
The schema applies itself
One embedded SQL file and a schema_version table, migrated at boot. A schema change ships in the binary; upgrading is replacing the binary and restarting.
Backups are a file copy
Everything is in ~/.tandem/tandem.db (WAL mode). Copy it, and you have copied every canvas, ticket, claim and audit trail.
Releases are tags
A v* tag builds darwin / linux / windows × amd64 / arm64 with CGO off (the SQLite driver is pure Go) and attaches them to a GitHub Release, which is where the launcher downloads from.

Honest limits

Where a guarantee is really a convention, or a check fails open. Recorded here rather than rounded up, because a capability list is only useful if it is also accurate about its edges.

The fence needs an asserted identity
It engages only when a caller presents a claim identity. A browser and an agent carry identical canvas tokens, so the human board keeps its escape hatch on purpose — and a blank or generic holder is not an exclusive identity and blocks nobody.
The reviewer's model is self-asserted
Cross-model review raises the cost of accidental same-model review. It cannot stop a client that misreports, and comparison fails open when a model is unrecorded — the deliberate opposite posture from the identity checks, so an unknown model cannot brick review on a live canvas.
Local mode is one user
Every browser that reaches the server is the owner. That is right for a laptop and wrong for a shared box — put it behind TANDEM_AUTH=google before anyone else can reach the port.
Remote workers need a reachable server
A CI runner or a cloud sandbox cannot see 127.0.0.1 on your laptop. The board they can share is a self-hosted one; local mode is for the sessions on this machine.
The web mirrors the lease TTL as a constant
Override it in a deployment and the board reads early or late. The ordering of live/slipping/stale stays right.
Out-of-band work is invisible to all of this
Nothing about a claim stops an agent doing work without claiming it — which is how the same feature once got implemented twice in one afternoon. The rule that incident left behind: if the work is happening, the board must say so before it starts.