Skip to content

Maxmedawar/tandem

Repository files navigation

tandem

An MCP bridge that lets a chat AI (Claude.ai, ChatGPT) spawn and drive real Claude Code sessions on your own machine.

What it is

tandem is a bridge between a chatbot and your computer: the chat AI connects over a URL you control and drives real, interactive Claude Code sessions running locally — opening them, sending work, and reading back results — while you watch or type alongside in your own terminal. It can also run two sessions autonomously: a manager that plans and reviews hands work to a worker that builds, relaying back and forth with no human in the loop. Everything runs on your machine; the only thing that leaves it is the tunnel you started yourself.

Quick start

  1. Install the prereqs:
    • Node 22.6+
    • tmux
    • Claude Code CLI (claude)
    • Tailscale:
      • macOS: brew install --cask tailscale-app (the standalone GUI app; the Mac App Store version does not support Funnel)
      • Linux: curl -fsSL https://tailscale.com/install.sh | sh
  2. Get tandem: git clone https://github.com/Maxmedawar/tandem.git && cd tandem && ./setup.sh
  3. Pick Tailscale when the script asks which mode (it's the default — just press Enter). You get a permanent URL you set up once and never touch again.
  4. Sign into Tailscale when prompted — open the Tailscale app and log in (one time, free account).
  5. Copy the MCP URL the script prints (https://<machine>.<tailnet>.ts.net/<token>/mcp).
  6. Paste it into your chat app: Claude.ai or ChatGPT → Settings → Connectors → Add custom connector.
  7. Install the Claude Chrome extension ("Claude for Chrome" from the Chrome Web Store) and sign in — this lets sessions report back to your chat.
  8. Say "start tandem" in your chat, and you're off.

Full walkthrough + troubleshooting: see SETUP.md.

How it works

tandem runs a small MCP server on your machine and exposes it one of three ways:

  • Tailscale Funnel (recommended): a persistent public URL — https://<machine>.<your-tailnet>.ts.net — that never changes across restarts. Free on every Tailscale plan, real HTTPS, no interstitial page. One-time login, then set-and-forget.
  • Cloudflare quick tunnel: a free, anonymous https://<random>.trycloudflare.com URL — no account at all, but the URL changes every run.
  • Local stdio (desktop): no network, no tunnel — for Claude Desktop / ChatGPT desktop.

A chat AI connects to that URL and can open a real, interactive claude session, talk to it, and watch it work, while you sit at the same terminal.

Sessions are real interactive Claude Code TUIs running inside tmux (ccm-<name>), driven by keystroke injection and screen scraping — not claude -p / headless — so usage stays on your normal Claude Code subscription. You can tmux attach -t ccm-<name> to watch or type alongside the AI. Everything runs locally; the only thing that leaves your machine is the tunnel you started yourself.

Capabilities

  • Drive a live Claude Code session — open, send turns to, and read back from an interactive claude session running locally in tmux.
  • Shared live session — you and the chat AI both interact with the same tmux session; tmux attach lets you watch or type alongside, and reads are incremental (cursor-based) so neither side blocks the other.
  • Turn-completion detectionsend_to_session holds open until the turn finishes (detected via Claude Code's "esc to interrupt" marker plus screen-stability); if a turn runs long it returns status:"running" and you poll read_session until idle:true.
  • Autonomous lead/worker relay — two interactive sessions message each other with no human in the loop: a lead strategist hands one step at a time to a worker and reviews results, relaying until the lead emits RELAY_DONE or a turn cap is hit. Steer it live with inject.
  • Persistent manager (park-and-wait) — the lead doesn't die when a task finishes: it parks (idle, alive, keeping its on-disk memory) and waits for the next task you enqueue, running each under a fresh per-task budget, until you stop it, it sits idle past a timeout, or it escalates that it's stuck.

What this can't do (honest limits)

  • It cannot wake the claude.ai chat tab. A remote MCP connector is request/response; the stateless Streamable-HTTP transport here holds no standing server→client channel. tandem can emit a completion signal (events.log + optional webhook) and ping a device (your phone, via ntfy), but it cannot make the claude.ai chat send an unprompted reply. See Completion events / waking the client below.
  • The ntfy push reaches a device, not the chat. It tells you the work is done so you can return to the chat — it does not resume the conversation.
  • It is not a hosted/multi-tenant service. Each user runs their own local bridge and their own tunnel; there is no shared server.

Tools

Six tools:

  • open_session — spawn an interactive session in an allowlisted dir. Skip-permissions (autonomous) by default; optional model / effort set the session model and thinking effort (session-scoped, via claude --model / --effort).
  • list_sessions — list live + recent sessions.
  • send_to_session — send a prompt and wait (bounded by TANDEM_WAIT_MS) for the turn; returns the report, or status:"running" to call again. Omit text for poll mode (fetch new output since cursor without sending) — this replaces the old read_session. Accepts slash commands verbatim (see below) and optional per-turn model / effort overrides.
  • interrupt_session — Ctrl-C the current turn, keep the session.
  • close_session — kill the session.
  • relay — one tool with action: start | read | enqueue | inject | stop for the autonomous lead/worker relay (replaces the old start_relay / read_relay / inject_to_relay / stop_relay). The lead is a persistent manager: when a task finishes it parks and waits; enqueue hands it the next task (see Persistent manager below).

Consolidated from 10 → 6; no capability was removed (the underlying routes are unchanged and still reachable).

Autonomy & control

Skip-permissions by default. Spawned sessions launch with --dangerously-skip-permissions so autonomous turns don't stall on allow-prompts. Disable per host with TANDEM_SKIP_PERMISSIONS=0. This is contained: it only suppresses Claude Code's in-session tool prompts. The cwd allowlist is enforced before every spawn (in open_session/relay and again inside the engine's spawn), and the pane is created in the already-validated cwd — so skipping prompts can never widen which directories are reachable. (On a host that has never accepted bypass mode and lacks skipDangerousModePermissionPrompt, Claude Code shows a one-time acceptance dialog; the engine auto-accepts it on warmup.)

Model & effort. Set them per session at open time, or override per turn on send:

Param Accepted values
model alias default / opus / sonnet / haiku, or a full claude-* id (e.g. claude-opus-4-8)
effort low / medium / high / xhigh / max
  • open_session{ model?, effort? } → session-scoped claude --model / --effort flags (no global side effect).
  • send_to_session{ model?, effort? } → applied to that turn via in-session /model / /effort controls (these also persist as Claude Code's saved default for new sessions — prefer open-time for strictly session-scoped control).
  • Unsupported values are rejected with a clear 400, never silently ignored.

Slash-command passthrough. Any slash command sent as send_to_session's text reaches the TUI verbatim and executes — the autocomplete's exact match resolves on the submit. Examples:

send_to_session { "name": "s1", "text": "/status" }     // session/model/account status
send_to_session { "name": "s1", "text": "/mcp" }        // MCP server status
send_to_session { "name": "s1", "text": "/model opus" } // switch model
send_to_session { "name": "s1", "text": "/goal ship the parser" } // custom command

Completion events / waking the client

tandem emits a completion event the moment a turn or relay finishes — you don't have to keep polling to learn that work is done. Detection reuses the engine's proven idle/done logic (the "esc to interrupt" marker + screen-stability for turns; RELAY_DONE / cap for relays).

What is emitted — a JSON object:

{ "ts": "", "type": "session", "status": "done", "id": "<session|loopId>", "cursor": 12345, "summary": "", "reason": "", "handoff": "CC check — session \"\" finished (done).\nSummary: …\nCommit: …\nFiles changed: …\nNext: …" }

The handoff field is a chat-ready, copy-pasteable plain-text block (also used as the phone notification body — see Phone notifications below).

Where it goes (the EMIT side, which this repo implements):

  1. ~/.tandem/events.log — one JSON line is appended per completion. Durable; tail -f it or have any local process watch it.
  2. TANDEM_DONE_WEBHOOK — if set, the same JSON is POSTed to that URL (fire-and-forget, no deps). Point it at any local listener, notifier, or automation.

Phone notifications (ntfy)

For a real buzz on your phone when a session finishes, tandem can push to ntfy (free, no account) on top of the event emit above. It's off until you set a topic. Three steps:

  1. Install the ntfy app (iOS App Store / Google Play), or use the web app.
  2. Subscribe to a topic in the app — pick a long, hard-to-guess name (anyone who knows the topic can read it), e.g. tandem-9f3a2c-done.
  3. Set TANDEM_NTFY_TOPIC in your .env to that exact topic (and optionally TANDEM_NTFY_SERVER if you self-host ntfy; default https://ntfy.sh).

Now each completion sends a notification titled tandem: <session id> done whose body is a chat-ready handoff block — the same plain-text block written to events.log and POSTed to the webhook. On the phone it looks like:

🔔 tandem: my-session done
CC check — session "my-session" finished (done).
Summary: added the handoff block to completion events
Commit: 880f6e0
Files changed: 3
Next: Review the session output and decide the next step.

The notification carries a Click action pointing at https://claude.ai, so tapping it opens claude.ai on your phone. There you paste check (or paste the handoff block itself) and the chat Claude immediately picks up — it knows what finished, the commit, how many files changed, and what to do next, without you re-typing any context. The POST is fire-and-forget; if ntfy is unreachable the failure is logged to ~/.tandem/bridge.log and the bridge keeps running. (Commit hash and file count are read with a bounded, 3s-timeout git call in the session's cwd; if it isn't a git repo they fall back to none / unknown.)

Honest note: this pings a device (your phone / the ntfy app) — it does not and cannot wake the claude.ai chat or post a reply there. It tells you the work is done and hands you a paste-ready block, so going back to the chat is one tap + one paste; the chat client still can't be woken by a server-initiated signal on its own (see below).

Persistent manager: disk-backed memory + escalation

The autonomous relay runs a lead ("manager") session that drives a worker. Its working state lives on disk, not just in a context window, so it survives context compaction within a run, under ~/.tandem/manager/<loopId>/:

  • MISSION.md — the standing definition of "done" (written once, re-read each turn).
  • STATE.json — the working set: status (running / parked / blocked / done), turn, current task, and blockedReason.
  • LOG.md — an append-only decision log, one line per turn.
  • QUEUE.json — pending tasks (FIFO), durable on disk.

Each turn the manager is re-grounded from these files (mission + recent decisions are re-fed into the lead), so continuity comes from re-reading disk rather than from a process "staying alive."

Park-and-wait (the manager doesn't die). When a task finishes (DONE or a per-task cap), the manager does not tear down — it emits a per-task completion event, sets STATE.json to parked, and waits, idle and alive, for the next task. Hand it one with relay { action: "enqueue", loopId, task }: the task is persisted to QUEUE.json (FIFO, bounded to 64 pending) and a parked manager wakes immediately and runs it under a fresh per-task budget (turn cap

  • wall-clock reset). The wait is a single awaited signal — no busy-polling, no burned turns. The manager only tears down (closing both tmux sessions) on an explicit stop, after sitting idle past a timeout (default 15 min, max 1 h), when it escalates BLOCKED, or on a fatal error. So one long-lived manager can take task after task while keeping its mission and decision history — instead of you spinning up a throwaway pair each time.

Limitation (restart does not auto-resume). The memory and queue files are durable on disk, but the bridge does not yet re-adopt a parked manager after a process restart: a fresh bridge mints new loop ids and does not scan ~/.tandem/manager/*, so a manager that was parked when the bridge died is orphaned — its relay-<id>-lead/-worker tmux sessions keep running and must be reaped by hand (tmux kill-session). Auto-resume + an orphan reaper are Phase 6c.

When does it buzz your phone? Deliberately only when it's worth your attention — not on every step:

Event Phone push? ntfy title
Routine task finished (manager parks for the next) No (logged only)
Manager asks you a question (NEEDS_INPUT) Yes, urgent tandem: <id> NEEDS YOUR ANSWER
Manager fully finished (stop / idle-timeout / all done) Yes tandem: <id> done
Manager hit a terminal dead-end (BLOCKED) Yes, urgent tandem: <id> NEEDS YOU

Every event is still written to events.log regardless; only the phone push is gated, so routine progress stays durable without buzzing you.

Needs input — ask, stay alive, resume. When the manager needs an answer to continue (not a dead-end, just a question), it emits NEEDS_INPUT: <question> on its own line. It then parks alive (it does not tear down), buzzes you urgently with the question, and waits — for a longer window than the routine idle park (default 1 h, max 6 h). You answer the same way you add work: through the chat, relay { action: "enqueue", loopId, task: "<your answer>" }. The first enqueue after a question is treated as the answer and resumes the same task (the lead re-grounds from its mission + decision log). So the full loop is: manager asks → phone buzzes → you tell the chat → chat enqueues the answer → manager resumes. If you never answer, it tears down at the answer-timeout (one final done buzz). BLOCKED remains the separate terminal escape hatch for a genuinely unrecoverable dead-end.

This is the one place a device-push is the right primitive: you are the only node at the top that can actually be woken — and the chat is where you go to push the answer back down.

The missing piece (client side, out of scope / not under our control): turning a completion event into an unprompted chat reply requires the chat client to be woken by it. Today claude.ai chat cannot be woken this way — a remote MCP connector is request/response, and the stateless Streamable-HTTP transport here holds no standing server→client channel to deliver a server-initiated notification to the chat UI. So tandem gives you the reliable signal (events.log + webhook); bridging that into an automatic message would need a client that polls events.log/the webhook and re-prompts the model — which only works in a harness you control, not in claude.ai chat as it exists now.

Setup modes (full details)

New here? Follow SETUP.md for the guided 10-minute walkthrough — this section is the full reference for all three modes.

Prerequisites

  • Node ≥ 22.6 (the bridge runs TypeScript directly via native type-stripping)
  • tmux (the session engine)
  • tailscale (tailscale mode only — the recommended persistent tunnel; see the macOS note below)
  • cloudflared (quick mode only — the free anonymous tunnel; desktop/stdio doesn't need it)
  • claude (Claude Code CLI) on your PATH

setup.sh first asks one question — tailscale, quick, or desktop? — and defaults to tailscale (the persistent URL). Skip the prompt with TANDEM_SETUP_MODE=tailscale|quick|desktop ./setup.sh for scripted installs (web is accepted as a legacy alias for quick).

Persistent setup (recommended): Tailscale Funnel

One login, one stable URL — https://<machine>.<your-tailnet>.ts.net/<token>/mcp — that never changes across restarts. Funnel is free on all Tailscale plans, needs no domain, terminates real HTTPS on your machine, and shows no interstitial/warning page.

  1. Install Tailscale.
    • macOS: brew install --cask tailscale-app — the standalone app.

      ⚠️ The sandboxed Mac App Store version of Tailscale cannot run Funnel. If you installed from the App Store, replace it with the standalone app. (Advanced/headless alternative: brew install tailscale

      • sudo brew services start tailscale — CLI only, no GUI.)
    • Linux: curl -fsSL https://tailscale.com/install.sh | sh
  2. Log in once: tailscale up (or open the Tailscale app and log in).
  3. Run setup: ./setup.sh and press enter — tailscale is the default. It checks prerequisites, npm installs, generates (or reuses) your TANDEM_TOKEN, starts the bridge on localhost:8787, runs tailscale funnel --bg 8787 (public 443 → local 8787), verifies https://<host>/health answers, and prints your MCP URL + connector JSON (also saved to .tandem/connector.json).
  4. Paste the MCP URL into Claude.ai → Settings → Connectors → Add custom connector. For Claude Code instead: claude mcp add --transport http tandem <your MCP URL>.

The URL is permanent. The hostname is your machine's stable MagicDNS name and the token is reused from .env, so the URL survives reboots and re-runs. The funnel itself is owned by the Tailscale daemon (--bg persists it across reboots); only the bridge process needs restarting — re-running ./setup.sh just re-attaches and prints the same URL. It only changes if you rename the machine, switch tailnets, or replace TANDEM_TOKEN. Stop sharing any time with tailscale funnel reset.

Two one-time tailnet toggles you may hit (the script detects both and prints the fix):

Instant fallback: Cloudflare quick tunnel

No account of any kind — TANDEM_SETUP_MODE=quick ./setup.sh starts the bridge behind your own free, anonymous https://<random>.trycloudflare.com quick tunnel, gated by the same TANDEM_TOKEN, and prints the connector JSON to paste into Claude.ai → Settings → Connectors → Add custom connector. It never reuses anyone else's URL or token — the tunnel is yours and disappears when you stop it. The trade-off: the URL changes every run, so you re-paste the connector after each restart (that's why Tailscale is the recommended default).

Local desktop (stdio): no tunnel at all

For desktop apps — TANDEM_SETUP_MODE=desktop ./setup.sh. The app spawns the bridge directly as a child process over stdio. No HTTP server, no tunnel, and no token — a stdio server can only be driven by the local app that spawned it, the same trust as your own terminal. The cwd allowlist is still enforced on every spawn. setup.sh prints (and saves to .tandem/desktop-connector.json) the connector config:

{
  "mcpServers": {
    "tandem": {
      "command": "node",
      "args": ["--experimental-strip-types", "/path/to/tandem/src/stdio-server.ts"]
    }
  }
}

For Claude Desktop, merge the tandem entry under mcpServers in ~/Library/Application Support/Claude/claude_desktop_config.json and restart the app. For ChatGPT desktop, add a local MCP server with the same command + args. Nothing keeps running between chats — the app spawns the server on demand (npm run start:stdio runs it manually). The server reads .env (allowlist etc.) on its own, from the file next to package.json. Neither tailscale nor cloudflared is required for desktop mode.

Connector icon. The server ships an icon (the Claude Code crab) so the connector and its tools show it instead of a blank glyph. It's exposed two ways, both unauthenticated and non-sensitive: as the MCP icons metadata (a self-contained data: URI in the server info) and at GET /favicon.ico / GET /icon.png — so a client that reads the spec field or one that just fetches the origin's favicon (like claude.ai) both pick it up. To change it, replace assets/claudecode-icon.png and regenerate the embedded copy in src/icon.ts (base64 -i assets/claudecode-icon.png).

Configuration

All config lives in .env (copied from .env.example, git-ignored):

Variable Required Meaning
TANDEM_TOKEN ✅ (HTTP/tunnel only — not used by stdio) Shared secret; every HTTP request must present it. The local stdio path needs none.
TANDEM_CWD_ALLOWLIST recommended Colon-separated absolute paths the bridge may operate in. If empty, defaults to $HOME and its immediate child dirs — narrow this.
TANDEM_DEFAULT_CWD Default working dir when a call omits cwd (default $HOME).
TANDEM_SKIP_PERMISSIONS Spawn claude in skip-permissions (autonomous) mode so turns don't stall on allow-prompts. Default on; set 0/false/no/off to require normal prompts. Only suppresses in-session tool prompts — the cwd allowlist is still enforced before every spawn, so it never widens reachable dirs.
TANDEM_HOST / TANDEM_PORT Local bind address (the Tailscale funnel or quick tunnel points here). Default 127.0.0.1:8787.

Runtime artifacts (session transcripts, audit log) are written to ~/.tandem/.

Security

Read this before exposing the bridge. For the full trust model and how to report a vulnerability, see SECURITY.md.

  • The bridge runs real commands on your machine. Anyone with your tunnel URL and token can drive Claude Code sessions in your allowlisted folders. Treat the token like a password.
  • A token is mandatory. The server refuses to start without TANDEM_TOKEN, and rejects (HTTP 401) every request whose token doesn't match — via Authorization: Bearer, ?token=, or the /<token>/mcp path.
  • Directory allowlist. Sessions and relays can only be opened inside the allowlist. Paths are realpath-canonicalized and boundary-checked, so ../ traversal, symlink escapes, and prefix look-alikes (/code-evil vs /code) are rejected. Keep the list as narrow as possible. Skip-permissions does not relax this — the allowlist check runs before spawn whether or not prompts are skipped, and a cwd outside it still returns 403.
  • Only ccm-* tmux sessions are drivable, and relay-owned sessions are isolated from the generic session tools. Every spawn/send/interrupt/close/relay action is appended to ~/.tandem/bridge.log.
  • The Funnel endpoint is public internet — the token gate is identical and mandatory. A Tailscale Funnel URL is reachable by anyone, exactly like a quick-tunnel URL; the difference is persistence, not exposure. Tandem treats both the same: every request must present TANDEM_TOKEN. Funnel additionally terminates TLS on your own machine with a real per-host cert and shows no interstitial page.
  • You run your own tunnel. Both tunnels are started locally by you — the quick tunnel is anonymous, the funnel runs under your own Tailscale account; nothing routes through the author's machine or cloud. Stop sharing instantly: kill the cloudflared process (quick) or run tailscale funnel reset (tailscale).
  • No secrets in the repo. Tokens and URLs come only from .env / generated runtime files, all git-ignored.

License

MIT — see LICENSE.

About

Tandem is an MCP bridge that lets a chatbot (Claude.ai or ChatGPT) spin up and drive real, interactive Claude Code sessions on your own machine. Runs 2 sessions in a lead/worker loop that works autonomously. Also makes claude code respond to the initial Chatbot through the browser so they can talk.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors