claude-broker
v0.1.39
Published
Encrypted git hub-and-spoke broker for cross-machine Claude Code messaging.
Readme
claude-broker
Encrypted git hub-and-spoke broker that lets Claude Code instances on different machines hold two-way, multi-turn conversations, with strict per-machine isolation, no inbound network reachability, and confidentiality from the transport host (GitHub).
The personal Mac is the hub; each job laptop is a spoke. The hub can dispatch work to a specific laptop's Claude and read the reply; a laptop's Claude can initiate a thread back to the hub; either side can ask a question mid-work and have the other side answer, resuming the paused session. No laptop can reach another laptop's traffic.
Why
Corporate laptops sit behind NAT with no inbound reachability, and a shared
Telegram account risks a ban. The broker needs a transport that works over
outbound HTTPS only and keeps engagement content unreadable to whoever hosts
it. A private git repo per laptop, holding only age ciphertext, satisfies all
three: reachable from anywhere, no inbound ports, and confidential end to end.
Architecture
Hub-and-spoke, one private GitHub repo per laptop used as an encrypted two-way message bus.
Personal Mac (hub)
| broker-runner x3 (one per repo) + broker CLI
|
|== repo: broker-work-a == work laptop A (runner, key: work-a only)
|== repo: broker-work-b == work laptop B (runner, key: work-b only)
|== repo: broker-work-c == work laptop C (runner, key: work-c only)Isolation is enforced at three independent layers, so "laptops cannot talk to each other" is a property of access control, not convention:
- Separate repos. No shared repo, branch, or folder spans two laptops.
- Per-laptop deploy keys. A laptop's git credential reaches only its repo.
- Per-runner config. A laptop's runner knows only its own repo URL.
The same broker-runner process runs everywhere. The hub runs one instance per
repo; each laptop runs exactly one. Role differences (autonomous-with-escalation
on the hub, direct execution on a laptop) are configuration, not separate code.
Encryption
age, point-to-point, one keypair per machine. Private keys never leave the
machine and never enter a repo. The hub holds every laptop's public key; each
laptop holds only the hub's. GitHub stores only ciphertext under opaque message
ids, so content, prompts, and results are unreadable to the transport host.
Message protocol
Each repo has two directional inboxes: to-hub/<id>.age and
to-spoke/<id>.age. A decrypted envelope carries a thread_id, an optional
in_reply_to, and a kind:
task: do this work (opens or continues a thread expecting a result)question: cannot proceed without an answer (the sender's session parks)answer: reply to a question. Resumes the parked session, or reconstitutes a fresh one from the question and the answer when that session is gonenotice: one-way information, no blocking reply expectedresult: terminal message for a thread
Multi-turn works by mapping each thread_id to a Claude Code session id: a new
thread pre-mints a --session-id; a continuation resumes with --resume. The
processed marker is written last, after the reply is pushed, so
re-processing a completed thread is a no-op (crash-safe, idempotent).
Hub autonomy and escalation
When a laptop writes to the hub, the hub's runner handles it autonomously under
a constrained permission mode. It escalates to Marc rather than guessing when it
hits ambiguity it cannot resolve, or when an action is flagged sensitive
(client push/merge, deletion, outbound communication, credentials). An
escalation queues locally and notifies Marc; the laptop thread waits. Marc
resolves it from any Mac session with broker answer <thread_id> "<decision>".
Telegram visibility
Two optional lanes, hub-only, that reach Marc through the existing personal Telegram gateway bot (reusing its token and chat id; no new bot, no Telegram user account):
- Escalations ping the DM the moment they queue.
- Visibility records broker events to a local activity log and, on a
throttled cadence, hands the recent slice to a cheap
claude -pturn that decides whether anything is worth surfacing and returns either a short note or stays silent.broker digestforces one on demand, and clears the events it summarised;broker digest --previewruns the same curation but sends nothing and clears nothing, which is the safe way to look at what the broker has been doing. The slice handed to the curator is capped, and anything past the cap is dropped oldest-first, so a curator that stays broken cannot grow the log until the prompt itself becomes the reason it keeps failing.
Delivery is best-effort over built-in HTTPS: a missing gateway config or a failed send is logged and never blocks message processing. The activity log holds only short body slices in local plaintext and is never pushed to any repo.
Kanban tracking
Optional, hub-only. When a kanban config block maps targets to board files,
the broker upserts one card per thread onto the target machine's own board
(an Obsidian Kanban markdown file), so every hub-and-spoke conversation is
visible and tracked where Marc already tracks machine dispatches.
One card per thread, keyed by thread_id and updated in place as the thread
advances: open, then waiting when it escalates, then flipped to a checked
done card when a result arrives. Cards live in a tool-owned ## Broker
column so they never collide with human-managed columns. A card for a target
only ever writes to that target's board, preserving the same per-target
isolation as the message bus.
Writes are local, atomic (temp file plus rename), and best-effort: a missing
board or a failed write is swallowed and never blocks message processing. The
feature is off unless kanban.boards is configured.
Observability
Optional, per machine. When an otlp config block is present and enabled, the
runner exports metrics and traces to Grafana Cloud over OTLP/HTTP-JSON. There is
no SDK: lib/telemetry.js speaks the wire protocol with the built-in https,
preserving the zero-dependency rule. The OpenTelemetry auto-instrumentations
would not help here anyway, since the broker's work is git, age and
claude -p subprocesses that they cannot see.
Metrics (counters cumulative for the life of the runner):
| Metric | Type | Labels |
| --- | --- | --- |
| broker.messages | counter | target, kind, direction |
| broker.escalations | counter | target, reason |
| broker.poll_errors | counter | target |
| broker.claude_turn_ms | histogram | target, outcome |
| broker.tokens | counter | target, type, model |
| broker.cost_usd | counter | target, model |
| broker.up / broker.uptime_ms | gauge | none |
Only bounded-cardinality fields become labels. Message bodies are never exported:
the closest thing is the escalation reason, which is a fixed code such as
sensitive:deploy. broker.up is a per-flush heartbeat, so a stale series is the
signal that the runner died, which no event-driven counter can express.
broker.tokens and broker.cost_usd come from the usage and total_cost_usd
fields that claude -p --output-format json already returns on every turn. type
is input, output, cache_creation, or cache_read, so cache-creation cost
(usually the dominant driver) is visible on its own. model collapses the turn's
modelUsage to one label: the single model id, multi, or unknown.
Traces are one broker.poll span per sweep per target, with a
broker.claude_turn child span per handled message carrying the turn outcome.
A failing sweep marks its span as an error before rethrowing. The span buffer is
capped so an export outage drops the oldest spans instead of growing the heap,
and a failed export is retried on the next flush rather than dropped.
Export is best-effort and never throws into message processing. Flush happens
once per sweep, before the restart.flag check, so the sweep the runner exits on
still ships. service.instance.id (from otlp.instance) becomes the Prometheus
instance label, keeping hub and spokes separate in one stack.
Host metrics and log shipping are a separate concern handled by one Grafana Alloy agent per machine, not by this repo. See the runbook.
Import grafana/broker-dashboard.json into your Grafana Cloud instance for the
dashboard: runner liveness (broker_up), message flow by direction/kind/target,
mean claude -p turn latency, poll errors, escalations, and the runner logs
panel from Loki. On import, bind the Metrics source variable to your
Prometheus datasource and Logs source to Loki; the Instance and Target
selectors populate from the live label values. The logs panel reads the
{job="claude-broker"} stream that Grafana Alloy ships from the runner's
broker.log. Metric names are OTel-normalized
by Grafana's OTLP gateway (dots become underscores, counters gain _total), so
the panels query broker_messages_total, broker_claude_turn_ms_*, and friends.
The runner log
launchd redirects both the runner's streams into broker.log and adds nothing of
its own, so the runner stamps every line with an ISO-8601 UTC time as it writes.
Alloy forwards those lines to Loki with no parsing stage, which means Loki's own
timestamp is when the tail caught up, not when the line was written; after an
outage the two are hours apart, and the in-line stamp is the one that is true.
Nothing else prunes that file. When the runner exits for a restart it moves the
log aside to broker.log.1 if it has passed log_max_bytes (default 5 MB, 0
to disable), keeping the pair bounded at twice the cap. Rotation happens at exit
rather than at startup because launchd binds the log to the process at spawn: a
rename while the runner is live leaves it writing into the archive.
Fleet staleness
Exporting telemetry proves nothing about whether it arrives. A machine can stop
shipping for nine hours with every dashboard still rendering, because the panels
plot what exists and a dark machine simply has no series to plot. broker fleet
asks the inverse question: for each machine that is expected to report, how
long has it been since its last sample.
This is hub-only by construction. A spoke's Grafana credential is write-only, so a machine cannot read back whether its own series are still arriving. It now says in its own log when an export fails, but that only covers a post it attempted: a runner that has stopped, crashed or lost its config posts nothing and therefore reports nothing, which is the state this check exists to catch. The hub holds the read token, so the hub is the only place the question has an answer.
Two pipelines are checked independently, because one shipping fresh says nothing
about the other: node is Alloy's host metrics (up{job="integrations/macos-node"},
keyed on instance) and broker is this repo's OTLP export (broker_up, keyed
on service_instance_id). The expectation list is declared rather than derived:
a machine dark past the lookback window and a machine that was never configured
both produce no series, so absence is only meaningful against a list of what
should be there. A reporting machine missing from that list comes back as
unexpected, which is the inverse failure nobody goes looking for.
"fleet": {
"enabled": true,
"credentials_file": "~/.claude-broker/observability-read.env",
"stale_after_s": 900,
"expect": [
{ "instance": "personal-mac", "paths": ["node", "broker"] },
{ "instance": "work-a-laptop", "paths": ["node", "broker"] },
{ "instance": "work-b-laptop", "paths": ["node", "broker"], "stale_after_s": 43200 }
]
}credentials_file is an env-style file holding GRAFANA_READ_TOKEN,
PROMETHEUS_READ_USER and PROMETHEUS_READ_URL (the URL already ends in the
API version segment). A missing variable names itself rather than sending an
empty auth header, and every other silent-success path throws too: a non-success
query body, a non-200 response, and an enabled block with no expectations.
broker fleet exits 1 when anything is stale, absent, unexpected, or
unconfigured, so a LaunchAgent can key on the code instead of parsing the report.
--notify sends that report over the Telegram lane, and only when the failing
set has changed since the last --notify run: a machine dark for two days pages
once rather than once per scheduled check, deepening from stale to absent pages
again because it is a different fault, and recovery sends an all-clear naming
what cleared. The last-seen set is kept in <state_dir>/fleet-state.json; a
bare broker fleet neither reads nor writes it, so a human running the check by
hand cannot consume a transition the scheduled run has not reported yet. An
unwritable state file degrades to paging every run rather than to silence.
Install
Per machine, deployed as a launchd LaunchAgent. Full runbook, including key
generation and distribution and the config.json for each role, is in
deploy/KEY-DISTRIBUTION.md.
BROKER_DIR=<repo root> ./deploy/install-runner.shGraceful restart after a config or code change: touch ~/.claude-broker/restart.flag
(never launchctl kickstart, which SIGKILLs the runner's own process group).
CLI
broker send <target> <kind> "<body>" [--cwd DIR] [--thread ID] # dispatch a message
broker answer <thread_id> "<decision>" # answer a question / resolve an escalation
broker read [<target>] [--thread ID] # print decrypted messages / a thread
broker threads # list threads and their sessions
broker digest # force a visibility note now, then clear those events
broker digest --preview # same curation, sends nothing, clears nothing
broker fleet # age of each expected machine's last sample; exit 1 if any is failing
broker fleet --notify # same check, and Telegram the report when the failing set changes<target> is a short name (work-a, work-b, ...) resolved through the hub's
config. The names are yours; the hub's repos map defines them.
Configuration
Per-machine ~/.claude-broker/config.json sets role (hub|spoke), the repo
map, the age identity and recipient public keys, claude_path, a
cwd_allowlist, the escalation rule, the hub-only notify block for Telegram,
and the hub-only kanban block for board tracking, plus the optional
permission_prompt allow-list, the optional otlp block for Grafana Cloud
export, and the hub-only fleet block for the staleness check. See the runbook
for annotated examples of both roles.
Pinning the child MCP surface
child_mcp_servers bounds the MCP servers a spawned claude -p turn loads. The
tools array leads the cached prompt prefix, so a child that lets its servers
connect asynchronously grows that array mid-turn and invalidates the prefix; the
conversation is then rewritten to cache at the 1h TTL, which bills at twice the
base input rate.
absent inherit the machine's full MCP surface (the behavior before this key existed)
[] no MCP servers at all
["a", "b"] only these, resolved by name from the poolThe pool is the top-level mcpServers object of ~/.claude.json, overridable
with mcp_pool_file. A name absent from the pool is reported once at startup and
skipped, never silently dropped. Note the pool bounds only what can be named:
with a pinned array, --strict-mcp-config also excludes project-scoped and
plugin-provided servers, which the pool never lists. This mirrors the telegram
gateway's CHILD_MCP_SERVERS contract, so one mental model covers both spawn
paths on a machine running both.
Bounding a thread's context
A resumed thread re-reads its whole accumulated history every turn, and that
re-read is the dominant cost: cache read measured 46% of spend on the machine
this was built for. max_thread_turns sets a ceiling. Past it, the next task on
that thread mints a fresh session instead of resuming, which is already a proven
path since an expired session takes it and the envelope carries the question.
Unset or 0 means never rotate, so a machine that has not opted in keeps exactly
its previous behaviour. Every turn on the thread counts, including an answer
resuming a parked session, since that turn re-reads the same history. An answer
is never itself rotated: reaching the session holding the parked question is the
whole point of one. Reconstituting a thread restarts the count, because the fresh
session carries none of the dead one's history.
Answering permission prompts
Outside bypassPermissions, the harness gates a sensitive path (anything under
~/.claude, ~/.ssh, and similar) by asking, not denying. A headless claude -p
with no way to answer that ask cannot proceed: the tool call is refused, and the
turn reports work it never did. permission_prompt gives the spawned child an
answer route, so a machine whose policy forbids bypassPermissions can still take
broker work that touches those paths.
"permission_prompt": {
"enabled": true,
"audit_log": "~/.claude-broker/permission-audit.jsonl",
"allow": [
{ "tool": "Write", "paths": ["/Users/you/.claude/settings.json"] },
{ "tool": "Edit", "paths": ["/Users/you/.claude/**"] },
{ "tool": "Bash", "commands": ["/Users/you/.claude/plugins/cache/*/*/*/scripts/config-sync.sh"] }
]
}Deny by default. A request is allowed only when a rule names its tool exactly and
the rule's own matcher accepts the request. A rule carries paths or commands,
never both.
paths is matched against input.file_path. ** spans separators and a single *
stops at one, so a rule written for one directory does not silently cover the tree
below it. Both sides are canonicalized through realpath before matching, so neither
.. nor a symlink out of an allowed directory widens a rule.
commands is matched against input.command, as a glob over the command or any
space-delimited prefix of it: the named program alone, or that program followed by its
own arguments. * works here too, which is what lets a rule name a path that moves,
such as the version hash in an installed plugin's directory. Prefix matching a shell
string is where allow-lists get walked through, so any request containing ; & | < >
backtick, $, parentheses or a newline is denied outright no matter what it starts
with, and a rule whose own text carries one of those is rejected at load. What a rule
grants is one program with its arguments, never a chain.
Write the program path unquoted in the rule. A matched pair of quotes wrapping the
whole program path comes off the request before matching, so a rule naming
/opt/bin/sync.sh still covers "/opt/bin/sync.sh" --apply, which is the form a
model produces when the command it is copying was written with quotes. The variable
form is a different case and stays denied: "${PLUGIN_ROOT}/scripts/sync.sh" carries
a $, and text whose meaning depends on the environment is not something a rule can
have named. Dispatch prompts should spell the path out.
A rule with no tool, or tool: "*", is rejected when the config loads rather than
compiled, because a wildcard would be bypassPermissions by another name. For the
same reason a Bash rule must carry commands: for every other tool an absent
matcher is a defensible "this tool, anywhere", but for Bash it would be every
command on the machine.
Every decision appends one JSON line to audit_log (defaults inside state_dir),
and a denial tells the model to stop and put the request to the operator, which
arrives on the bus as a NEEDS-INPUT question rather than as a silent failure or an
improvised workaround.
This clears the permission layer only. A seatbelt sandbox is a separate gate: where
~/.claude sits in a denyWithinAllow list, Bash there still returns Operation not
permitted no matter what this allow-list says.
String fields in the otlp block expand ${VAR} references from the process
environment, and the runner reads ~/.claude-broker/observability.env (0600) at
startup. The access token therefore lives in exactly one file: not in this repo,
not in config.json, and not in the world-readable LaunchAgent plist. An unset
reference is left verbatim so a missing secret fails visibly instead of becoming
an empty credential.
Development
Zero runtime dependencies. Node >= 18. Tests use the built-in node:test
runner and run fully offline against local bare git remotes and throwaway age
keypairs, with the Telegram and claude -p seams injected (no real network or
model calls).
npm test # node --test test/*.test.jsThe age and age-keygen binaries must be on PATH to run the encryption tests.
Layout:
lib/: envelope, age, config, repo I/O, thread store, claude adapter, escalation, runner, the permission allow-list (permission), the visibility feature (notify,activity,visibility), and kanban board tracking (kanban).bin/:broker.js(CLI),runner-main.js(launchd entrypoint / loop), andpermission-server.js(the child's approval MCP server).deploy/— LaunchAgent template, install script, key-distribution runbook.docs/— design spec and implementation plan.
Status
Deployed, carrying real traffic between the hub and its spokes. The defects that
traffic has surfaced, and what closed each one, are recorded in
.superpowers/sdd/progress.md, along with the v1 follow-ups still open: the
inert broker send --timeout and threads --open flags, and the processed
list every poll scans, which nothing prunes.
