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
Why this exists
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.
See it work
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.
$ 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.
$ 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.
How it works
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.
repo + label -> session id
-> tools, ctx
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.
Quickstart
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
$ 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
$ ./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
$ 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
$ 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.
$ bash deploy/install-macos.sh --root /home/you/projects
$ bash deploy/install-linux.sh --root /home/you/projects
> 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:
$ 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.
{
"key": "<the same shared key>",
"machines": {
"workstation": "http://<workstation-private-ip>:4747",
"server": "http://<server-private-ip>:4747"
}
}
D Check it
$ node bridge/client.js health server $ node bridge/client.js run server --repo /home/you/projects/app "say OK"
Usage guide
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.
$ 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.
$ 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.
$ 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.
$ 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.
$ 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.
$ 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.
# 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.
$ 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.
$ 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.
Security
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 carryx-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 inconfig.jsonandclient.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 -pwill sit waiting forever for a permission prompt that nothing can answer, so removing the flag turns every turn into a hang untilmaxTurnMs. If you want tighter safety, narrowallowedRoots, or replacepermissionArgswith 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.
Known limitations
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 statuslies. It can reportloggedIn: truefrom 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/binand leave the npm package as a thin wrapper with nocli.js. That migration can happen underneath a running daemon, so the daemon probes the native path first. If it still cannot find claude, pinclaudePath. -
A turn is killed at
maxTurnMsand returns an error with a tail of stderr. Long jobs need a raisedmaxTurnMson the remote daemon, not just a longer client--timeout. - Changing branches starts a new session for that repo and label, by design.