macroclaw-bridge
v0.1.0
Published
Serves live Claude Code session state to the MacroClaw iOS app over the local network.
Readme
macroclaw-bridge
Serves live Claude Code session state to the MacroClaw iOS app over your local network.
npx macroclaw-bridgeIt prints a pairing token. The phone sends that token; without it the bridge serves nothing.
What it does
Claude Code already writes everything the deck needs. Each session is an append-only JSONL file
under ~/.claude/projects/<slugified-cwd>/<session-uuid>.jsonl, carrying cwd, gitBranch,
the model, per-turn token usage, an AI-generated title, and every tool call. The bridge tails
those files and folds them into the shape MacroClaw renders.
The one thing that matters most falls out of the log for free: a session is blocked on you
exactly when an AskUserQuestion tool call has no matching tool_result. That is what turns a
card amber.
Files only ever grow, so the bridge keeps a byte offset per session and parses just the new tail.
Answering from the phone
npx macroclaw-bridge --install-hookThen restart your Claude Code sessions. A tool call that needs permission now waits for MacroClaw instead of only the terminal.
It works through Claude Code's PreToolUse hook, which is built for exactly this: the hook runs
before a tool call, and whatever it prints decides the call's fate.
session wants to run `rm -rf build`
-> PreToolUse hook fires, makes one blocking request to the bridge
-> bridge parks it and pushes it to the phone over SSE
-> you tap allow / deny, optionally with a note
-> hook prints {"permissionDecision":"allow"} and exits
-> the session runs the commandHooks get a 600-second timeout (configurable), so there is plenty of room for a human.
The note you type is a real channel to the model. Claude Code feeds
permissionDecisionReason back verbatim on a deny, so "not on this network" arrives as the
model's stated reason for being blocked, and it reacts to it.
It must never wedge your terminal
That is the governing rule, and every failure path ends the same way: the hook prints nothing, exits 0, and the session falls back to its ordinary permission prompt as though no hook were installed. Bridge not running, phone asleep, request timed out, malformed payload, bridge killed mid-wait — all identical, all safe. Shutting the bridge down releases anything parked first.
The handshake file the hook reads (~/.macroclaw/bridge.json, mode 0600) only exists while the
bridge is running, so its absence is the ordinary "no bridge right now" case rather than an error.
What it cannot do
AskUserQuestion — the multiple-choice prompt — is a tool, so the hook does see it, but a hook
can only allow or deny; it cannot hand back a chosen option. Denying with the choice as the reason
does reach the model and works in practice, but it is a workaround rather than the real thing.
Permission prompts, which are the common case, are handled properly.
Endpoints
| | |
|---|---|
| GET /health | No auth, no data. Confirms a discovered service is a bridge before pairing. |
| GET /sessions | Board summaries, freshest first, no transcripts. |
| GET /sessions/<id> | One session with its transcript, for the detail sheet. |
| GET /events | Summaries and pending asks, then a push on every change (SSE). |
| GET /pending | Tool calls waiting on an answer right now. |
| POST /pending/<id> | Answer one: {"decision":"allow"\|"deny","reason":"..."} |
| POST /hook/ask | Used by the hook. Blocks until answered. |
Transcripts are deliberately not in the list. Inlining them made the payload 1.7 MB for 160 sessions; summaries alone are about 17 KB, and the board draws no transcript anyway.
curl -s localhost:8787/health
curl -s -H "Authorization: Bearer $TOKEN" localhost:8787/sessions | jq
curl -sN -H "Authorization: Bearer $TOKEN" localhost:8787/eventsOptions
--port <n> port to listen on (default 8787)
--token <s> pairing token (default: a fresh random one each run)
--host <addr> interface to bind (default 0.0.0.0; 127.0.0.1 keeps it on this machine)
--days <n> only serve sessions touched in the last n days (default 7)
--limit <n> most sessions to serve (default 60)
--name <s> Bonjour service name (default MacroClaw)Security
These logs contain your source, your file contents, and anything ever pasted into a session.
Claude Code stores them 0600 for that reason. Putting them on a wire undoes that, so:
- Every data endpoint requires the pairing token, compared with
timingSafeEqual. - The default token is random per run — a restart invalidates the old one.
--host 127.0.0.1keeps it off the network entirely, if you only want the Simulator.- Nothing is written. The bridge opens every file read-only.
It binds 0.0.0.0 by default because a phone cannot otherwise reach it. On a network you do not
trust, don't.
Discovery
Advertised as _macroclaw._tcp, with a TXT record carrying host and port. The record is what
makes discovery reliable: resolving a service endpoint client-side does not always yield a
concrete address, and when it does not, the app can see the bridge and still fail to reach it.
Published through the platform's own mDNS responder — dns-sd on macOS,
avahi-publish-service on Linux. A JavaScript responder would have to bind UDP 5353 itself and
compete with the daemon that already owns it, which mostly works and fails silently when it
doesn't. Shelling out also keeps the dependency count at zero, so npx has nothing to install.
If neither tool is present, discovery is skipped and the bridge prints its LAN addresses instead.
Known limits
- Status is honest, not complete. Only three states are knowable from the log: blocked on a
question, recently active, quiet.
doneanderrorwould be guesses, so the bridge never claims them. - Answering covers permission prompts, not multiple-choice questions. See above.
- Polling, not watching.
fs.watchon a directory does not reliably report appends to files inside it on macOS, so a 1-second poll is the real change signal. Each pump stats a file and reads only when there are new bytes. - Same LAN only. Plenty of corporate and guest wifi blocks mDNS outright.
Tests
npm testDrives the parser over a throwaway root, so it never touches your real sessions.
