Real-time project-scoped kanban for humans and AI agents.
Website: tandemcloud.cc
Tandem is a single Rust backend with four first-class clients:
- Desktop app (Tauri + Vue + shadcn-vue)
- Web UI (Vue + shadcn-vue)
- CLI (
tandem) - MCP server (
mcp/) for LLM agents
- HTTP API for task and project operations
- Project-scoped WebSocket stream for live board updates
- Optional bearer auth (
TANDEM_AUTH_TOKENS) - SQLite persistence with in-memory cache
- Pluggable storage boundary (
BoardPersistence) for future backend swaps (for example libSQL) - Integrated developer workflow for local, remote, and Tauri-oriented UI development
flowchart LR
subgraph Clients["Client Surfaces"]
UIBrowser["Vue UI\n(Browser)"]
UIDesktop["Vue UI\n(Tauri WebView)"]
CLI["tandem CLI"]
MCPClient["MCP Host\n(Claude, Codex, etc)"]
end
subgraph Desktop["Desktop Shell (Tauri v2)"]
Runtime["Desktop runtime manager\n(workspace settings + mode switch)"]
Settings["workspace-settings.v1.json"]
LocalProc["Local server subprocess\n(--tandem-local-server)"]
end
subgraph MCP["MCP Adapter (mcp crate)"]
MCPServer["tandem-mcp\nstdio server"]
MCPSubs["Resource subscription manager\nclient-id aware subscribe/unsubscribe"]
MCPWS["WebSocket listener\n(reconnect + fan-out)"]
end
subgraph Server["Tandem Server (axum)"]
HTTP["REST API\n/api/tasks /api/projects\n/api/agent-runs"]
WS["WebSocket /ws\nproject-filtered events"]
Access["Access control\nAuthorization + X-Tandem-Project"]
State["AppState\nbroadcast + webhook + storage"]
Presence["Presence registry\n(online/recent clients)"]
Runs["Agent run state\n(in-memory queue/history)"]
end
subgraph Storage["Persistence Layer"]
Trait["BoardPersistence trait"]
Sqlite["SqliteBoardPersistence"]
DB[("tandem.db (SQLite)")]
Future["Future backend\n(e.g. libSQL)"]
end
subgraph SessionRuntime["Session Runtime (tandem-session)"]
Main["main\ncoordinator + SSE aggregator"]
Worker["worker\nproject-local executor"]
PI["pi-coding-agent\n(pi-rpc / pi-sidecar)"]
end
Webhook["Webhook endpoint (optional)"]
UIBrowser -->|HTTP| HTTP
UIBrowser -->|WS| WS
UIDesktop -->|invoke| Runtime
Runtime --> Settings
Runtime -->|start/stop| LocalProc
UIDesktop -->|effective HTTP| HTTP
UIDesktop -->|effective WS| WS
LocalProc -->|hosts| HTTP
LocalProc -->|hosts| WS
CLI -->|HTTP| HTTP
MCPClient -->|MCP stdio| MCPServer
MCPServer -->|HTTP tools| HTTP
MCPWS -->|WS events| WS
MCPServer --- MCPSubs
MCPSubs --- MCPWS
HTTP --> Access
WS --> Access
Access --> State
State --> Presence
State --> Runs
State --> Trait
Trait --> Sqlite
Sqlite --> DB
Trait -.swappable boundary.-> Future
State -->|broadcast WsMessage| WS
State -->|task notifications| Webhook
Runs -->|POST /run| Main
Main <-.SSE /event.-> State
Main -->|POST /run by project| Worker
Worker -->|POST /worker/event| Main
Worker -->|POST /worker/heartbeat| Main
Worker -->|spawns + control channel| PI
PI -.events / stream.-> Worker
git clone https://github.com/nasedkinpv/tandem-oss
cd tandem-oss
cargo build
npm installmake uiThis starts Tandem on http://127.0.0.1:8080 and serves UI from ./dist when available (or ./static as fallback).
# add
target/debug/tandem add "Review API contract" --priority high --tag api --tag docs
# create/list projects
target/debug/tandem project create "Platform Core"
target/debug/tandem project list
target/debug/tandem project update platform-core --context-file ./AGENTS.md
# list (optional tag/column filters)
target/debug/tandem list --column todo --tag api
# cross-project dashboard
target/debug/tandem overview
target/debug/tandem overview --stale-days 10 --stuck-days 5 --quiet
# update
target/debug/tandem edit <id> --column doing --add-tag in-progress
# update task inside a specific source project scope
target/debug/tandem edit <id> --project platform-core --title "Renamed from CLI"
# move task between projects (source scope + explicit target project)
target/debug/tandem edit <id> --project default --to-project platform-core
# move command supports the same project flags
target/debug/tandem move <id> done --project default --to-project platform-core
# delete
target/debug/tandem delete <id> -ymake uiTANDEM_REMOTE_URL=http://<remote-host>:8080 make ui-remoteWhen Vite is available, /api and /ws are proxied to TANDEM_REMOTE_URL, so UI Workspace Settings can stay default in dev mode.
Fallback mode (no Vite) serves static files only. In that case set UI Workspace Settings manually:
API Base URL:http://<remote-host>:8080WebSocket mode:Auto(orManualwith explicit URL)Auth Token: optional bearer tokenProject: project slug or*(UI fetches all projects and aggregates)
# local backend
make mcp
# remote backend
TANDEM_URL=http://<remote-host>:8080 make mcpnpm run ui:dev:tauri
npm run ui:build:tauri# run desktop app in dev mode
npm run tauri:dev
# build desktop app bundle
npm run tauri:buildDesktop runtime behavior:
- Default mode is
local: app launches a managed Tandem subprocess on startup and UI auto-connects. - If workspace mode is
remote, desktop app does not start local subprocess. - Settings are persisted per desktop workspace in
workspace-settings.v1.json(app config dir). - Connection mode can be switched to
remote; optionalstopWhenRemotestops local subprocess automatically. - WebSocket config is explicit:
wsMode=auto|manualwithwsUrlManualpersisted separately (no implicit loss). - Workspace Settings are grouped into categories:
Connection,Agent Runtime,Identity,Defaults,Advanced.
Optional build-time default backend for desktop shell:
VITE_TANDEM_DEFAULT_API_BASE_URL=http://127.0.0.1:8080 npm run ui:build:tauri# build for current host target and copy to ./bin
make build-bin
# explicit macOS Apple Silicon output
make build-bin-macos-arm64Outputs are written to ./bin with platform suffixes (for example tandem-macos-arm64, tandem-mcp-macos-arm64).
| Method | Path | Notes |
|---|---|---|
GET |
/api/health |
Readiness endpoint with DB check (200 {"status":"ok","db":"ok"} or 503 {"status":"degraded","db":"error"}) |
GET |
/api/capabilities |
Runtime feature flags (for example agentRuns.executorEnabled) |
GET |
/api/overview |
Cross-project aggregates (todo/doing/done/stale/stuck) with ?stale_days and ?stuck_days thresholds |
GET |
/api/tasks |
Cursor paging (?limit, ?cursor) + filters (?tag=..., ?column=..., `?sort=asc |
GET |
/api/tasks/search |
Cursor-paged search (?q required) with optional tag/column/sort/limit/cursor |
POST |
/api/tasks |
Create task |
GET |
/api/tasks/:id |
Fetch one task (project-scoped) |
PATCH |
/api/tasks/:id |
Partial update with validation (including optional project move) |
DELETE |
/api/tasks/:id |
Delete task |
GET |
/api/agent-runs |
List run states for active project (task_id, limit) |
POST |
/api/agent-runs |
Dispatch a new agent run for a task (optional model override; optionally forwards webhook to external executor) |
POST |
/api/agent-runs/:run_id |
Update run status (`queued |
GET |
/api/projects |
List projects |
POST |
/api/projects |
Create project (name, optional slug, optional context) |
PATCH |
/api/projects/:slug |
Update project metadata (name, context) |
DELETE |
/api/projects/:slug |
Delete empty project (default project is protected) |
POST |
/api/projects/:slug/copy |
Clone project metadata + tasks (include_done option), reset task authorship |
GET |
/api/projects/:slug/members |
List project members (`owner |
PATCH |
/api/projects/:slug/members/:member_id |
Update member role (owner only) |
DELETE |
/api/projects/:slug/members/:member_id |
Remove member (owner only, cannot remove last owner) |
GET |
/api/projects/:slug/invites |
List invites for project (owner only) |
POST |
/api/projects/:slug/invites |
Create invite + one-time claim (owner only) |
POST |
/api/invites/:id/revoke |
Revoke invite (owner only) |
POST |
/api/invites/accept |
Accept invite by claim and upsert membership |
GET |
/api/tokens |
List token metadata for active project (owner only) |
POST |
/api/tokens |
Issue scoped token (plaintext returned once) |
POST |
/api/tokens/:id/revoke |
Revoke token |
POST |
/api/tokens/:id/rotate |
Rotate token (revokes old, returns new plaintext once) |
GET |
/api/ws/replay |
Replay buffered project-scoped websocket events since cursor (project, since, limit) |
GET |
/api/ws/presence |
Presence snapshot + timeout diagnostics for one project (project) |
GET |
/api/project-chat/events |
Replay buffered project-chat websocket events (project, since, limit, optional session_id) |
GET |
/ws |
WebSocket stream (project-scoped) |
GET /api/capabilities includes agentRuns flags:
executorEnabled: executor webhook is configured on server.executorMode: executor mode (session_runtime).executorReachable: last cached probe indicates endpoint is reachable.executorAuthFailed: last probe returned401/403(auth mismatch).executorLastError: sanitized probe error text (no token values).executorCheckedAt: RFC3339 timestamp of latest probe sample.sessionRuntimeSse: runtime SSE bridge snapshot:enabled: bridge configured and started.mode:shadow|activewhen configured.connected: current stream connectivity state.lastEventId/lastEventAt: last observed runtime SSE event.lastError/checkedAt: last stream failure and observation timestamp.
sessionRuntimeMain: runtime main/healthsnapshot:reachable,service,at,workers,mainSessionWake.components[]with per-componentstatus,lastError,lastOk,updatedAt,restartCount.
sessionRuntimeWorkers: runtime main/workersnapshot:reachable,checkedAt,lastError.workers[]entries:workerId,project,baseUrl,directory,activeRuns,lastSeen,agentRunAvailable.
sessionRuntimePollRecovery: SSE fallback poll status:enabled,lastAttemptAt,lastSuccessAt,lastError,lastRecoveredRuns.
sessionRuntimeWsReplay: websocket replay endpoint snapshot:enabled,lastAttemptAt,lastSuccessAt,lastErrorAt,lastError,lastTruncatedAt,truncatedCount,lastReplayedEvents,lastReplayedPages.
UI policy:
- Show
Run Agentonly when executor is configured. - If probe sample exists and
executorReachable=false, show disabled/warning state before dispatch. - In
session_runtime, showRunfor a task only when current project has discovered worker(s) withagentRunAvailable=true. - Runtime settings should expose topology/discovery panel:
- main health summary,
- worker topology (
host/project/lastSeen/activeRuns/event lag), - discovery coverage for known projects,
- SSE + poll-recovery diagnostics.
Authorization: Bearer <token>whenTANDEM_AUTH_TOKENSis configured- Issued tokens from
/api/tokensare also accepted:- stored as SHA-256 hash
- reject immediately when revoked/expired
- enforce declared scope on API + WebSocket paths
X-Tandem-Project: <slug>for project scope- Optional client identity headers:
X-Tandem-Client-IdX-Tandem-Client-NameX-Tandem-Client-TypeX-Tandem-Avatar-Seed
- WebSocket supports query fallbacks for browser clients:
/ws?token=<token>&project=<slug>&client_id=<id>&client_name=<name>&avatar_seed=<seed>&client_type=<type>
Task payloads now include actor metadata fields:
created_by(who created task)created_by_user_id(stable creator user id)updated_by(who last edited task)updated_by_user_id(stable last-editor user id)
- Title: required, max 500 chars
- Content: max 10,000 chars; empty string clears content on update
- Tags: max 20 tags, each non-empty, max 50 chars
- Priority:
low|medium|high(default:medium) - Project slug: lowercase letters, digits,
-, max 64 chars - Project context: optional markdown, max 10,000 chars (empty string clears on update)
PATCH /api/tasks/:idwithprojectrequires target project to exist- Invite
target_email: optional, max 320 chars - Invite
expires_in_minutes: optional, defaults to 1440, max 10080 - Token
label: optional, max 120 chars - Token
expires_in_minutes: optional, defaults to 1440, max 10080 (0means immediate expiry) - Token
scopes:workspace:*orproject:<slug>:read|write|admin(project scopes must match active project)
All websocket frames include monotonic event_id cursor field for replay/recovery.
tasks_loadedtask_addedtask_updatedtask_movedtask_deletedpresence_snapshotpresence_changedagent_run_dispatchedagent_run_updatedagent_run_output_deltaproject_chat_message
SQLite file defaults to ./tandem.db.
Main tables:
projectsusersapi_tokensproject_membershipsproject_invitesaudit_eventstasks
The runtime keeps an in-memory board cache under RwLock and persists every mutation through BoardPersistence.
Agent run state is intentionally in-memory only (bounded per project).
TANDEM_BIND(default127.0.0.1:8080)TANDEM_DB_FILE(default./tandem.db)TANDEM_STATIC_DIR(default./distif present, else./static)TANDEM_CORS_ORIGIN(default*)TANDEM_AUTH_TOKENS(comma-separated bearer token allowlist)TANDEM_DEFAULT_PROJECT(defaultdefault)TANDEM_WEBHOOK_URLTANDEM_WEBHOOK_TOKENTANDEM_AGENT_EXECUTOR_URL(optional outbound endpoint foragent_run_dispatchedpayloads)TANDEM_AGENT_EXECUTOR_TOKEN(optional bearer for executor endpoint)TANDEM_AGENT_EXECUTOR_TIMEOUT_MS(optional timeout, default10000)TANDEM_AGENT_EXECUTOR_MODE(optional, currentlysession_runtime)TANDEM_SESSION_RUNTIME_SSE_MODE(optional:off,shadow,active; if unset and executor mode issession_runtime, defaults toactive)TANDEM_SESSION_RUNTIME_SSE_URL(optional override for runtime main SSE endpoint; default is derived from executor URL as<base>/event)TANDEM_SESSION_RUNTIME_SSE_TOKEN(optional bearer for runtime SSE stream; defaults to executor token)TANDEM_SESSION_RUNTIME_SSE_RECONNECT_MS(optional SSE reconnect delay, default750)TANDEM_WS_EVENT_HISTORY_LIMIT(optional in-memory per-project websocket replay buffer size, default4000, clamp100..20000)TANDEM_AGENT_RUN_QUEUE_TIMEOUT_SECS(optional stale queue timeout, default180)TANDEM_AGENT_RUN_RUNNING_TIMEOUT_SECS(optional stale running timeout, default900)RUST_LOG
Legacy executor aliases were removed:
TANDEM_EXECUTOR_URL->TANDEM_AGENT_EXECUTOR_URLTANDEM_EXECUTOR_TOKEN->TANDEM_AGENT_EXECUTOR_TOKEN
TANDEM_URL(defaulthttp://localhost:8080)TANDEM_TOKENTANDEM_PROJECT(default project scope; CLI--projectoverrides it per command)TANDEM_CLIENT_NAME(optional actor label override for CLI/MCP)TANDEM_CLIENT_ID(optional stable client id override)TANDEM_CLIENT_ID_FILE(optional path for persisted client id file)TANDEM_WS_URL(MCP only; optional explicit WS override)
VITE_TANDEM_DEFAULT_API_BASE_URLTANDEM_REMOTE_URL(used by Vite proxy in remote dev mode)TANDEM_UI_HOST,TANDEM_UI_PORT
# tests
cargo test
cd mcp && cargo test
# strict lint
cargo clippy --all-targets -- -D warnings
cd mcp && cargo clippy --all-targets -- -D warnings
# dead-code scans (UI + optional rust dependency audit)
make deadcode
TANDEM_REQUIRE_UDEPS=1 ./scripts/check-rust-udeps.shIntegration coverage includes:
tests/api_integration.rs(API behavior, validation, project scoping)src/cli/http_client.rs(CLI cursor pagination for list/search +/api/tasks/search404 fallback)mcp/src/client.rs(MCP cursor pagination for list/search + defensive cursor-loop checks)- token lifecycle and scope isolation (
issue/list/revoke/rotate, revoked/expired rejection) mcp/tests/smoke.rs(MCP end-to-end list/filter and project create/update/move smoke against mock Tandem API)
- MCP details:
mcp/README.md
Elastic License 2.0 (ELv2). See LICENSE.
Third parties may use/modify the software, but may not offer Tandem itself as a hosted or managed service under ELv2.
