Skip to content

About

Claude Code subscription as structured HTTP sessions

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

claude-bridge

Expose a Claude Code subscription as structured HTTP sessions that any client can drive.

claude-bridge is a small Swift Hummingbird server that runs the claude CLI headlessly — one long-lived claude -p --input-format stream-json --output-format stream-json process per conversation, kept between turns so work a turn leaves running in the background outlives it — and turns its stream-JSON output into clean REST + SSE: persistent multi-session chat, token-by-token streaming, structured tool calls, reasoning blocks, session resume, clear, and fork.

It also serves the parts of a Claude Code session that aren't messages: the subagents a turn spawned and their own transcripts, context compactions as events rather than a wall of summary text, the attachments a prompt carried and every picture the agent looked at, the slash commands that machine will actually resolve, /goal state, plan rate-limit gauges — and the machine itself: what a whole conversation cost and where the month's money went, the project's git tree read for triage, whether the CLI is signed in (with a sign-in the bridge runs over a pseudo-terminal so a phone can complete it), whether the bridge is current (with a self-update that follows through its own restart), and a turn the machine cut off, recovered as a named state with a resume instead of silence. With an APNs key configured, Live Activity and turn-end pushes let a phone watch a turn it isn't looking at.

It uses the logged-in CLI (your Claude subscription), not an API key.

Known consumers:

  • Tailscode — native iOS, Linux and macOS clients, drive the bridge over Tailscale.
  • CodingAgentKit — Swift package whose ClaudeCodeBackend speaks this protocol.

Requirements

  • Linux (x86_64 or aarch64, glibc 2.35 or newer, which is every mainstream distribution since 2022) or macOS 15+ on Apple silicon, and curl. No Swift toolchain and no git: the installer downloads a prebuilt program. A Swift 6 toolchain and git are needed only on a machine the prebuilt program does not fit, or with --from-source.
  • Claude Code CLI installed and logged in (claude must work interactively for the user running the bridge).

Install

curl -fsSL https://raw.githubusercontent.com/guitaripod/claude-bridge/master/install.sh | bash

Downloads the latest release for this machine into ~/.claude-bridge/bin, checks its SHA-256 (integrity; the download itself is HTTPS from GitHub), generates a password, and registers a service that survives a reboot — systemctl --user on Linux, a launch agent on macOS. It ends by printing a pairing link and a QR code that Tailscode scans (see Pairing a phone); a program too old to know pair gets the address, username and password plus a single http://… BRIDGE_PASSWORD=… line that Tailscode reads whole instead. Config lives in ~/.claude-bridge/config.env; edit it and restart the service to change the port, model, or APNs key.

Before it installs anything it looks at the machine and says what it found, in plain words:

  • Tailscale: installed, signed in and connected? It is what lets a phone reach the machine from anywhere. If it is missing or signed out the installer says exactly what to do and carries on, because the bridge still works on that computer and on the home network.
  • Claude Code: installed, and signed in (claude auth status)?

It ends with a short list of what is still left to do, only the things that are actually left (Tailscale on this computer and on the phone, signing Claude Code in), and then the pairing link and code.

If there is no prebuilt program for the machine, the download fails, or the checksum does not match, the installer says why in one sentence and builds from source instead (clone into ~/.claude-bridge/src, swift build; 5 to 15 minutes), and if Swift is missing then, says where to get it: https://www.swift.org/install/. --from-source asks for that build directly, and --no-service installs everything except the service. A machine that already has a source checkout keeps building from it.

A client that already minted a password can bake it into the one-liner, so both sides carry the same one without anyone retyping it (an existing config's password is never overwritten):

curl -fsSL https://raw.githubusercontent.com/guitaripod/claude-bridge/master/install.sh | BRIDGE_PASSWORD=xxx bash

Updating

curl -fsSL https://raw.githubusercontent.com/guitaripod/claude-bridge/master/install.sh | bash

Re-running the installer updates in place and keeps your config and service. It never overwrites an existing config.env. (The program is replaced on disk; the running bridge keeps the old one until the service restarts.)

A bridge can also be updated from your phone: GET /update reports the state, POST /update fetches, rebuilds and restarts, reporting progress through a state file that outlives the restart. Tailscode exposes this as a button on the server screen, so the machine never needs an ssh session to keep current. An update refuses to run on a checkout with uncommitted changes, and names the files rather than only saying so.

A prebuilt install updates itself too, without a checkout. The installer leaves two things in the state directory: install.json ({"mode":"release","repo":…,"base":…}, where releases are published) and install.sh with its install.sh.sha256, the installer that arrived inside the release archive and was verified by the archive's own checksum. The bridge then answers GET /update in exactly the shape a checkout gives:

  • What is newer is the release's latest.txt, read with one conditional request (ETag) at most every five minutes however often a client asks (Check now has a ten-second floor), and compared as numbers (1.10.0 is newer than 1.9.0). A pointer that is not a plain dotted version is reported in remote.error and never acted on; with no network the answer keeps what was last heard, remote.ok is false, and the error says it could not reach the project.
  • What is new is the CHANGELOG-<version>.md asset of that release (the changelog as of it, fetched once per version), cut down to the releases newer than the one running: release.version is the offered version, release.commitsPastTag is 0, release.notes carries the sections. A release that publishes no such asset costs only the notes.
  • Doing it is POST /update running the kept installer as install.sh --update --managed after checking it against its checksum again; it downloads the archive, refuses it unless its SHA-256 matches and the program starts, and only then puts it in place. A checksum that does not match leaves the running program, the installer and the record as they were, fails the job in its download step with the sentence the installer wrote, and counts against the unattended backoff like a build that does not compile. The quiet barrier, the phases, the job and the unattended policy are the checkout path's, untouched.

Only behind (the number of commits) is absent: a release machine does not count in commits. A checkout wins over a record, and a machine installed by an older installer, without install.json or a kept installer, says what is missing (obstacle.kind noInstaller) and is brought across by running the install command once. install.sh --update on such a machine does the same download and install by hand, finding the release where install.json says.

The restart never takes a turn with it. Stopping this process is how the new binary gets loaded, and on Linux the supervisor tears down the whole control group — every claude the bridge started dies with it. A check taken when the build starts says nothing about the machine four minutes later when it ends, so the quiet test is a barrier at the exit: the bridge waits until nothing is running on that machine (a turn it is running, a transcript somebody is growing in a terminal, a sign-in halfway through its handshake), flushes, and only then exits. The wait is reported as phase waiting rather than as silence, and it is given up rather than held forever.

It can keep itself current. POST /update/auto {"enabled":true} turns unattended updating on — off by default, and the setting lives on the machine rather than on whichever device asked, so every client reads back the same answer. It takes an update only when it can finish the job end to end: a real supervisor, a clean checkout, a fetch that actually succeeded, and the same quiet barrier. A commit that fails to build is not tried again until a newer one exists or an exponential backoff runs out, and automation.holdingOff says which in words.

A build that landed and was never started is one press. POST /update/restart loads a binary already on disk — no fetch, no rebuild — behind the same barrier. It refuses (409) on a machine with no supervisor, because a bridge that exits with nothing to bring it back is a machine no client can reach.

UpdateStatus is deliberately hard to misread: it reports the checkout's version and the stamp of the binary actually running (running, builtAt — the installer writes a build.json after every build), because those part company between a build and a restart — restartRequired says so explicitly. The remote block records whether the remote was actually reached and when, so a fetch that failed can never read as "up to date".

Releases and the installer

scripts/package-release.sh builds swift build -c release --static-swift-stdlib and packs claude-bridge-<version>-<os>-<arch>.tar.gz with its .sha256 into dist/; the version is the newest numbered heading of CHANGELOG.md, which must equal BridgeVersion.fallback (a test checks it) and what the binary prints for --version. .github/workflows/release.yml runs it on a pushed X.Y.Z tag for linux-x86_64 and linux-aarch64 inside swift:6.4.1-jammy (glibc 2.35, so the archive runs on Ubuntu 22.04 and newer, Debian 12, Fedora and the like; it needs the system's libcurl.so.4, which any machine that can run the installer's curl has) and for darwin-arm64 on the xcode-27 runner, then publishes them with latest.txt, the pointer the installer reads. Every release is started on the machine that built it and sent a body after its headers before it is published, because Xcode 26's Swift 6.3 miscompiles that wait (swiftlang/swift#84793) while Swift 6.4 does not.

Each archive also holds install.sh, and CHANGELOG-<version>.md (the changelog as of that release, from its own heading down) is published beside it for the bridge to read. scripts/package-release.sh --notes-only writes just that file once for all platforms.

scripts/test-install.sh exercises the installer offline against a fake release, with a PATH that holds no swift and no git. scripts/fake-release-server.py is the release server the Swift tests use (ETag, request log); the tests install from it into temporary homes and run whole updates against it.

The bridge depends on CodingAgentKit's PairingKit product for the link format and the QR encoder. Package.swift takes a sibling working copy (../CodingAgentKit) when there is one and the published tag (Kit.version) otherwise; CLAUDE_BRIDGE_KIT_REMOTE=1 forces the tag, which a release build should set.

Pairing a phone

claude-bridge pair                 # the link, a QR code, how to show it again
claude-bridge pair --link-only     # just the link, for scripts
claude-bridge pair --json          # everything, machine-readable

prints the tailscode://connect?v=1&host=…&port=…&agent=claude&name=…[&key=…] link the Tailscode apps open, and a QR code of it for the phone's camera (colours on a terminal, plain blocks in a pipe; --color always|never decides). It reads the port and password from the environment, then from config.env in the state directory, so it works from a shell as well as from the installer.

  • Host: this computer's MagicDNS name from tailscale status --json, else its tailnet IPv4, else IPv6. On a machine that is not on a tailnet (no Tailscale, signed out, turned off, not answering) it is the hostname with a plain note saying so, and no code, because a phone away from this network cannot use it; --any-host prints the code anyway, and --link-only refuses (exit 3) without it.
  • key (the password) goes in the link only when the machine does not vouch for its tailnet: BRIDGE_TAILNET_AUTH=off, or no tailscale CLI, or tailscaled not answering, with a password set — the same three conditions under which the bridge's own door admits nobody without it. A machine whose tailnet gate is up gets a link without a password. The key is shown only on the terminal that asked, PairingLink redacts it everywhere it is printed by that type, and --json carries redacted beside link.
  • name is the hostname, without its domain.

The installer ends by running exactly this.

Running it by hand

git clone https://github.com/guitaripod/claude-bridge
cd claude-bridge
swift build -c release

BRIDGE_PASSWORD=change-me .build/release/claude-bridge

The server listens on 127.0.0.1:4098 by default. Try it:

curl -u claude:change-me http://127.0.0.1:4098/health
curl -u claude:change-me -X POST http://127.0.0.1:4098/sessions -d '{}'
curl -u claude:change-me -N http://127.0.0.1:4098/sessions/<id>/events &
curl -u claude:change-me -X POST http://127.0.0.1:4098/sessions/<id>/message \
  -d '{"text": "hello"}'

Endpoints

All request/response bodies are JSON. Every route (including /health) is behind the door: HTTP Basic auth with username claude when BRIDGE_PASSWORD is set, and the caller's tailnet identity when the gate is on — see Security.

Method Path Body Response
GET /health — ok
GET /status — {"agent": "claude", "model": …, "version": …, "authenticated": bool, "access": {…}, "proto": 2, "epoch": …, "turnWait": 2, "marks": 1, "queue": 1, "agents": 1, "schedules": 1, "detail": 1, "chapters": 1} — proto/epoch announce the sequenced /stream protocol, turnWait the protocol version of /sessions/:id/wait (2 holds an until=settled wait until the work is over), marks that conversations carry saved/pinned/archived/readAt, queue that this bridge holds the shared queue (see Shared queue), agents that it hosts ACP agents (see Other agents), schedules that it keeps schedules (see Schedules), detail that the session read answers the goal, cut-off turn and provider wait whole, chapters that it serves a conversation's chapters (see Chapters), and access which door let the request in
GET /stream Last-Event-ID header or ?since=epoch:seq one sequenced SSE stream of everything this bridge knows is happening (below)
GET /auth — whether the CLI is signed in, as whom, and the sign-in in progress if there is one
POST /auth/login — starts claude auth login on a pseudo-terminal and answers with the URL it printed
POST /auth/code {code} types the code the browser produced; answers with the status once the machine is signed in (400 with error if it wasn't accepted)
POST /auth/cancel — drops a sign-in that was left waiting
GET /permissions — the operating-system grants the bridge's agents run under (platform, host, executable, grants: [{id, state}], requestedAt); on a Mac, Full Disk Access judged by whether a child can open the privacy database, which never raises a prompt; grants is empty on Linux
POST /permissions/request {id} opens System Settings at that grant's list on the Mac and shows the bridge's binary in Finder beside it; answers with the status (400 for an unknown id or on Linux)
GET /update ?check=false to skip the remote fetch UpdateStatus — checkout version, the running binary's own stamp, restartRequired/canRestart, newer commits and their subjects, whether this install can update itself, the obstacle in the way named file by file, what the machine is busy with, its automation policy, the toolchain that would build it, an explicit remote block, and the phase of the last update
POST /update — 202 UpdateStatus once the update is running (409 when it cannot, with reason); poll GET /update for phase: running → building → waiting → restarting → succeeded/failed
POST /update/restart — 202 UpdateStatus — loads a build already on disk, behind the quiet barrier; 409 without a supervisor or with nothing to load
POST /update/auto {enabled} UpdateStatus — turns unattended updating on or off on this machine
GET /sessions — [SessionSummary], newest first, store + discovered transcripts merged
POST /sessions {title?, directory?, model?, effort?} the created Session; directory sets the project directory its turns run in
GET /sessions/:id — Session (404 if unknown), including the turn in flight; tagged, so If-None-Match gets a 304
GET /sessions/:id/revision — {updatedAt, active, turnOpen, background*} — the session's state in one small answer, for a client polling while its stream is quiet
GET /sessions/:id/context — what fills the conversation's context window as its own CLI counts it: the system prompt, tools, memory files, skills and agents, the messages split by kind, the buffer held back for auto-compaction, the window and where compaction starts; 404 when this conversation's window cannot be measured (an agent conversation, or one with no transcript yet)
PATCH /sessions/:id {title?, saved?, pinned?, archived?, read?: "seen"|"unread", at?} {"ok": true} (404 if a title names an unknown session; marks are accepted for any id). at is when the person decided: each mark keeps the later decision, not the later arrival
DELETE /sessions/:id — {"ok": true}
POST /sessions/:id/message {text, model?, effort?, attachments?} 202 {"ok": true, "queued": false}; the turn runs async, watch /events. A prompt sent while this bridge is already running a turn on the session is accepted and queued behind it as an item of the shared queue (no transcript echo until it starts) — 202 {"ok": true, "queued": true, "position": n} — so two clients on one session cannot start two claude processes against one transcript. 409 when the session is being written by a turn started outside this bridge (a terminal), which it cannot serialize against
POST /sessions/:id/abort — {"ok": true, "stopped": bool, "discarded": n, "paused": bool}; stops the turn in flight and pauses the shared queue behind it, keeping its items (discarded is only ever internal continuations now). 409 when there is nothing to stop from here
GET /sessions/:id/queue — the shared queue: {"queue": [Item], "paused": bool}
POST /sessions/:id/queue {id, text, kind?: "prompt"|"command", model?, effort?, attachments?} {"started": bool, "queue": [Item], "paused": bool}. id is the client's own UUID and makes the call idempotent. Nothing running: the item starts at once (started: true) and never appears in the queue. Otherwise it is appended. Either way the queue stops being paused. 400 for an empty id, an unknown kind or an item with neither text nor attachment
PATCH /sessions/:id/queue/:qid {text?, model?, effort?} the snapshot; 409 {"error": "already_started"} when the item started or is gone
DELETE /sessions/:id/queue/:qid — the snapshot (also when the id is already gone, so a retry is safe); 409 {"error": "already_started"} when it has started
POST /sessions/:id/queue/resume — the snapshot; clears paused and starts the head when the session is idle
POST /sessions/:id/background/stop — {ended} — ends the background shells the conversation's process is carrying; 409 {error} names why it would not
POST /sessions/:id/clear — {"ok": true}; drops history and the resumable Claude session id. 409 while a turn is running or queued — fork instead
POST /sessions/:id/fork — new Session (404 if unknown) seeded with the source's history; its first turn runs --fork-session so it diverges instead of mutating the parent
GET /sessions/:id/events — text/event-stream of per-session bridge events (below)
GET /sessions/:id/wait — holds until the turn ends or needs you, then one TurnWait JSON object (below); safe for a background URLSession
GET /sessions/:id/usage — {costUSD?, tokens?} for the session's last turn
GET /sessions/:id/spend — the whole conversation priced turn by turn from the CLI's own transcript — per-turn token tiers (cache write/read split), per-model, always an API-equivalent estimate
GET /sessions/:id/interruption — the state of a turn the machine cut off, as {"interruption": Interruption|null} — null when there is none
POST /sessions/:id/resume (also /sessions/:id/interruption/resume) — 202 {ok, queued, position?, interruption}; resumes an interrupted turn with a composed continuation brief — what its own transcript says it already did — never a blind re-send. 409 {error, reason, interruption} when it will not: reason is nothing_interrupted (interruption: null) or already_resumed (the record, so the caller can correct its card without asking again); 404 {error, reason: "unknown_session"}
POST /sessions/:id/interruption/dismiss — sets the interruption aside; same refusal shape — 409 nothing_interrupted, 404 unknown_session
POST /sessions/:id/auto-resume {enabled} resume this session's future interruptions automatically
GET /sessions/:id/agents — [SubagentSummary] — the subagents this session spawned, with what a live one is doing right now (current tool, todo progress)
GET /sessions/:id/agents/:agentID — SubagentTranscript — that subagent's own messages
GET /search ?q=, ?limit= full-transcript search across every session on the machine, subagent sidecars included; honest about what it didn't get to (truncated)
GET /analytics ?days= the whole machine's usage ledger: every transcript priced turn by turn — totals, per-day series, models, projects, tools, compactions, records
GET /git ?dir= or ?session= GitSnapshot — branch, upstream drift, porcelain-v2 status with per-file numstat, recent commits, and any merge/rebase left half-done
GET /git/diff ?path=, ?staged=, ?dir=/?session= GitPatch for one file
GET /git/commit ?hash=, ?dir=/?session= GitCommitDetail via show + numstat
GET /commands ?directory= [AgentCommand] — slash commands a headless turn will resolve
GET /usage — Claude plan rate-limit gauges (session / week / Opus) read from the CLI's own accounting
GET /usage/grok — Grok quota gauges, when a Grok key is configured
GET /usage/opencode — opencode Go subscription usage, read live from opencode's account status with the Go key; the last good reading is marked cached and an unreadable account says why
GET /files ?path= [FileNode] — directory listing for the project picker
GET /files/content ?path= {path, content}
GET /files/raw ?path=, ?tool=, ?session= the file's bytes with its MIME type — what an image part points at; a deleted file falls back to the copy that session's transcript kept for that tool call
GET /attachments/:session/:name — the bytes a prompt carried, served back with its MIME type
POST /sessions/:id/live-activity LiveActivityRegistration registers an ActivityKit push token for this session
POST /push/device {token, environment, rules?} {"ok": true, "delivers": bool} — registers a device token for turn-end pushes; rules is what this phone wants to hear (finish: settled, everyTurn, failures or never; minimumWork, needsYou, muted), applied per device, and a phone that registered before the rules existed gets settled; delivers says whether this bridge actually holds an APNs key, so a client can tell a real push from one an older bridge only pretended to accept (requires an authenticated bridge — a password or the tailnet gate)
POST /push/device/unregister {token} forgets it

Session: {id, title, directory?, claudeSessionID?, priorClaudeSessionIDs?, model, effort, createdAt, updatedAt, messages, lastCostUSD?, lastTokens?, pendingFork?, customTitle?, autoTitled?, chapters?, chaptersRevision?, goal?, interruption?, autoResume?, agent?, agentName?, agentSessionID?, scheduleID?, retry?} — chapters oldest first, each {title, turn, messageID?, startedAt, gist?, namedBy}, with chaptersRevision counting every change so a slow read never undoes a newer list; retry is the provider wait an open turn is in. Message: {id, role: "user"|"assistant"|"system", parts, createdAt} — system carries transcript boundaries such as compactions. Part is {kind: "text"|"reasoning", text}, {kind: "tool", tool: ToolCall}, {kind: "file", file: FileRef} or {kind: "compaction", compaction: Compaction}. ToolCall: {id, name, input, output?, status: "running"|"completed"|"error", background?}. background is {taskID?, status: "completed"|"failed"|"stopped", summary?, result?, reportedAt?} — how work this call handed to the background ended. A Workflow launch or a run_in_background command answers within milliseconds and then runs for minutes, so status says only that the launch worked; the ending arrives later as the harness's own <task-notification> line, which the transcript renders down to one sentence for the reader and which is read here for the two ids and the returned value that sentence cannot carry. The report is matched to its call by <tool-use-id>, or — for a sweep of orphaned tasks from a dead process, which names no call — by the task id the launch banner announced. Absent while the work is still out. Dates are ISO 8601. Sessions persist to BRIDGE_STORE across restarts. After a session's first exchange, a one-shot haiku-model claude -p call writes it a 3–6 word title.

A file part on a user message is an attachment that prompt carried; on an assistant message it is a picture the agent looked at — every tool result that hands Claude an image becomes one, docked at the tool call that read it, with url pointing at /files/raw so a client renders the same picture without the base64 the transcript keeps. The picture outlives the file: agents write screenshots to /tmp and clean them up, so /files/raw falls back to the transcript's own copy when the path is gone.

Other agents

Besides Claude Code the bridge hosts any agent that speaks the Agent Client Protocol — Hermes, DeepSeek Harness, Gemini CLI, Codex, goose, Kimi CLI, Qwen Code, GitHub Copilot and the rest of the ACP registry — on the same port, behind the same door, in the same session list. Nothing else is installed or paired.

  • An agent is found when its own command is on the machine's PATH (the person's copy, signed in the way they signed it in), available when npx or uvx can run it without installing (Hermes through uvx --from 'hermes-agent[acp]' hermes acp, DeepSeek Harness, which the registry does not carry, through npx @deepseek-ai/dsh --profile acp), installed when this bridge downloaded it from the registry (archives are checked against the registry's sha256 and unpacked under ~/.claude-bridge/agents/<id>/), or custom. A found agent is listed, never started, until someone adds it.
  • Ready means it answered. Adding an agent, and every change to its brain, key or sign-in, opens a real session in it and closes it again (~/.claude-bridge/agents/probe); what that proof found is the agent's state until the next one, and an added agent is proved again once a day. A failure is classified from the agent's own words into needsBrain, needsKey or needsSignIn, and kept as failure otherwise.
  • A brain. Hermes with no model of its own is given one the way its own wizard would (hermes config set model.provider custom, model.base_url, model.default): any OpenAI-compatible server on the machine (ollama, llama-swap, LM Studio, vLLM, SGLang on their usual ports, more in BRIDGE_BRAINS), or a provider key. A llama-swap that marks agent models (meta.llamaswap.agent) offers those alone, by their own names.
  • Keys are kept one file per agent, ~/.claude-bridge/agents/<id>.env, mode 0600, handed to the agent as environment variables when it starts and never sent back — a listing says only whether each is set.
  • Sign-in runs the agent's own terminal method (authMethods of type terminal) on a pseudo-terminal and relays what it prints — lines, links, one-time codes — to whoever asked, and what they type back; an agent-run method is authenticate over ACP with its stderr relayed. Exit 0 proves the agent again.
  • A conversation with an agent is bound by its first turn that names one of the agent's models (<agent id>/<model id>, the agent's default being <agent id>/default) and keeps that agent: a turn naming another answers in the conversation that it has to be carried. One agent process per conversation, kept between turns and reaped when idle like Claude's; a later process picks the agent's own session back up (session/resume, else session/load with the replay swallowed), and an agent that can do neither starts again with a brief of the conversation the bridge holds. The agent's updates are translated into the stream-json Claude Code writes, so the same assembler, journal, queue, live mirror, wait route, Live Activity and push carry them: execute becomes Bash, read Read, edit Edit or Write, search Grep or WebSearch, fetch WebFetch, a call of no kind titled with a Claude tool's name (bash, write, todo_write, as DeepSeek Harness sends every call) that tool, the agent's plan a TodoWrite, session_info_update the title. A permission request is answered with its allow option (allow_once first), as Claude runs with full permissions here. Fork answers 409 for an agent conversation.
Method Path Body Response
GET /agents ?refresh=1 re-asks the local model servers {agents: [{id, name, version, description, icon, website, source, added, state, runtime, failure, models, currentModel, efforts, keys: [{env, name, url, set}], signIn: [{id, name, description, kind}], brain, brainStyle, distribution, checkedAt}], brains: [{id, name, baseURL, models: [{id, name}]}]}
GET /agents/registry — the ACP registry as this machine can run it: [{id, name, version, description, icon, authors, website, distribution, installable, runtime, here}]
GET /agents/models — every ready agent's models: [{id, name, provider, providerName, agent: true, variants}]
POST /agents/install {id} downloads, checks and unpacks a registry agent (or readies an npx/uvx one), adds and proves it; 422 with error when the runtime is missing or the checksum does not match
POST /agents/:id/add · /remove · /check — adds and proves · forgets · proves again
POST /agents/:id/key {env, value} keeps a key and proves the agent
POST /agents/:id/brain {kind: "local", brain, model} or {kind: "key", provider, key} gives the agent a model to think with and proves it
POST /agents/:id/signin {method} starts the agent's sign-in; GET reads {state, lines, links, codes, exitCode}, /signin/input {text} types into it, /signin/cancel ends it

/stream carries a machine frame ({"topic": "agents"} or "schedules") whenever either changes.

Schedules

The machine keeps the clock: a schedule lives here, in ~/.claude-bridge/schedules.json, and asks whether or not any app is open. Rules are the ones people say — {"kind": "once", "at"}, {"kind": "daily", "hour", "minute"}, "weekdays", {"kind": "weekly", "weekday" (1 = Sunday), …}, {"kind": "hours", "every", "hour", "minute"} — read in the timeZone of the device that made it. A due schedule is sent through the store's own road (so it queues behind a live turn rather than racing it) into its one conversation (thread: "continue", the default) or a new one ("fresh"), with its model and level, an ACP agent's included. With quiet on (the default for a repeat) a run after the first is told, in what the agent is handed and never in the stored message, to answer exactly NOTHING NEW when nothing changed; such a run's answer becomes "Nothing new since the last check.", the conversation is marked read on this machine's clock and nothing is pushed. A failure always speaks. A machine that was off asks once for what it owed if the latest missed time is within 12 hours (caughtUp, with missed counting the times it slept through) and otherwise records missed with failure: "off"; a run still going when the next is due records that one missed with failure: "busy". At most 50 schedules; a one-off that ran is kept three days.

Method Path Body Response
GET /schedules — [{id, text, title, model, effort, directory, rule, timeZone, next, paused, thread, quiet, sessionID, createdAt, createdBy, runs: [{id, scheduledFor, startedAt, endedAt, sessionID, outcome, ending, missed, failure}]}], soonest first; outcome is running, answered, quiet, failed, missed or caughtUp (how a caught-up run ended is ending)
POST /schedules {text, rule, timeZone, model?, effort?, directory?, title?, thread?, quiet?, sessionID?, createdBy?} 201 with the schedule; 422 with error for a time already gone, an unreadable rule or a full machine
GET · PATCH · DELETE /schedules/:id PATCH: any of text, title, model, effort, rule, timeZone, paused, thread, quiet the schedule · the changed schedule · {ok} (its conversations stay)
POST /schedules/:id/run — asks now; a repeat keeps its hour

A conversation a schedule made carries scheduleID and is scheduled: true in a listing.

Chapters

A title says what a conversation is about now, not how it got there, so the bridge also keeps its stretches. Every third of the person's messages the bridge's one-shot titler looks at whether the name still fits and answers KEEP, RENAME: … for the same work reworded, or MOVED n: … where a new topic began. A move renames the conversation, opens a chapter at the message named and sums up the chapter it closed in one past-tense sentence; a person's own rename pins the title while the chapters still follow the topic underneath it. A failed look is asked again at the next message instead of read as KEEP, and an ACP agent that names its own conversation is left alone.

A conversation older than chapters and six or more of the person's messages long is split once, the first time it is read: its messages are numbered and the titler answers one n | title | what it came to line per chapter, with a split that opens a chapter at nearly every message refused and one finer than a chapter per three messages folded to that.

Session.chapters is the list oldest first — {title, turn, messageID?, startedAt, gist?, namedBy} where namedBy is prompt, model, person, agent, schedule, fork or kept — and chaptersRevision counts every change. The stream carries a chapters frame whenever they change, and a fork carries the chapters it was cut from. GET /status reports chapters: 1.

Signing in

A signed-out CLI does not fail a turn — it answers it, with "Not logged in · Please run /login" — so the bridge reports the machine's account state (/status carries authenticated, /auth the detail) and can run the sign-in itself. claude auth login is a terminal program that prints an OAuth URL and waits for the code the browser hands back, so the bridge runs it on a pseudo-terminal and splits those two halves across the network: a client opens the URL wherever the person is, and returns the code. That is what makes a headless server signable-in from a phone that has no shell on it.

One turn at a time

A session runs one turn. A prompt that arrives while a turn is in flight is queued behind it and started when that turn ends, so a phone and a desktop can both drive one session without two claude processes running against one transcript in one working directory, each blind to the other's edits.

The session goes idle only when nothing is left waiting, which is what stops a client's own send queue from draining into turns nobody asked for. Stopping a turn pauses the queue behind it. /clear refuses while a turn is held, because pulling the transcript out from under a running turn loses it; fork instead. A turn started outside this bridge — an interactive claude in a terminal — cannot be serialized against, so /message still answers 409 for that case alone.

Shared queue

A message queued behind a running turn lives on the bridge, not on the device that queued it, so every client of the conversation shows the same queue and can edit or cancel it. The bridge drains it: when a turn closes (a compaction is a turn, so it is respected) the head starts, unless the queue is paused.

An item is {id, text, kind, model?, effort?, attachments, createdAt}. kind is prompt or command (a slash command such as /compact). attachments is metadata only, {mime, filename, size}; the bytes arrive once, with the enqueue, as dataBase64 exactly as POST /sessions/:id/message takes them, and are kept under the session's attachments directory until the item starts or is cancelled.

  • Not in the transcript until it starts. A queued item has no user message. The message is written and broadcast when the item starts, as a normal send does.
  • Stop pauses. POST /sessions/:id/abort ends the running turn and sets paused; the items stay. A new message, an enqueue or POST /sessions/:id/queue/resume clears it.
  • Restarts. The queue (items, attachment files, paused) is kept in the turn journal (turns.json). A queue restored after a restart comes back paused: true, so nothing runs unattended after a crash.
  • Old clients. POST /sessions/:id/message stays. Sent while a turn runs, it becomes an item with a bridge-made id, so a message from an old client shows in new clients' queues.
  • Events. Every change (enqueue, edit, cancel, the start of an item, pause, resume, restore) is an SSE frame {"type": "queue", "queue": [Item], "paused": bool} carrying the whole snapshot, also on /stream. The hello of /sessions/:id/events includes the snapshot when the queue is non-empty or paused. GET /sessions/:id carries queue and queuePaused.
  • Order is arrival order at the bridge; there is no reordering.

A turn the machine cut off

Every turn is journaled. When the bridge starts, each turn the journal says was open gets a verdict: the transcript closed cleanly (completed), the claude process from before is somehow still alive (still running), or the machine died under it (interrupted). An interruption is a state, not silence: it records the progress the transcript proves — tools run, files touched, commands issued, how far the answer got — and surfaces on the session (Session.interruption, SessionSummary.interrupted) until someone acts on it.

POST /sessions/:id/resume continues the turn with a composed brief telling the model what its own transcript says it already did — never a blind re-send of the original prompt. dismiss sets it aside; auto-resume (per session, or BRIDGE_AUTO_RESUME=1 machine-wide) does the resuming without being asked. While an interruption stands, unattended continuation is off for that session — nothing carries on by itself until it is resumed or dismissed, which is a cost a client is expected to state on its card rather than leave a person to discover.

Both presses answer in a shape a client can act on without guessing. A refusal names a machine- readable reason beside the sentence — already_resumed is not nothing_interrupted, and only the first means the card should stay and start saying the work is going again — and carries the interruption the server actually holds (null when there is none), so a conflict corrects the client's card instead of dead-ending it. Resume answers on /sessions/:id/interruption/resume too, because shipped clients post that path.

Transcript discovery

The bridge also surfaces every local Claude Code CLI session as a first-class bridge session — the CLI's transcripts under ~/.claude/projects (override with BRIDGE_PROJECTS) are the single source of truth. GET /sessions merges them into the list (newest first), GET /sessions/:id parses the transcript into the message model above, and the first write (message, fork, clear) adopts the transcript into the store and resumes the underlying Claude session — a chat started in the terminal continues seamlessly from any client, in the project directory it was started in. DELETE on a discovered session hides it from the list (persisted next to BRIDGE_STORE) without touching the transcript on disk. Discovery is incremental: files are re-parsed only when their mtime/size changes.

Because the transcripts are all there, they are also searchable: GET /search fans one query out over the whole transcript directory — subagent sidecars searched with their parent — bounded from the inside, and honest when it ran out of time rather than silently partial.

SSE events

Each event on /sessions/:id/events is one data: <json>\n\n frame:

type Fields Meaning
message message Full message upsert — the user's message echoed back, the empty assistant message that opens a turn, and the final assembled assistant message (reasoning + tool + text parts) that closes it
delta messageID, delta Incremental assistant text chunk; append to the message's text
tool messageID, tool Tool call upsert — first with status: "running", again with output and completed/error
status status "running" when a turn starts, "idle" when it ends
goal goal? The session's /goal changed; the field is absent once nothing is being pursued
compaction phase, error? A compaction started, finished, or failed — the turn is still running throughout. The seam itself arrives as a system message upsert with one compaction part, carrying the numbers at once and the summary once the transcript has it; what the model says after it is a new assistant message
background tasks?, task?, since?, stalled? The background work the conversation's process carries changed; absent fields once nothing is running. No turn is open either way
queue queue, paused The shared queue changed (see Shared queue); the whole snapshot
retry retry? An open turn is waiting on Anthropic between attempts: the attempt, the reason, the HTTP status and when it tries next; absent once an answer arrives or the turn ends
chapters chapters, revision A conversation's chapters changed — one opened, one renamed or summed up, or a clear dropped them; the whole list and its revision
interrupted interruption? A turn was cut off by the machine, with what it had already done; the field is absent once it is picked back up or dismissed
error error Turn-level failure (e.g. the claude binary could not be launched)

A subscriber gets a status frame immediately on connect, so a client that attaches mid-turn knows the session is running without waiting for the next token.

Waiting on a turn

GET /sessions/:id/wait is for a client that is about to stop listening — the app going to the background, a process handing the question to something that isn't a live socket — and wants to be told, once, when there is finally something to say. It carries no cursor and no state: any number of these can be open on one session at once, a client that already knows how the turn ended gets the same answer as one that never asked before, and it is safe to hand to a background URLSession because the request has no side effect and asking twice changes nothing.

The response starts arriving at once — a single \n the moment the connection opens, so the headers and a first byte leave immediately — with another \n every ten seconds while the hold continues, and finishes with exactly one JSON object. A dropped connection is not an answer: it is silence, and the only thing to do with silence is ask again.

{"state":"ended","waited":true,"ending":"finished","title":"Fix the queue","toolCount":4,
 "background":1,"duration":192.4,"lastMessageID":"…","endedAt":"2026-09-25T16:19:55Z"}

state is "ended" (nothing is queued or running any more), "needsYou" (the turn stopped on an AskUserQuestion it is still waiting to have answered), or "running" (the hold reached its cap and the turn is still going — ask again). waited is false only when nothing was open the moment the request arrived. Every other field is omitted, never written null, when this bridge has nothing to say about it; ending is one of finished, answerless, failed, interrupted, cancelled, question — the same vocabulary the app's Live Activities already read, so a card and a /wait answer for the same turn never disagree. A hold is capped at BRIDGE_WAIT_MAX seconds (default 10800, three hours); past it the answer is {"state":"running","waited":true} and the caller is expected to open another wait rather than treat it as an ending.

?until=settled holds past the turn's end until the conversation's work is over: nothing running, nothing carried in the background, nothing queued, and ten seconds of quiet. The answer says "settled": true, and duration and toolCount cover the whole stretch, so a phone says one piece of work is done once rather than once per turn. A bridge that predates it answers at the turn's end and leaves settled out.

This bridge runs Claude with a fixed permission mode, so no turn ever stops on an interactive approval prompt — state: "needsYou" here only ever means a question, never ending: "approval". That case, and ending: "lost", are carried on the wire for the app's sake (the same vocabulary covers every backend) but this bridge does not produce them.

One stream of everything

A client watching many sessions doesn't want one socket per session. GET /stream is protocol 2: a single sequenced SSE stream of everything — a hello frame on connect, session frames carrying the per-session events above, list.upsert/list.remove as the chat list changes, agents frames as subagents come and go, and a heartbeat every 10 seconds so a dead link is noticed rather than trusted.

Every frame except the heartbeat carries id: <epoch>:<seq> (heartbeats are unsequenced so they never advance Last-Event-ID). Reconnect with Last-Event-ID (or ?since=epoch:seq) and the bridge replays the gap; when the window is gone — or the epoch changed because the bridge restarted — it answers reset and the client refetches instead of guessing. GET /status advertises proto: 2 and the current epoch, so a client knows before subscribing.

Commands and goals

GET /commands lists what a claude -p turn will actually resolve: the built-ins worth running from a client (goal, recap, compact, context, usage, init, review — the CLI's other commands either need an interactive terminal or duplicate controls a client already has), plus every command file on the machine — ~/.claude/commands, <directory>/.claude/commands, and each installed plugin's, namespaced plugin:command. AgentCommand: {name, description, argumentHint?, source: "builtin"|"user"|"project"|"plugin", scope?}. Running one is just sending /name args as a message; there is no separate route.

/goal <condition> makes the agent keep working until the condition holds, which is the one piece of state worth setting from a phone and then walking away from. The CLI records it in the transcript as goal_status attachments, so the bridge reads goal state from the same incremental fold that serves messages and reports it as Session.goal, as a goal SSE event when it changes, and in the turn-end push (Goal reached: … rather than Done in 2m · 6 tools). A goal survives the bridge respawning the conversation's process with --resume: the CLI restores it from the last goal_status record unless that record is met or failed. GoalStatus: {condition, met, failed?, reason?, iterations?, durationMs?, tokens?, updatedAt?}.

Models, effort, ultracode

A session's model and effort default to what the machine would use: when BRIDGE_MODEL / BRIDGE_EFFORT are unset, the bridge reads the CLI's own ~/.claude/settings.json (model, effortLevel) and re-reads it whenever the file changes — a /model pick in a terminal changes the next chat from a phone, no restart. Both remain overridable per session and per message.

Effort ultracode is real: the turn runs as --effort xhigh plus --settings {"ultracode": true}, and because print mode ignores the CLI's own keyword, the bridge also honors the word ultracode appearing in a prompt. A session that ran ultracode keeps reporting it even though its transcript can only spell xhigh.

Money

GET /sessions/:id/spend prices a whole conversation from the CLI's own JSONL, turn by turn, across the four token tiers (output, fresh input, cache written — 5m/1h split — and cache read). GET /analytics does it for the whole machine: every transcript plus every subagent sidecar folded into one ledger — totals, a per-day series, models, projects, tools, compactions, records — cached per file, nothing stored beyond the cache. Both are API-equivalent estimates, marked as such: a subscription bills a flat fee, so the figure is what the tokens would have cost, never a bill.

The repository, read

A transcript can't tell you where a change lands, so the bridge reads the project's git tree — and only reads it. GET /git returns the branch and its upstream drift, porcelain-v2 status with per-file numstat, recent commits, and any merge/rebase/cherry-pick left half-done; /git/diff and /git/commit serve one file's patch and one commit's detail. The directory comes from ?dir= or ?session='s working directory. Nothing stages, commits, pulls or pushes: a change made from a phone is a change nobody reviewed on the machine that has to live with it. A directory outside version control says so.

Configuration

Everything is environment variables. Empty values fall back to the default.

Variable Default Meaning
BRIDGE_PORT 4098 Listen port
BRIDGE_BIND 127.0.0.1 Bind address. Set BRIDGE_BIND=0.0.0.0 only when the machine sits behind Tailscale (or an equivalent private overlay) so tailnet clients can reach it
BRIDGE_PASSWORD empty HTTP Basic auth password (username claude). One of two keys: a request carrying it is admitted whoever sent it. Required only when the tailnet gate is off or tailscaled is not running — see Security
BRIDGE_TAILNET_AUTH same-user Who on the tailnet gets in without a password. same-user admits every node signed into the Tailscale account this machine is signed into; a comma-separated list of login names and tag: names admits exactly those; off disables the gate. Needs the tailscale CLI (found automatically; BRIDGE_TAILSCALE overrides the path)
BRIDGE_PERMISSION bypassPermissions Claude --permission-mode. bypassPermissions also passes --dangerously-skip-permissions
BRIDGE_CLIENT_NOTE 1 0 stops appending the short note that tells Claude its replies are read in Tailscode (--append-system-prompt, text in ClientNote.swift). A conversation keeps the system prompt of its first request until it is compacted, so the switch reaches new chats
BRIDGE_WORKDIR ~/agentapi-workdir Working directory for sessions that didn't choose one (created if missing, also passed as --add-dir); a session created with directory runs in its own
BRIDGE_CLAUDE ~/.local/bin/claude Path to the claude binary
BRIDGE_MODEL machine's Default model for new sessions. Unset, it follows ~/.claude/settings.json (model), falling back to sonnet
BRIDGE_EFFORT machine's Default reasoning effort. Unset, it follows ~/.claude/settings.json (effortLevel), falling back to medium
BRIDGE_AUTO_RESUME 0 Resume interrupted turns automatically, machine-wide
BRIDGE_PROCESS_TTL 1800 Seconds an idle conversation's process is kept with nothing running
BRIDGE_PROCESS_POOL 4 Idle processes kept at once; past this the least recently used goes
BRIDGE_LAUNCH_TIMEOUT 300 Seconds a process that has never written a line gets before its launch is called failed
BRIDGE_TURN_SILENCE_TTL 7200 Seconds an open turn may go without a line from its process before it is ended
BRIDGE_STALL_WINDOW 600 Seconds a background shell past its budget must show no CPU and no output before it is ended; 0 leaves it to the person
BRIDGE_STORE ~/.claude-bridge/sessions.json Session persistence. From 1.19 this file is an index of every conversation without its messages; each conversation's messages live in conversations/<id>.json beside it
BRIDGE_PROJECTS ~/.claude/projects Claude Code CLI transcript root scanned for discoverable sessions
BRIDGE_RELEASE_BASE the project's releases/latest/download Installer only: where latest.txt, the archives and CHANGELOG-<version>.md are served from. A prebuilt install records the one it used in install.json and the bridge reads it from there
BRIDGE_SRC ~/.claude-bridge/src The checkout self-update operates on (set by the installer's generated runner)
BRIDGE_STATE_DIR ~/.claude-bridge Where the running binary's build stamp (build.json) is read from; update state always lives in BRIDGE_STORE's directory
BRIDGE_APNS_KEY empty Path to an APNs .p8 auth key. Set all four BRIDGE_APNS_* values to enable Live Activity and turn-end pushes; leave them empty and the bridge simply never pushes
BRIDGE_APNS_KEY_ID empty Key id of that .p8
BRIDGE_APNS_TEAM_ID empty Apple developer team id
BRIDGE_APNS_TOPIC com.guitaripod.tailscode.push-type.liveactivity Live Activity push topic; set it to your own bundle id + .push-type.liveactivity if you ship a different client

Security

Read this before deploying.

What bypassPermissions means. By default the bridge runs Claude with --permission-mode bypassPermissions --dangerously-skip-permissions. Claude executes any tool call — shell commands, file reads and writes, network access — without asking. Anyone who can send a message to this server can run arbitrary commands as the user the bridge runs as. That is the point of the tool (an unattended agent has nobody to answer permission prompts), but it makes the HTTP surface equivalent to remote shell access.

The tailnet is a credential. Tailscale already authenticated every node with a device key and an account, so the bridge asks tailscaled who is on the other end of each connection (tailscale whois) and admits a node signed into the same Tailscale account as this machine without a password (BRIDGE_TAILNET_AUTH=same-user, the default). A LAN neighbour, a Docker bridge, a local browser tab, a shared node, a guest and a tagged server are not that account and are refused. The password stays a second key for anything that is not on the tailnet, and a client is told which door let it in (/status → access) or, on refusal, that only the tailnet would (401 with {"error":"tailnet-only"}), so it never asks for a password that does not exist. Every other node on the tailnet is somebody's device: list them by login or tag to admit them, or leave the default and hand them the password.

Fail-closed startup. The server refuses to start when nothing guards the door: BRIDGE_PASSWORD empty, tailscaled not answering (or the gate off), and BRIDGE_PERMISSION still bypassPermissions. Either set a password, run on a tailnet, or set BRIDGE_PERMISSION=default.

Deploy behind Tailscale only. The default bind is 127.0.0.1, which is only useful for local experiments. The intended deployment is a machine on a Tailscale tailnet with BRIDGE_BIND=0.0.0.0, so that reachability is gated by tailnet membership (WireGuard) and Basic auth is the second layer, not the only one.

Never expose this server to the public internet. Do not port-forward it, do not put it behind a public reverse proxy, do not run it on a cloud box with an open firewall. Basic auth over plain HTTP is not a sufficient boundary for something that executes shell commands.

Running as a service (systemd)

See examples/claude-bridge.service for a systemd user unit.

mkdir -p ~/.config/systemd/user
cp examples/claude-bridge.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now claude-bridge
loginctl enable-linger "$USER"

enable-linger keeps the user service running across reboots without a login session. On macOS, use a launchd LaunchAgent with the same environment variables instead. (The installer does all of this for you, pointing the unit at a generated ~/.claude-bridge/run.sh that sources config.env.)

License

GPL-3.0. Copyright (c) 2026 Midgar Oy. See LICENSE.

About

Claude Code subscription as structured HTTP sessions

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Contributors

Languages