bondsmithd
Local daemon that manages tmux-backed terminal sessions on your machine and exposes them to the Bondsmith web app over a signed WebSocket protocol. Runs entirely on your own machine — no code, terminal output, or session data is ever sent anywhere except the Bondsmith account you explicitly pair it with.
Install
npm install -g @bondsmith/bondsmithd
Requires Node >=24 and tmux 3.2 or newer (brew install tmux on macOS). node-pty, the one native
dependency, ships prebuilt binaries for macOS (Intel and Apple Silicon) and installs without
compiling anything on supported platforms.
Running
bondsmithd
On first run, bondsmithd prints a pairing code and URL — confirm it from your Bondsmith account
to trust this machine. After that, bondsmithd just needs to be running for the Bondsmith web app
to see and control sessions on this machine.
Start it from a normal interactive shell, not a stripped-down context (a bare launchd/login-item
invocation, a minimal CI shell, etc.). The daemon passes its own process.env through to every
tmux session it creates, so sessions inherit your real PATH, version manager shims
(mise/nvm/etc.), and tools like claude or pnpm — the same environment you'd get opening a
new terminal tab. A stripped environment at daemon-start time means the same stripped environment
in every session it launches.
Environment variables (all optional):
PORT=4577 # defaults to 4577
ALLOWED_ORIGINS=https://app.bondsmith.dev
BONDSMITH_RELAY_URL=ws://127.0.0.1:8787 # defaults to production; set this when developing bondsmithd against a local apps/relay
BONDSMITH_RELAY_AUDIENCE=relay.example.com # only if a non-loopback relay's RELAY_AUDIENCE differs from its hostname
BONDSMITH_API_URL must be https://, and BONDSMITH_RELAY_URL must be wss://; the plain http://
and ws:// forms are accepted only for a loopback server (127.0.0.1, localhost, [::1]). The relay
URL is only checked while the relay is enabled. The daemon refuses to register with a relay whose audience is not
the relay URL's hostname (or BONDSMITH_RELAY_AUDIENCE); a loopback relay is not checked.
BONDSMITH_DEV_TUNNEL_TOKEN also exists but only applies to the legacy direct-connection path
(BONDSMITH_RELAY_ENABLED=false) — should not be set by hand for a normal install.
Updating
Stop the running daemon, then update it and start it again:
npm install -g @bondsmith/bondsmithd@latest
bondsmithd
If you run it with npx, use npx @bondsmith/bondsmithd@latest so a cached older copy isn't reused.
When the web app and an older daemon disagree, Bondsmith shows "couldn't read a message from your daemon". Reload the page first; if that doesn't fix it, update the daemon as above.
What it does
- Creates, lists, attaches to, and stops tmux sessions prefixed
bondsmith-*. - Never enumerates or manages tmux sessions it didn't create.
- Attaches a real PTY (
tmux attach-session) so whatever is running inside (claude,opencode,bash, anything) behaves exactly as it would in a normal terminal. - Keeps tmux itself as the only source of truth — no database. Restarting the daemon rediscovers running sessions from tmux.
Agent state
An agent can tell Bondsmith what it is doing by running this from inside its session's pane, usually
from one of its hooks. $TMUX_PANE is set by tmux in every pane, so the command targets the session
that pane belongs to:
tmux set-option -t "$TMUX_PANE" -q @bondsmith-agent-state "waiting:$(date +%s)"
The value is <state>:<epoch seconds>, where state is working, waiting (needs the user) or done (finished a turn, shown until the user attaches, types or resizes after it).
The daemon reads it on its 5-second tick and shows it on the session. Anything else is ignored, as is
a report dated in the future. A waiting or done report is dropped once the pane writes output more
than 2 seconds after it, since that means the agent moved on. A working report is dropped once the
pane has been silent for 2 minutes, since an interrupted or crashed agent never reports again.
Claude Code sessions that Bondsmith starts as plain claude report this on their own (see the end of the
plan section below): working when a prompt is sent, waiting when it asks for permission or fails with
an API error, done when it finishes a turn. Nothing needs installing for those.
For an agent the daemon does not set up (a wrapper, npx claude, another agent), note that every session
Bondsmith starts has BONDSMITH_SESSION set to its id, so a hook installed once for all your projects can
stay silent everywhere else. This entry in ~/.claude/settings.json reports a Claude Code session as
waiting the moment it asks for permission, and as done when it finishes a turn:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "[ -n \"$BONDSMITH_SESSION\" ] && tmux set-option -t \"$TMUX_PANE\" -q @bondsmith-agent-state \"waiting:$(date +%s)\"; exit 0"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "[ -n \"$BONDSMITH_SESSION\" ] && tmux set-option -t \"$TMUX_PANE\" -q @bondsmith-agent-state \"done:$(date +%s)\"; exit 0"
}
]
}
]
}
}
This is optional, and nothing edits your settings for you. The command does nothing outside a Bondsmith
session, and sessions started before the daemon set the variable are skipped by it. It reports only
waiting and done. A working report is dropped after the pane has been silent for 2 minutes, so one that an
interrupted Claude Code never clears (it does not run Stop then, and has no hook for Escape) expires on
its own.
An agent can also share the plan it is working against, which Bondsmith shows above the session's
changes. Write it as markdown to .bondsmith/plan.md in the session's folder once the plan is agreed,
and again whenever it changes. The file is read as it is, cut at 32,768 characters; a symlink, or a
path that leaves the session's folder, is ignored. Changes does not list the file while it is untracked.
The daemon never writes to your repository, so git status still shows it and git add . would commit it;
add .bondsmith/plan.md to your .gitignore or .git/info/exclude to prevent that.
A hook can store the plan outside the repository instead, in ~/.bondsmith/plans/<session id>.md, where
<session id> is $BONDSMITH_SESSION. It is read the same way and wins over the folder's file and the tmux
option, so a plan written there is never left over from an earlier session.
For Claude Code the daemon sets this up itself. When a session's command is claude (that word alone, not
behind npx or a wrapper; VAR=value words before it are fine), the daemon starts it as
claude --settings ~/.bondsmith/hooks/claude-settings.json …. That file adds hooks to the ones you
already have: one stores the plan as above as soon as plan mode proposes it, before you approve it;
another logs the agent's task calls (TaskCreate, TaskUpdate, TodoWrite) to ~/.bondsmith/plans/<session id>.tasks.jsonl,
which the daemon replays into the session's task list (shown under the plan in the browser); and four
report the agent state (working on a prompt; waiting on a permission request, the end of a turn, or a turn that fails with
an API error). Nothing in ~/.claude changes, the session still shows the command you typed, and the hooks
do nothing outside a Bondsmith session. Claude Code's /hooks menu does not list hooks passed with
--settings, though they run.
A fifth hook watches Bash calls for gh pr create. When one succeeds and prints a pull request URL, the URL is
appended to the tmux option @bondsmith-prs (space separated, each once, the newest 20 kept), and the daemon
reports it on the session as pullRequestUrls. It matches gh pr create only as a command of its own, so
echo "gh pr create" or gh pr view record nothing, and a failed command never reaches it.
The daemon leaves the command alone if it already has --settings, if the installed claude does not list
--settings in claude --help (asked once per daemon run; if claude cannot be run at all, that is logged
and asked again after a minute), or if BONDSMITH_AGENT_HOOKS is set to off, 0, false or no where
the daemon starts. Each hook has a timeout (5 seconds for a state report, 10 for the plan and the task list), so a stuck command
cannot hold up the agent. Plans are written to ~/.bondsmith/plans readable only by you, and a symlink in
the way is replaced, not followed. A plan the agent changes later without going back through plan mode is
not seen; the hook's plan keeps winning over the folder's file until the next approval.
An agent that cannot write files can set a tmux option instead, from inside the session's pane. The file wins when both exist:
tmux set-option -t "$TMUX_PANE" -q @bondsmith-plan "$(cat <<'EOF'
# What the session is doing
- step one
- step two
EOF
)"
Keep that one short: tmux refuses a command over about 16,000 bytes (measured: 16,000 works, 17,000 fails with "command too long"), so a longer plan is not stored and the panel keeps showing the previous one. The file has no such limit.
The daemon reads the plan only when the browser asks, so unlike the state report it has no timestamp and is not dropped when the pane writes output.
An agent with no hook can still get attention by ringing the terminal bell (printf '\a'). A bell
rung in a session nobody has looked at marks it as waiting, whatever its last report said, until tmux
clears the flag when someone attaches to the session. Agents that can ring the bell for approvals or
a finished turn work this way with nothing to install.
With no hook and no bell, a session still shows as waiting once its pane has written something and then
gone silent for 8 seconds. Agents animate while they work and stop when they need you, so this needs no
setup for any agent. It cannot tell an approval prompt from a finished turn, and a long silent command
looks the same. A working hook report takes precedence over this while the agent keeps writing, and stops counting
once the pane has been silent for 2 minutes.
Attaching to a session, typing in it, resizing it, or inserting text into it counts as the user looking, so the redraw that causes is ignored (output within 3 seconds of it), and the session reads as waiting again only after new output goes quiet. When the daemon starts, the sessions it finds count as already seen, so one that was waiting before a restart shows as waiting again only after it writes more.
tmux only flags a bell when no client is attached to the session or the bell is in a window other than the one the attached client has open. A session whose terminal is open in a browser tab, even a hidden one, counts as being looked at, so a bell in it is not flagged; use the hook report for that case.
Adding an agent
The daemon splits what is the same for every agent from what is one agent's own.
| Part | Where | Agent-specific? |
|---|---|---|
| What an agent reports: state, plan, task list, pull requests (the channels below) | src/sessions/agent-reports/ | No |
Turning reports into what the UI shows (readSessionReport: the plan and the task list for getPlan; attention state) | agent-reports/, tmux/agent-state.ts | No |
| Recognising an agent's command and launching it so it reports | src/sessions/agents/<agent>/ | Yes |
| The list of agents | AGENT_IDS in @bondsmith/local-agent-protocol; agents/registry.ts | Add one line each |
An agent reports through these channels, and the daemon reads them without caring who wrote them:
| Report | Channel |
|---|---|
| State | tmux option @bondsmith-agent-state = <working|waiting|done>:<epoch seconds> on the session's pane; or ring the bell; or nothing (a pane gone quiet counts as waiting) |
| Plan | ~/.bondsmith/plans/<session id>.md, or .bondsmith/plan.md in the session's folder, or tmux option @bondsmith-plan |
| Task list | ~/.bondsmith/plans/<session id>.tasks.jsonl: one JSON TaskEvent per line, appended (add, update, remove, replace; see agent-reports/task-list.ts) |
| Pull requests | tmux option @bondsmith-prs, space-separated URLs |
To add an agent: add its id to AGENT_IDS, which makes registry.ts refuse to compile until it has an adapter. A new
AgentAdapter needs matches(command) and capabilities, each capability an optional function (report launches the agent so it reports; deliverNote and
withdrawNote leave and take back a note it reads on its next hook, see agent-reports/notes.ts). With none use plainAdapter(id),
as Codex and opencode do today (they report through the bell and the quiet-pane check). With hooks, put the agent's
settings and payload mapping in agents/<agent>/ and build its hook commands from the helpers in
agent-reports/hook-commands.ts and agent-reports/hook-scripts.ts; the agent's file only says where in its
payload the plan, the task event and the shell command are. agents/claude/ is the worked example.
Production connectivity (current status)
bondsmithd connects outbound to the Bondsmith relay by default (BONDSMITH_RELAY_ENABLED=true,
BONDSMITH_RELAY_URL=wss://relay.bondsmith.dev), which is what a production account actually
uses — no dev-tunnel token involved, no config needed for a real install. Override
BONDSMITH_RELAY_URL to a local apps/relay instance (see "Environment variables" above) only when
developing bondsmithd itself.
The dev-tunnel-token requirement only applies to the legacy direct-connection path
(BONDSMITH_RELAY_ENABLED=false), which still 404s the token-handoff route in production by
design — that path is for local debugging only, not a real install.
Security model
- Every connection is validated against the daemon's
ALLOWED_ORIGINSand completes a cryptographic handshake — a fresh, single-use, daemon-issued challenge signed by a controller key the daemon has explicitly and locally trusted. See "Pairing" above for how a controller key becomes trusted in the first place. - Every sensitive command AND every sensitive read (session listing, workspace status, diffs, plus every state-changing action) must arrive as part of that same authenticated, signed connection — read access is not treated as lower-risk than write access, since a read can expose session metadata and source code just as much as a write can change it.
- Replay protection is connection-scoped and lives entirely in the daemon's memory — nothing about it is persisted to disk, so a browser reload or reconnect always starts a clean, independent session.
- Revoking trust for a controller (from your Bondsmith account) closes its active connections within a bounded time, whether or not it's mid-session.
- Only ever touches tmux sessions it created itself, named
bondsmith-<uuid>. - All tmux/PTY invocations use argv arrays, never shell string interpolation.
- PTY/terminal contents are never logged or persisted by the daemon itself.
- This is not a confidentiality boundary against a compromised network path between your machine and Bondsmith Cloud — the design assumes source, protocol behavior, and the daemon binary itself are fully observable by an attacker, and derives its guarantees from cryptographic signing and local trust decisions, never from secrecy of the code. The dev-tunnel admission token described under "Production connectivity" above is the one exception (a genuine shared-secret mechanism), but it only applies to the legacy direct-connection path, not the relay path a real install uses.
Full design detail: docs/architecture/local-sessions-auth-decision-log.md in the
bondsmith repository.
Contributing / developing bondsmithd itself
This section is for people working in the bondsmith monorepo, not for installed users of the
published package.
pnpm --filter @bondsmith/bondsmithd dev
This is deliberately not part of pnpm dev / turbo dev — it needs tmux and a native module
on the host, and it's opt-in tooling, not a service the rest of the stack depends on.
node-pty is pinned to 1.2.0-beta.15, not the 1.1.0 stable release. 1.1.0's prebuilt
darwin-arm64 binary fails immediately with posix_spawnp failed on Node 24 (verified on macOS
15/Apple Silicon) — any spawn, not just tmux, crashes the whole daemon process. The 1.2.0-beta
line has a prebuild that works correctly on Node 24. Don't downgrade to a node-pty stable release
without first confirming pty.spawn(...) actually works on your Node version.
For local development, create a .env file in this directory (or export the vars directly):
PORT=4577
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:4000,http://127.0.0.1:4000
Port 4000 is included because that's where make dev's Docker Compose stack serves the web app
(docker-compose.dev.yml maps 4000:4000); port 3000 covers running apps/web directly via
pnpm dev:web instead.
The web app needs NEXT_PUBLIC_LOCAL_AGENT_DAEMON_URL=ws://127.0.0.1:4577 set wherever it reads
its env from — under make dev that's the repo-root .env.local (referenced by
docker-compose.dev.yml's env_file), not apps/web/.env.local.
Publishing
pnpm --filter @bondsmith/bondsmithd build bundles @bondsmith/local-agent-protocol directly into
dist/index.js (see tsup.config.ts's noExternal) — the published package has no workspace:*
dependency and no runtime dependency on that package existing on any registry. Only dist/index.js,
package.json, and this README are published (see package.json's files); sourcemaps and every
other repo file are excluded.
Non-goals (current scope)
No cloud execution, no multi-machine support, no session sharing, no Claude/OpenCode API integration, no agent-output parsing or detection, no terminal history storage or session recording.