Claude OPP.

One Claude Code session dispatches work to another machine.

A small zero-dependency Node daemon on each machine. A session on machine A sends a prompt to machine B, where it runs as a real headless Claude Code turn (claude -p) inside a target repo, with tools. The result comes back to A.

  • MIT
  • Node >= 18
  • zero dependencies
  • macOS / Linux / Windows

It removes the copy-paste round trip

If you already run Claude Code on two machines, you know the loop: write the prompt on one, paste it into the other, wait, paste the output back. The bridge deletes that loop.

No shuttling text

The prompt is dispatched over your private network and the result is returned in the same call. Nothing goes through your clipboard.

Real turns, not RPC

The remote side runs a full Claude Code turn with tools, in a real repo. It can read files, run bash, and fan out its own subagents.

Memory that persists

The daemon resumes one session per repo plus label, so consecutive prompts against the same thread share context.


Dispatch a turn, get the answer

From your laptop to a build server. The header line reports the machine, repo, label, session id and elapsed time; the trailer reports turns, context size and cost.

workstation
$ node bridge/client.js run server --repo /home/you/projects/app "run the test suite and summarize the failures"
[server /home/you/projects/app label=default 6f1c1e0a-... 48213ms]
3 tests fail, all in tests/parser_test.js. Each one passes a bare string where
the parser now expects a token array; the change landed in src/parser.js:88.
Fix is mechanical: wrap the argument in tokenize().
[label=default turns=4 ctx=41.2K cost=$0.31]

Watch a turn while it is in flight

watch redraws in place and shows what the remote session is doing right now: files read, bash commands, subagents spawned, tool errors.

workstation
$ node bridge/client.js watch server
/ server (server)   active: 1   queued: 0

  [RUNNING] default  1m12s  tools:9  ctx 41.2K
    repo: /home/you/projects/app
    task: run the test suite and summarize the failures
      . bash: npm test
      . read tests/parser_test.js
      . read src/parser.js
      . subagent: Explore

  recent:
    ok  docs  3m41s  tools:22

  ctrl-c to stop watching (this does NOT stop the turns)

Ctrl-C stops watching. It does not stop the turns; they run on the remote daemon and are unaffected by the watcher coming and going.


Five stages, two machines

Both daemons are the same file (bridge/daemon.js, Node stdlib only). Every machine can call every other machine; there is no server role and no client role.

machine A
1 Claude Code session You, or a model, decides the work belongs elsewhere.
2 MCP / CLI bridge_run, or bridge/client.js. HTTP plus an x-bridge-key header.
machine B
3 daemon.js :4747 Auth, per-repo queue, session store. sessions.json
repo + label -> session id
4 claude -p A real turn, real tools, cwd = the target repo. transcript watched live
-> tools, ctx
5
result + context Returned to machine A with the session id, elapsed time, turn count, true context size and cost. If the session is over the rotation threshold, an explicit rotation instruction comes with it.

Sessions, labels and rotation

The daemon stores one Claude Code session id per repo path plus label and resumes it with --resume. Before resuming it runs git rev-parse --abbrev-ref HEAD: if the branch changed since the last turn it starts a new session instead, because a context full of one branch is stale and misleading on another. Non-git directories skip the check.

The default label is default, so anything that does not pass a label behaves as if labels did not exist. Labels must match [A-Za-z0-9._-] and be 1-64 characters. Do not create a second repo just to get an isolated conversation; use a label per task.

Labels do not buy parallelism. The daemon's mutex is per repo, so turns against the same repo queue FIFO regardless of label. The lock protects the shared git working tree. Labels give separate memory, not concurrent writes.

Every turn reports the true current context size, read from the last assistant entry in the session's own transcript, not from the aggregate usage object (which sums every internal API call in the turn and therefore overstates a tool-heavy turn badly). Aggregate usage is still correct for cost, and is reported separately as cost=.

Per-machine state lives in ~/.claude-bridge/: config.json, sessions.json, transcript.jsonl, daemon.log.


Install

One script per machine, two copy-pasted lines total. It checks everything before it touches anything, installs nothing if a check fails, and proves the finished pair works by making the other machine run a real turn.

00 Starting from nothing?

If a machine has no Node, no Claude Code and no Tailscale yet, docs/PREREQUISITES.md walks through all three per platform, each ending in a command that proves it worked. If you would rather not do it by hand, SETUP-WITH-CLAUDE.md is a runbook you paste into a Claude Code session on that machine and let it do the work.

Two steps open a browser and a human has to finish them - tailscale up and Claude Code's /login - so no script and no agent can complete those for you.

01 On machine 1

sh
$ git clone https://github.com/trustworkscompany-dev/claude-opp.git
$ cd claude-opp
$ ./setup.sh

It prints one line to run on machine 2.

02 On machine 2

sh
$ ./setup.sh --join <blob>

Installs, pairs, runs a real round trip, and prints one last line to paste back on machine 1: ./setup.sh --peer <blob>. Done, verified in both directions. On Windows use setup.ps1 with -Join and -Peer. The blob carries the shared key, so send it over a channel you trust.

03 If something breaks

sh
$ node bridge/client.js doctor

Checks Node, the claude binary, a real turn, your daemon config, the local daemon, and every configured peer, and names the fix for whatever fails.

Requirements

  • Node.js 18 or newer on every machine.
  • Claude Code installed and logged in on every machine that will run turns. The daemon cannot log in for you. Verify with an actual turn, not with claude auth status.
  • A private network between the machines: Tailscale, WireGuard, or any equivalent overlay VPN. The daemon must never be reachable from the internet.

A Clone the repo on each machine

sh
$ git clone https://github.com/trustworkscompany-dev/claude-opp.git
$ cd claude-opp

B Install the daemon

macOS installs a per-user launchd LaunchAgent, Linux a systemd user service, Windows a Scheduled Task.

macOS
$ bash deploy/install-macos.sh --root /home/you/projects
Linux
$ bash deploy/install-linux.sh --root /home/you/projects
Windows (PowerShell)
> powershell -ExecutionPolicy Bypass -File deploy\install-windows.ps1 -RepoRoot C:\Users\you\projects

On the first machine the installer generates a shared key with openssl rand -hex 24, writes ~/.claude-bridge/config.json (mode 600), starts the daemon, health-checks it, and prints the key. Copy that key. On every later machine, pass the same key so all daemons agree:

later machines
$ bash deploy/install-linux.sh --key <key-from-machine-1> --root /home/you/projects
> powershell -ExecutionPolicy Bypass -File deploy\install-windows.ps1 -Key <key-from-machine-1> -RepoRoot C:\Users\you\projects

--root (-RepoRoot on Windows) sets allowedRoots: turns can only run in repos under that directory. If you omit it, the installer uses ~/projects when that exists, and otherwise refuses to install until you name a directory. It will not silently expose your whole home directory. Read Security before you widen it. Re-running an installer is safe; it leaves an existing config alone. Each installer takes an uninstall flag (--uninstall, or -Uninstall on Windows) that stops and removes the service but leaves your config and logs in place.

C Write the client config on each calling machine

Create ~/.claude-bridge/client.json (on Windows, %USERPROFILE%\.claude-bridge\client.json). The names on the left are what you type on the command line. Use the private network address of each machine, never a public one.

client.json
{
  "key": "<the same shared key>",
  "machines": {
    "workstation": "http://<workstation-private-ip>:4747",
    "server": "http://<server-private-ip>:4747"
  }
}

D Check it

sh
$ node bridge/client.js health server
$ node bridge/client.js run server --repo /home/you/projects/app "say OK"

Organized by what you want to do

Exit codes: 0 turn completed, 1 turn returned an error, 2 transport, auth or config failure.

Dispatch a turn and wait for it

Continues that repo's default session.

sh
$ node bridge/client.js run server --repo /home/you/projects/app "run the release checks"

Fire and forget, then poll a job id

--no-wait returns immediately with a job id. A wait timeout returns one too, so poll instead of resending.

sh
$ node bridge/client.js run server --repo /home/you/projects/app --no-wait "list failing tests"
$ node bridge/client.js job server <jobId>

Watch a running turn

The daemon runs claude with --output-format stream-json --verbose, captures the event stream per job, and serves it at GET /jobs. Against a daemon too old to have /jobs, watch says so and exits instead of failing obscurely.

sh
$ node bridge/client.js watch server              # refresh every 2s
$ node bridge/client.js watch server --interval 5

Run many threads in one repo

Two independent conversations with separate memory inside the same working tree. They still serialize against each other, because the mutex is per repo.

sh
$ node bridge/client.js run server --repo /home/you/projects/app --label api "..."
$ node bridge/client.js run server --repo /home/you/projects/app --label docs "..."

Pick a cheaper model for mechanical work

Both machines bill the same subscription. Route searches, bulk reads and log grovels to a cheap model, or set defaultModel on the machine that mostly does mechanical work.

sh
$ node bridge/client.js run server --repo /home/you/projects/app \
    --model claude-haiku-4-5-20251001 --new --no-wait "list failing tests"

Give a long turn more time

--timeout is the client's wait in seconds (default 600). The hard wall-clock limit is maxTurnMs on the remote daemon (default 90 minutes); a long job needs that raised too, not just a longer client wait.

sh
$ node bridge/client.js run server --repo /home/you/projects/app --timeout 3600 "refactor the parser"

Rotate a full session with a handoff doc

At rotateWarnTokens (default 100000) the CLI prints a ROTATE: line and sessions marks over-threshold rows with a leading !. Resetting throws the memory away, so hand it to the repo first. Name the doc after the label: one path per thread, or they overwrite each other.

sh
# 1. ask the same label to write its memory into the repo
$ node bridge/client.js run server --repo /home/you/projects/app --label api \
    "Write .bridge-handoff/api.md in this repo (create the dir if needed): current state, decisions and rationale, open questions, next steps, relevant file paths."

# 2. reset that label
$ node bridge/client.js reset server --repo /home/you/projects/app --label api

# 3. start fresh and point the new session at the doc
$ node bridge/client.js run server --repo /home/you/projects/app --label api \
    "Read .bridge-handoff/api.md, then continue with the next step."

Housekeeping

--all-labels drops every session for a repo at once and reports how many were dropped. Use it when you are done with a repo entirely, not as part of a rotation.

sh
$ node bridge/client.js health server
$ node bridge/client.js sessions server
$ node bridge/client.js reset server --repo /home/you/projects/app
$ node bridge/client.js reset server --repo /home/you/projects/app --label ci-triage
$ node bridge/client.js reset server --repo /home/you/projects/app --all-labels

# prompt from stdin; raw JSON output
$ echo "summarize recent commits" | node bridge/client.js run server \
    --repo /home/you/projects/app --stdin --json

Drive it from inside Claude Code (MCP)

Register the MCP server once. The other machine then becomes a callable tool.

sh
$ claude mcp add bridge -- node /home/you/claude-opp/bridge/mcp_server.js

# on Windows
$ claude mcp add bridge -- node C:/Users/you/claude-opp/bridge/mcp_server.js

That exposes five tools to the session:

  • bridge_run - run a remote turn. {machine, repo, prompt, model?, session_label?, new_session?, timeout_sec?}. Returns the remote result text after a header line such as [server /home/you/projects/app label=ci-triage session=<id> 7710ms ctx=87.0K turns=12], so the calling model always sees how full that session is. If the wait times out, it returns the job id and says the job is still running.
  • bridge_job - poll a job by id.
  • bridge_sessions - list stored sessions on a machine (repo, label, turns, context size, cost, last update), plus the rotation threshold and which sessions are over it.
  • bridge_health - daemon health, claude version, active and queued jobs.
  • bridge_reset - drop a stored session. {machine, repo, session_label?, all_labels?}.

A dispatched prompt runs as a full Claude Code turn, so it can tell the remote session to fan out its own subagents. One call can become a whole remote agent tree, which is much cheaper than many round trips across the bridge.


This daemon is remote code execution by design

Read this before you install it

An authenticated request runs claude with your credentials, on your machine, inside one of your repos, with tools enabled and - in the default configuration - --dangerously-skip-permissions. Anyone who can reach the port and knows the key can read, write, and execute anything reachable from allowedRoots.

That is why allowedRoots is your real blast-radius control. Pointing it at $HOME means any machine holding the shared key can read and write your SSH keys, cloud credentials, and browser data. That may be an acceptable trade on a private tailnet you fully control. Make it deliberately, not by accident.

Rules to follow:

  • Private network only. Bind to a Tailscale or VPN address. Never bind 0.0.0.0. Never port-forward it, never put it behind a public reverse proxy, never expose it with Tailscale Funnel or an equivalent.
  • The shared key is the only authentication. There are no users, roles, or scopes. Every request, including /health, must carry x-bridge-key; everything else gets a 401, compared in constant time. Treat the key like an SSH private key: keep it out of shell history and out of any repo. It lives in plaintext in config.json and client.json, so keep those mode 600.
  • Narrow allowedRoots. Point it at a projects directory, not at your home directory or /.
  • Do not "harden" it by deleting the permission flag. Headless claude -p will sit waiting forever for a permission prompt that nothing can answer, so removing the flag turns every turn into a hang until maxTurnMs. If you want tighter safety, narrow allowedRoots, or replace permissionArgs with an alternative permission mode you have verified works headlessly.
  • Prefer a dedicated tailnet (or at least a tightly ACL'd one) containing only the machines that need the bridge. Anyone else on the network is one leaked key away from your shell.

What it does not do

  • Both machines bill the same Claude subscription. The bridge does not give you more quota; it removes the friction that used to keep you from spending it. It is easy to double your burn rate without noticing.
  • Labels isolate conversation, not Claude's memory tool. Project memory is scoped to the working directory, so a turn told to "remember X" writes a file that every label in that repo can see.
  • Turns on the same repo serialize. The mutex is per repo, across all labels, because it protects the git working tree. A queued turn waits.
  • Claude Code must be logged in on each machine, and claude auth status lies. It can report loggedIn: true from a stale credential record whose token is dead. The only trustworthy check is running a real turn: claude -p "say OK".
  • The claude npm package migrated to a native binary. Recent versions move the executable to ~/.local/bin and leave the npm package as a thin wrapper with no cli.js. That migration can happen underneath a running daemon, so the daemon probes the native path first. If it still cannot find claude, pin claudePath.
  • A turn is killed at maxTurnMs and returns an error with a tail of stderr. Long jobs need a raised maxTurnMs on the remote daemon, not just a longer client --timeout.
  • Changing branches starts a new session for that repo and label, by design.