matron-bridge runs beside the Claude Code and Codex CLIs on your dev box, spawns and manages agent sessions, and publishes them to a matron-journal server. The Matron apps — Apple (iPhone + Mac), Android, desktop, and web — chat with those sessions from anywhere, with live streaming and a return path for user input. See matron.chat for the overview.
Claude uses --print structured JSON streaming. Codex uses a persistent codex app-server connection with native streaming, approval cards, and question buttons. Set MATRON_CODEX_TRANSPORT=exec to use the legacy one-process-per-turn backend.
Use /switch codex or /switch claude inside an idle session to hand the same bridge conversation to the other agent. The bridge keeps separate native session IDs for Claude and Codex, resumes each one when you switch back, and prepends only the transcript messages that agent has not seen to your next real prompt. The Matron conversation ID stays stable, as do the shared working directory, files, and Git state. Provider-private reasoning and tool state are not transferable.
Codex defaults to YOLO mode in Matron: danger-full-access with approval_policy="never", disabling sandbox restrictions and approval prompts. Set CODEX_SANDBOX_MODE=workspace-write or read-only to enable sandboxing and native on-request approval cards. Matron Plan mode always stays read-only with escalations denied. The legacy exec backend cannot answer approvals.
For the full operator reference, see Using the Codex backend.
This project is licensed under AGPLv3. For alternative licensing, contact licensing@matron.chat.
- Node.js 22+
- Claude Code CLI and/or Codex CLI installed and authenticated
- A matron-journal server, plus the Matron app signed in to it (to pair the bridge by QR code) or an agent token minted on the journal server with
matron-admin agent add <user> <device-name>
Linux (Ubuntu/Debian): apt-get install nodejs npm (or use nvm), and apt-get install ffmpeg for the key frames of videos sent in chat. Voice notes are transcribed by the journal, so whisper is optional: setup/install-whisper.sh installs it (and ffmpeg) as a local fallback.
macOS: Homebrew, Xcode Command Line Tools (xcode-select --install), and brew install node@22, plus brew install ffmpeg for the key frames of videos sent in chat. Voice notes are transcribed by the journal, so whisper is optional: setup/install-whisper.sh runs brew install whisper-cpp ffmpeg for a local fallback.
Windows (10 1809+ / Server 2019+): Node.js 22+ (winget install OpenJS.NodeJS.LTS), Git for Windows (winget install Git.Git — Git Bash is what gives Claude Code its Bash tool; without it sessions get the PowerShell tool and live Bash output is off), and Claude Code via the native installer (irm https://claude.ai/install.ps1 | iex), logged in (claude) as the user who will run the bridge. If Claude Code cannot find Git Bash, set CLAUDE_CODE_GIT_BASH_PATH in %USERPROFILE%\.claude\settings.json (see the Claude Code setup docs). Not available on Windows: the Codex backend and the local whisper fallback (voice notes are still transcribed by the journal).
For public file and secret viewer links on macOS, install cloudflared if you want to publish the local viewer through Cloudflare Tunnel:
brew install cloudflarednpm install
npm run setup # guided: asks for your journal URL, pairs with the Matron app (or takes a pasted token), tests the connection, writes .env
npm startTo get the agent token, the wizard shows a pairing code as a QR code in the terminal. In the
Matron app, open Settings -> Devices -> Add Agent -> Scan QR (or type the code) and name
the agent; the bridge picks up the token by itself, so nobody copies it around. If you'd
rather mint the token on the journal server (matron-admin agent add <user> <device-name>),
choose the paste option instead.
To pair (or re-pair) without going through the rest of the wizard, for example after the agent was revoked in the app:
npm run pair # uses JOURNAL_WS_URL from .env
npm run pair -- --server https://journal.example.com
npm run pair -- --force # replace a token that still works (pairs a NEW agent)npm run pair refuses to replace a token that still connects unless you pass --force.
Remote journals must use https/wss: the token comes back in the pairing response.
The wizard and npm run pair store the agent token in a gitignored .journal-token file (mode 600),
generates HMAC_SECRET, and leaves every other setting on its documented default.
Re-run it any time to change answers; the previous .env is backed up to .env.bak.
Prefer to configure by hand (or provisioning non-interactively)?
cp .env.example .env
# Edit .env — set JOURNAL_WS_URL + JOURNAL_TOKEN_FILE (or JOURNAL_TOKEN), ALLOWED_USER_IDS, and HMAC_SECRET (openssl rand -hex 32) for file/secret linksInstall and authenticate the Codex CLI as the same OS user that runs the bridge:
npm install -g @openai/codex
codex login
codex login status
codex exec --json "Reply with exactly: Codex is ready"The last command verifies the same non-interactive interface used by the bridge. For a headless machine, use codex login --device-auth; API-key login is also supported by the CLI. Do not copy ~/.codex/auth.json into this repository or put credentials in chat.
To make Codex the default, set these values in .env:
MATRON_DEFAULT_AGENT=codex
CODEX_SANDBOX_MODE=workspace-write
# Optional; the repository's BRIDGE_CODEX.md is used when empty
BRIDGE_CODEX_MD_PATH=You can keep Claude Code as the default and select Codex per conversation instead:
/start --codex ~/Dev/my-project
/agent
/model default
After changing .env, restart the bridge or re-run the service installer as described below. See Using the Codex backend for sandbox guidance, /switch behavior, provider-specific commands, files/media, and troubleshooting.
Use the OS-detecting installer:
setup/install.sh # installs npm deps, seeds .env
# Linux (systemd):
sudo setup/service.sh
# macOS (LaunchAgent — runs while you're logged in). Do NOT use sudo:
setup/service.sh
# or, system-wide LaunchDaemon (runs at boot, requires sudo):
sudo SCOPE=system setup/service.shOn macOS, the default user-scope install must run as your own user. Under
sudo it fails with Bootstrap failed: 125: Domain does not support specified action and can leave root-owned files behind. If that happened, clean up with:
sudo rm -f ~/Library/LaunchAgents/chat.matron.matron-bridge*.plist
sudo chown "$USER":staff ~/Library/LaunchAgentsAfter editing .env, re-run setup/service.sh (on macOS, launchd has no
EnvironmentFile equivalent — values are inlined into the plist at install
time).
Per-app firewalls on macOS identify a program by its executable file and its
code signature. Every Node script runs through the same node binary, so the
bridge would otherwise be indistinguishable from any other Node program
(npm, a dev server, a test runner), and a wrapper script, symlink or
process.title does not change that.
So on macOS the bridge's starters (setup/service.sh, start-bridge.sh,
restart.sh) go through bin/run-named-node, which keeps a copy of your
node binary under a Matron name in ~/.matron/bin and runs the script with
it. The copy keeps node's own code signature (codesign -v passes), and the
firewall shows:
| Process | Name |
|---|---|
The bridge (index.js) |
matron-bridge |
The file viewer (viewer/start.js) |
matron-viewer |
The ask-user and show-file MCP servers the bridge starts for each
session run with the bridge's copy too, so they appear as matron-bridge.
- After a node upgrade the copy is refreshed on the next start (it is replaced whenever node's size or modification time differs), and the firewall may ask about it once more.
- To opt out, set
MATRON_NAMED_NODE=0in the environment the starter runs in (for the launchd service, add it to.envand re-runsetup/service.sh). The script then runs with node itself. MATRON_NAMED_NODE_DIRmoves the copies elsewhere;NODE_BINpicks the node binary (the service installer records the one it found).- If the copy cannot be written, the launcher prints one warning and runs node itself, so the bridge still starts.
Existing installs switch over when setup/service.sh is re-run. On Linux the
launcher just runs node; the Windows starters do not use it.
From PowerShell (not elevated), as the user who will run the bridge:
setup\install.ps1 # checks node/git/claude, npm install, runs the wizard (or seeds .env)
setup\service.ps1 # registers two Scheduled Tasks at logon and starts themThe tasks (\Matron\matron-bridge and \Matron\matron-bridge-viewer) run in
your desktop session, so a Claude session on that box can drive GUI
applications; the flip side is that the bridge only runs while you are logged
on. For an unattended machine, configure auto-logon. index.js loads .env
itself, so after editing .env just run .\restart.ps1.
Print mode (the default) is the supported session mode on Windows. /iv
(interactive TUI mode) runs through ConPTY, which re-renders Claude Code's
terminal output, and is best effort: if prompts are not detected, switch back
with /iv off.
The service installer starts two units on Linux, macOS and Windows: the bridge and the local file viewer. The viewer listens on 127.0.0.1:$MATRON_VIEWER_PORT and powers file links, secure secret requests, and one-time sensitive-data links.
To make those links usable from Matron clients, set VIEWER_BASE_URL to a public HTTPS URL that forwards to the local viewer (e.g. via a Cloudflare named tunnel or your own reverse proxy pointed at 127.0.0.1:$MATRON_VIEWER_PORT). The bridge no longer ships its own Cloudflare tunnel helper — provisioning the tunnel/DNS is a dev-box-level concern, not something this repo manages.
The share_html_page tool gives an agent a signed /page link that renders one self-contained HTML file from its working directory as a web page (reports, demo packs, mock-ups). These links last 7 days by default, up to 30, and can be opened many times. Each open re-runs the same file guards as /view: the file must be in the workdir, not a symlink, not sensitive-named, and at most 25 MB.
The viewer origin also serves the secret-request and sensitive-data pages, so pages are served with a Content-Security-Policy: sandbox header that has no allow-same-origin. The browser then runs the page in an opaque origin of its own. Its scripts cannot read from or post as the viewer origin, and it gets no cookies or storage. The policy also blocks network reads, form posts and framing by other sites, and Referrer-Policy: no-referrer keeps the link token out of the Referer of anything the page links to. To serve pages from a hostname of their own anyway, point a second public hostname at the same viewer and set VIEWER_PAGE_BASE_URL.
Live Bash output streams to Matron clients over the authenticated
matron-journal WebSocket (stream_append frames — see the
tool-output streaming design spec
in the matron-journal repo). It no longer uses VIEWER_BASE_URL or the viewer's
/live/ws endpoint, and new chat.matron.live_output events carry no
viewer_url. The viewer service is still required for file links, secure
secret requests, and one-time sensitive-data links.
Linux (systemd):
| Action | Command |
|---|---|
| Status | systemctl status matron-bridge |
| Restart | sudo systemctl restart matron-bridge |
| Logs | journalctl -u matron-bridge -f |
| Stop | sudo systemctl stop matron-bridge |
The installer also manages matron-bridge-viewer — substitute that unit name for viewer status and logs.
macOS (launchd, user scope):
| Action | Command |
|---|---|
| Status | launchctl print gui/$UID/chat.matron.matron-bridge | head -20 |
| Restart | launchctl kickstart -k gui/$UID/chat.matron.matron-bridge |
| Logs | tail -f ~/Library/Logs/matron-bridge.log |
| Stop | launchctl kill TERM gui/$UID/chat.matron.matron-bridge |
| Uninstall | launchctl bootout gui/$UID/chat.matron.matron-bridge && rm ~/Library/LaunchAgents/chat.matron.matron-bridge.plist |
For SCOPE=system setups, replace gui/$UID with system and ~/Library/LaunchAgents with /Library/LaunchDaemons.
Windows (Scheduled Tasks):
| Action | Command |
|---|---|
| Status | Get-ScheduledTask -TaskPath '\Matron\' | Select TaskName, State |
| Restart | .\restart.ps1 (graceful: POST /shutdown, then the task is started again) |
| Deploy latest | .\deploy.ps1 (-DryRun to preflight only) |
| Logs | Get-Content -Wait "$env:LOCALAPPDATA\matron-bridge\logs\matron-bridge.log" |
| Stop | .\restart.ps1 -StopOnly (graceful; disables the task until the next .\restart.ps1) |
| Uninstall | setup\service.ps1 -Uninstall |
The task restarts the bridge within a minute if it exits with a non-zero
code, and a five-minute watchdog trigger relaunches it whenever it is not
running (a no-op while it is). A plain Stop-ScheduledTask is therefore
undone within five minutes; use -StopOnly, which disables the task.
restart.ps1 run from inside a bridge session (a Claude tool call,
deploy.ps1) hands itself to a one-shot Scheduled Task a few seconds out,
because Task Scheduler would otherwise stop it along with the bridge.
Import preflight: MATRON_BRIDGE_PREFLIGHT=module node index.js loads
index.js's whole static import graph (native bindings included) and exits 0
before it reads .env or starts anything. deploy.sh, deploy.ps1 and the
self-restart check below all use it. Startup code added to index.js must stay
below that early exit.
Self-restart onto new code (every platform): a deploy that finds a live
session (deploy.sh --dry-run, a fleet updater script) leaves the
new code on disk and the old process running. The bridge notices by itself:
it polls its checkout's HEAD reflog, lets a landed update settle for five
minutes (so npm install has finished), proves the tree boots with the same
preflight the deploy scripts run, and then exits for the supervisor to
relaunch it at the first poll where no session has work in flight: a turn
running, a background job (shell, monitor) still alive under the session's
agent process, or a subagent running. A running turn always holds the
update. Background jobs and subagents share the idle reaper's eight-hour
cap from the session's last activity, so a stuck background job cannot
defer updates forever. If the process table cannot be read, running turns
and subagents still hold the update, with the cap applying only to
subagents. While waiting,
the bridge logs a warning every 30 minutes. A box that would rather force
the restart after a while sets
MATRON_CODE_UPDATE_FORCE_AFTER_MS; the interrupted chats then carry on by
themselves (MATRON_CODE_UPDATE_AUTO_CARRY_ON=0 gives them the "Carry on"
card instead). Sessions between turns resume with their history on their next turn,
as with any restart. The exit code is 75, non-zero on purpose: launchd only
relaunches after a non-zero exit.
The file viewer follows the same rule: it watches the same checkout and,
once new code has settled and the viewer module loads from it, exits with
75 for its own supervisor to relaunch it. It has no turns to wait for. This
keeps the viewer and the bridge on the same code, so a change to how the
viewer signs its calls to the bridge cannot leave secret forms and one-time
links failing until the machine reboots. MATRON_CODE_UPDATE_RESTART=0 in
the viewer's environment turns it off.
| Variable | Description | Default |
|---|---|---|
ALLOWED_USER_IDS |
Comma-separated allowlist of authorized user identities for this bridge (its sender label for journal-originated session commands) | "" (any user) |
DEFAULT_WORKDIR |
Default working directory for coding-agent sessions; ~ expands to the service user's home directory |
process.cwd() if unset |
MATRON_DEFAULT_AGENT |
Default coding agent (claude or codex) for new sessions; override per command with --claude / --codex. The box's own default set in the apps (Settings ▸ Devices, journal PUT /devices/:id/defaults, or the box_defaults_set tool) takes precedence |
claude |
MATRON_DEFAULT_MODEL |
Claude model for fresh starts when none is picked (New Chat picker, /start without --model); an alias such as fable, opus, sonnet or a full claude-* name. The default alias resolves to this too. Resumed rooms keep their own model. The box's own default model (Settings ▸ Devices, when Claude is its default agent) and then the user's own default model (Settings in the apps, journal GET /defaults) take precedence when this box can run them. Claude only; the effective default is reported to the picker as default_model. |
fable |
MATRON_DEFAULT_EFFORT |
Claude reasoning effort for fresh starts when none is picked (New Chat, agent_session_start without effort), passed as --effort: low, medium, high, xhigh or max. Empty means Claude Code's own default; an unknown level is ignored with a warning. Resumed rooms keep the level they last ran at. The user's own default effort (Settings in the apps) takes precedence. Claude only; the effective default is reported to the picker as default_effort alongside effort_options. After a person changes a chat's model or effort, the bridge asks "Use … for new chats too?" (No / Yes; no answer means No) and Yes saves it as their default. |
(empty) |
MATRON_CODEX_DEFAULT_MODEL |
Codex model for fresh Codex starts when none is picked and the box's own default (Settings ▸ Boxes) sets none: a Codex model id such as gpt-6-astra. default is ignored (the ChatGPT account refuses it). Empty leaves it to Codex's own config. |
(empty) |
MATRON_CODEX_DEFAULT_EFFORT |
Codex reasoning effort for fresh Codex starts when none is picked and the box's own default sets none: minimal, low, medium, high or xhigh. Empty or unknown means xhigh. |
xhigh |
SESSION_IDLE_TIMEOUT_MS |
Idle time after which a session is silently reaped (next user message auto-resumes it). Set to 0 to disable, or 86400000 to restore the previous 24h default. |
3600000 (1 hour) |
SESSION_IDLE_CHECK_MS |
How often the reaper scans for idle sessions | 300000 (5 minutes) |
MATRON_PRE_REAP_HANDOFF |
Pre-reap handoff: shortly before the idle reap, while the model's prompt cache is still warm, the bridge sends an idle session one turn asking it to refresh its mission status and post a progress milestone if anything changed (skipped for the Coordinator, for a session with no mission, and when the status is already newer than the session's last activity), then compacts it when its context is large (never a Codex session). Once per idle period, never mid-turn or while a prompt waits, and it does not postpone the reap. One line in the chat. 0/false/off disables it. |
on |
MATRON_PRE_REAP_HANDOFF_IDLE_MS |
How long a session must have been idle before the handoff runs. Must be shorter than SESSION_IDLE_TIMEOUT_MS, or the handoff is off. |
3000000 (50 minutes) |
MATRON_PRE_REAP_COMPACT_PCT |
Context gauge (percent of the model's window) at or above which the handoff compacts the session. 0 or off keeps the status turn but never compacts. |
20 |
MATRON_CODE_UPDATE_RESTART |
Restart the bridge by itself once newer code is on disk in its checkout (see "Self-restart onto new code" above). 0/false/off disables it. |
on |
MATRON_CODE_UPDATE_POLL_MS |
How often the checkout's HEAD reflog is read for new code | 60000 (1 minute) |
MATRON_CODE_UPDATE_SETTLE_MS |
How old a HEAD move must be before the new tree is preflighted and trusted (the deploy's npm install runs after the pull) |
300000 (5 minutes) |
MATRON_CODE_UPDATE_WARN_EVERY_MS |
How often the bridge logs that it is still waiting for a running turn before it can restart onto new code | 1800000 (30 minutes) |
MATRON_CODE_UPDATE_FORCE_AFTER_MS |
Opt-in: after this long waiting for a running turn, restart anyway (the turn is cut off and carries on by itself, see the next row). 0 never forces. |
0 (never) |
MATRON_CODE_UPDATE_AUTO_CARRY_ON |
After a forced self-restart (MATRON_CODE_UPDATE_FORCE_AFTER_MS) that cut turns off, resume those chats automatically with an [auto-continue after bridge update] turn instead of publishing a "Carry on" card. 0/false/off keeps the card. |
on |
BASH_DEFAULT_TIMEOUT_MS |
Default timeout for a bridge-spawned Claude session's Bash tool call when the model sets none. Raises Claude Code's 120000 (2 min) built-in so long Codex reviews / test suites aren't SIGTERM'd mid-run. Positive integer ms; out-of-range (>3600000 = 1h) is clamped, malformed is ignored. Applies at session spawn — restart to take effect. |
1200000 (20 min) |
BASH_MAX_TIMEOUT_MS |
Ceiling for an explicit per-call Bash timeout in a bridge-spawned Claude session. Same parsing/clamping/restart semantics as BASH_DEFAULT_TIMEOUT_MS; raised to the resolved default if set lower. |
1800000 (30 min) |
USAGE_FETCH_TIMEOUT_MS |
How long the claude -p /usage one-shot behind the account meters (status frame, /limits) may run before it is killed. It scans the box's local session transcripts, so it slows on boxes with many sessions. Whole milliseconds, at least 1000; anything else uses the default. |
180000 (3 minutes) |
BRIDGE_CLAUDE_MD_PATH |
Optional markdown file appended to bridge-spawned Claude sessions for bridge-specific guidance | BRIDGE_CLAUDE.md |
BRIDGE_CODEX_MD_PATH |
Optional developer-instructions markdown injected into bridge-spawned Codex turns | BRIDGE_CODEX.md |
BRIDGE_NOTICES_MD_PATH |
Markdown appended to every session's instructions while the user's "Send things I need to read to For you" setting is on (text after a <!-- coordinator --> line goes to the Coordinator only) |
BRIDGE_NOTICES.md |
CODEX_SANDBOX_MODE |
Sandbox for Codex programmatic turns: read-only, workspace-write, or danger-full-access |
workspace-write |
CODEX_NETWORK_ACCESS |
Command network access in Codex workspace-write mode: true or false; empty inherits Codex configuration |
unset |
DEBUG |
Set to 1 to log verbose bridge and coding-agent events |
0 |
MATRON_INTERACTIVE_MODE |
Set to 1 to spawn Claude Code as a real PTY (instead of --print stream mode) so interactive flows like /login work |
0 |
MATRON_DUMP_PTY |
When MATRON_INTERACTIVE_MODE=1, set to 1 to dump raw PTY bytes for each session to a private per-session temp dir, e.g. /tmp/iv-pty-XXXXXX/<roomId>.log (exact path is printed to the bridge log at session start), for debugging stuck-prompt issues |
0 |
MATRON_CLAUDE_QUEUE_IN_MATRON |
Messages typed during a running print-mode Claude turn wait in Matron's queue. Set to 0 (or false/off) to write plain messages straight into the running turn instead (see Mid-turn steering) |
1 (queue in Matron) |
HMAC_SECRET |
Shared secret for signed file viewer URLs | — |
VIEWER_BASE_URL |
Public URL for file viewer | — |
LINK_EXPIRY_MS |
Signed URL expiry in ms | 900000 (15 min) |
MATRON_APPROVAL_KEYS_FILE |
Path of the pinned approval keys file. Set, the box verifies the user's signed approval itself before it starts a session, joins a chat or takes a file (see "Approval keys"). The file must not be owned by the bridge's user, writable by group or others, or a symlink | (unset: off) |
MATRON_APPROVAL_MAX_AGE_MS |
An upper bound on how old a signed approval may be, for every kind of request | (per kind: 5 minutes to 24 hours) |
MATRON_APPROVAL_JOURNAL_HOST |
The journal's public host as signed approvals name it, when it differs from the host in JOURNAL_WS_URL |
host of JOURNAL_WS_URL |
MATRON_STEP_UP_ALL |
1 marks every question this box files, and every comment with buttons, as step-up: the tap needs the user's device signature |
off |
MATRON_LOCKED |
1 puts the box in locked mode: outside input is taken only during a signed unlock window. Needs MATRON_APPROVAL_KEYS_FILE; the bridge refuses to start without it |
off |
MATRON_UNLOCK_MAX_MINUTES |
The longest unlock window a locked box grants | 120 |
MATRON_BRIDGE_API_PORT |
Internal API port (hooks, MCP, viewer) | 9802 |
MATRON_BRIDGE_API_AUTH |
off stops checking the per-session tokens on the internal API (they are still issued). Leave it unset. Scripts run outside a session can list sessions (POST /sessions) with curl -H @"${XDG_STATE_HOME:-$HOME/.local/state}/matron-bridge/api-operator.header", an owner-only file the bridge rewrites at each start |
on |
MATRON_VIEWER_PORT |
Local file viewer port | 9803 |
MATRON_WAKE_CONVO_ID |
Conversation that POST /wake on the internal API wakes. Lets a scheduled job on the same machine wake one conversation with a fixed text, resuming its session if it was idle-stopped; the request can choose neither the conversation nor the text. Off unless both this and MATRON_WAKE_TEXT are set |
— |
MATRON_WAKE_TEXT |
The exact text POST /wake delivers to MATRON_WAKE_CONVO_ID |
— |
MATRON_WAKE_TARGETS |
Optional. Comma-separated names of further wake targets, for a box that runs more than one conversation needing a wake. POST /wake/<name> wakes target <name> (lowercase letters, digits and single dashes, up to 32 characters) with the conversation in MATRON_WAKE_CONVO_ID_<NAME> and the text in MATRON_WAKE_TEXT_<NAME>, where <NAME> is the name upper-cased with dashes as underscores (chat-2 reads MATRON_WAKE_CONVO_ID_CHAT_2). Each target coalesces on its own, and MATRON_WAKE_TOKEN guards them all. A listed target missing either variable is off (404) and logged at startup |
— |
MATRON_WAKE_TOKEN |
Optional. When set, POST /wake is refused (403) unless the request carries it in an X-Matron-Wake-Token header — the internal API port is reachable by every local user, and a wake resumes a session |
— |
MCP_DEFAULT_EXTRAS |
Comma-separated MCP extras loaded for every session on this machine (e.g. circleci). Names must match mcpExtras keys in mcp-config.json/mcp-config.local.json. |
(none) |
BRIDGE_PLUGIN_CACHE_DIR |
Dir plugin MCP servers (context7, serena, …) load from. Unset = an empty bridge-owned dir (no plugin MCPs, lean). Set to ~/.claude/plugins or a curated dir to re-enable. |
(empty dir) |
BRIDGE_TOOL_FILTER |
Set to 1 to hide Coordinator-only ask-user MCP tools from confirmed Claude worker sessions, in print and interactive modes. Unknown roles keep the full list. |
off |
BRIDGE_TOOL_ROLE |
Set per Claude session by the bridge when filtering is enabled: worker, coordinator, or full. Only worker hides tools; unset or unknown values keep every tool. |
unset |
DOWNLOAD_RATE_LIMIT |
Viewer requests per minute for file downloads and sensitive-link shell pages | 30 |
PAGE_RATE_LIMIT |
Viewer requests per minute for rendered HTML pages (/page) |
60 |
VIEWER_PAGE_BASE_URL |
Optional separate public URL (pointed at the same viewer) for share_html_page links |
VIEWER_BASE_URL |
REVEAL_RATE_LIMIT |
Viewer requests per minute for POST /sensitive/reveal, counted separately so shell loads cannot exhaust it |
30 |
WHISPER_MODEL_PATH |
whisper.cpp model for the optional local voice-note fallback. Used only when this file, and the whisper-cli built beside it, exist; the journal transcribes first, and the bridge never installs whisper itself |
~/.local/share/whisper-cpp/models/ggml-small.bin |
WHISPER_LANGUAGE |
Language for the local whisper fallback | en |
WHISPER_PROMPT |
Vocabulary hint for the local whisper fallback, passed to whisper-cli as --prompt: names and terms it should spell right (box names, sudo, PR). Set it to your own list, or to the empty string to send no prompt |
(built-in Matron vocabulary) |
SUMMARY_PROVIDERS |
Preference order for conversation titles and rolling TOC summaries, comma-separated from journal, anthropic, openai, gemini. journal asks the journal (POST /summarise, with the bridge's journal token), which holds the provider key server-side, so a bridge connected to a journal needs no summary key of its own. A provider without its key (or, for journal, without a journal URL and token) is skipped; a failed request falls through to the next one. After a 404 or 503 the journal is skipped for 10 minutes, and after a 429 for as long as Retry-After says. Summaries are off when no provider is available |
journal,anthropic,openai,gemini |
SUMMARY_ANTHROPIC_API_KEY |
Optional local fallback: dedicated Claude summariser key (claude-haiku-5-5 by default). Does not change session authentication |
— |
SUMMARY_ANTHROPIC_MODEL |
Claude summariser model | claude-haiku-5-5 |
OPENAI_API_KEY |
Optional local fallback: OpenAI summariser key (gpt-6-luna by default) |
— |
GEMINI_API_KEY |
Optional local fallback: Gemini summariser key (gemini-3-flash-preview by default) |
— |
SUMMARY_MODEL |
Model override for the OpenAI and Gemini summarisers | — |
Voice notes are transcribed by the journal: at upload when it has a cloud speech-to-text key (MATRON_STT_AZURE_KEY), falling back to its own whisper when the cloud call fails; tracker-item voice notes also use the journal's whisper when it has no key (see the journal's protocol doc). For a voice note sent in chat the bridge asks GET /media/:id/transcript; when the journal has no words for it (its job failed, or never ran) the bridge asks it to try again with POST /media/:id/transcript and waits for the answer. A voice note on a tracker item follows the same order, and the words are written back to the item's comment. Only when the journal still has no words does the bridge run its own whisper, and only where it is installed (see WHISPER_MODEL_PATH); otherwise a chat voice note is not delivered (any text typed with it still is) and the user is told why, and a tracker item's turn goes out with (transcription failed) in place of the words ((no transcript) when the journal heard none). Videos sent in chat arrive as key frames, which needs ffmpeg and ffprobe on this machine, narrated from the journal's timestamped transcript of their audio, or by the local whisper fallback where installed.
Tool filtering reduces the worker's tool list; it does not change authorization.
The list in lib/tool-roles.js covers project close/merge, routines, consent,
session control, briefings, unseen summaries, and timeline read/write.
permission_request and ordinary worker tools remain available. Roles are fixed
at spawn: configuration changes or promotion to Coordinator need a respawn
(for example, /restart) to refresh the list. Codex sessions and mobile clients
are unaffected; this adds no client polling or background work.
Sessions are lean by default so the bridge runs on small VPS boxes. Only the
bridge's own ask-user MCP loads per session; everything else is opt-in.
- Stdio MCP extras — defined under
mcpExtrasinmcp-config.json(committed; e.g.browser) ormcp-config.local.json(gitignored, per-machine; e.g.circleci). Enable per session with!start --<name>(e.g.!start --browser --circleci). Sessions run with--strict-mcp-config, so servers from your personal~/.claude.jsondo NOT leak in. - Per-machine default —
MCP_DEFAULT_EXTRAS=circleciturns an extra on for every session on this machine. Explicit--flagsstack on top (no per-session opt-out; change the env and restart to go lean). - Plugin MCP servers (context7, serena, …) — disabled by default via an
empty
CLAUDE_CODE_PLUGIN_CACHE_DIR. SetBRIDGE_PLUGIN_CACHE_DIRto~/.claude/plugins(all plugins) or a curated dir to re-enable. Your interactive~/.claude(creds, transcripts) is never modified.
/ and ! command prefixes are interchangeable; the table uses ! for brevity.
| Command | Description |
|---|---|
!start [--claude|--codex] [workdir] |
Start a session with the selected agent (optional custom workdir) |
!start now |
Start a fresh session (skip resume offer) |
!start --browser [workdir] |
Claude only: also load the chrome-devtools MCP (off by default to save ~400M/session). The flag is order-independent and also accepted by !resume, !workdir, and !restart. Chrome runs with its sandbox on, so on Linux the bridge must run as an unprivileged user (Chrome will not start sandboxed as root) with Xvfb and the Google Chrome package installed; the package's AppArmor profile lets it use the user namespaces Ubuntu otherwise restricts. |
!start --auto [workdir] |
Claude only: spawn with auto permission mode + Matron permission cards instead of the default --dangerously-skip-permissions. Also order-independent and accepted by !resume, !workdir, and !restart; pass --bypass instead to return to the default. See Permissions below. |
!stop |
Stop the current session |
!restart [--force] [--browser] [--bypass|--auto] |
Restart the session; mid-turn it waits for the turn to finish unless --force is given (--browser/--bypass/--auto are Claude-only) |
!resume [--claude|--codex] <n|id> [--browser] [--bypass|--auto] |
Resume a previous session (--browser/--bypass/--auto are Claude-only) |
!sessions [--claude|--codex] |
List past sessions for an agent |
!workdir [--claude|--codex] <path> [--browser] [--bypass|--auto] |
Start an agent session in another working directory (--browser/--bypass/--auto are Claude-only) |
!status |
Show session info (uptime, workdir, restarts) |
!agent |
Show the current/default coding agent |
!switch <claude|codex> |
Hand the current conversation to the other agent (idle sessions only) |
!working |
Toggle tool call visibility |
!mcp |
Show MCP server status |
!model [model-id|default] |
Show or change the active provider's model |
!mode [mode] |
Claude: interactive or print. Codex: plan (read-only) or default (Build) |
!login / !logout |
Log in to / out of your Anthropic account (auto-switches the session to interactive mode) |
!effort [level] |
Show or set model-supported reasoning effort; Codex also supports default |
!cost |
Show session cost |
!usage |
Show token usage stats |
!limits |
Show the active provider's subscription limits and reset times when available |
!timer <duration> <message> |
Send a message to this chat later (e.g. !timer 30m /compact); !timer lists pending timers, !timer cancel <id|all> cancels |
!context |
Claude's context report trimmed to the model and token headline; !context-full prints the untrimmed report |
!tools |
List available tools |
!help |
Show available commands |
While the agent is busy, messages queue automatically; !esc cancels the current turn without killing the session, and sending interrupt force-interrupts.
Any other message is forwarded directly to the selected agent. Claude Code slash commands (e.g. /commit, /review-pr) are passed through in Claude interactive mode; Codex programmatic sessions treat messages as normal task prompts. /model changes a model within the active provider; /switch hands the bridge conversation between Claude and Codex.
Print-mode Claude sessions run with --dangerously-skip-permissions by default. Opting a session in with --auto switches it to Claude Code's auto permission mode: routine work is auto-approved, dangerous actions are blocked, and the remaining prompts appear in Matron as Allow once / Always allow this tool (session) / Deny cards (an unanswered card denies itself after 5 minutes). --bypass returns a session to the default. Both flags persist across restarts until changed again, and MATRON_PERMISSION_MODE=auto flips the box-wide default for Claude print sessions. Interactive Claude sessions run bypassed. Codex defaults to YOLO (no sandbox or approvals); an explicit sandbox setting enables native on-request approval cards. These Claude flags do not change Codex settings. MATRON_CODEX_TRANSPORT=exec selects the legacy Codex backend, where approvals cannot be answered. See Codex setup and security. Note: Haiku-class models don't support auto mode and fall back to Claude Code's default mode, which prompts more often; the bridge warns about this at spawn time. Also note: Claude Code refuses --dangerously-skip-permissions when it runs as root (it exits with "cannot be used with root/sudo privileges"). If the bridge runs as root, every Claude session falls back to auto mode and the bridge warns at boot and at each spawn. Run the bridge as an unprivileged user; if it genuinely must run as root inside a container, set IS_SANDBOX=1 in its environment to restore bypass.
The bridge connects to a matron-journal server as an agent device — this is the bridge's sole transport. JOURNAL_WS_URL and an agent token (JOURNAL_TOKEN_FILE or JOURNAL_TOKEN) are required; the bridge exits at startup without them.
What rides the journal connection:
- Outbound mirror — session output, uploaded files/images (media mirroring), and read-marker advances are published as journal events. The media HTTP endpoint is derived from
JOURNAL_WS_URL; no extra config. - Ephemeral live UX — activity indicators (typing / "running
<command>…") and in-progress assistant-text streaming for Matron clients viewing the conversation. Best-effort: never queued or replayed, so an outage means a missed indicator, not a stale one. - Return path — user messages and prompt-button replies sent from Matron clients are routed into the owning coding-agent session. The inbound cursor persists to
JOURNAL_CURSOR_FILEso a restart resumes where it left off. - Control convo — one stable conversation (
JOURNAL_CONTROL_CONVO_ID, defaultbridge-<hostname>) accepts session-management commands from Matron clients:/start [--claude|--codex] [dir](aliasnew),/sessions [--claude|--codex](aliaslist),/resume,/workdir,/help. Session-scoped commands (/status,/stop, …) don't apply there — they belong to each session's own conversation.
| Variable | Description | Default |
|---|---|---|
JOURNAL_WS_URL |
Journal server WebSocket URL (required) | — |
JOURNAL_TOKEN_FILE |
Path to a file containing the agent token (takes precedence over JOURNAL_TOKEN) |
— |
JOURNAL_TOKEN |
Raw agent token | — |
JOURNAL_CURSOR_FILE |
Where the inbound cursor is persisted | journal-cursor.json in the repo root |
JOURNAL_CONTROL_CONVO_ID |
Stable convo id for session-management commands | bridge-<hostname> |
JOURNAL_STREAM_INTERVAL_MS |
Streaming-overlay coalescing floor (at most one in-progress frame per conversation+message per window) | 200 |
Get the agent token by pairing with the Matron app (npm run pair, or the first option in npm run setup), or provision it on the journal server with matron-admin agent add <user> <device-name>.
The journal also exposes an HTTP search API (GET /search?q= on the https base derived from JOURNAL_WS_URL, authenticated with the same agent token) that full-text searches every one of the user's conversations across all their devices, plus an around_seq context mode on GET /convo/:id/messages for reading prose around a hit. Bridge sessions are told how to use it in BRIDGE_CLAUDE.md / BRIDGE_CODEX.md; the full spec lives in matron-journal's docs/protocol.md ("Journal search").
The item_* MCP tools and the /items HTTP routes they wrap (the task & decision tracker, described for sessions in BRIDGE_CLAUDE.md / BRIDGE_CODEX.md) need a matron-journal deployment with the items routes (journal PR #73). Deploy that journal update before this bridge in production — against an older journal, the tools answer journal unreachable or the routes answer HTTP 404.
Bridge sessions on the same journal server can chat with each other. An agent room is an ordinary journal conversation plus an invite lifecycle: the session that starts a room owns it, invited sessions join as guests, and pending invites expire after 30 minutes. Room state survives bridge restarts.
Every session gets these MCP tools via ask-user.js:
agent_roster— list the user's other agent sessions (titles, states, rolling summaries)agent_chat_start— pick a target from the roster and invite its agent to a room (a second call at the same target returns the room the pair already has)agent_chat_accept/agent_chat_refuse— answer an inbound request that still needs one (a same-bridge invite, or a join request into a room you own); an approved invite from another box has already joined the agent, so accept is a no-op there and refuse mutes the roomagent_chat_join— ask to join an existing room by idagent_chat_send/agent_chat_read— post to a room, or catch up on its recent messagesagent_chat_mute/agent_chat_unmute— stop and resume delivery of a room's messages to youagent_chat_leave— step out of a room of three or more that someone else started, once your part is done (a room of two cannot be left)
The user's consent card is the gate: once an invite is approved, the target bridge joins its agent to the room on delivery and tells it so (with the room's opening message), rather than asking it to accept — a target mid-turn for the whole invite window could never accept in time, and pairs ended up with several rooms. Inbound requests are also posted into the invited session's conversation, so the user sees who asked and why. Invites never block: the inviting agent keeps working, and answers and room replies arrive as later turns.
A room between two sessions stays open for as long as both live — agents have no way to close one. When a room goes wrong (a peer looping, spamming, or malfunctioning) the agent mutes it with a reason: the room and both members' chats say so out loud, and the user gets a 🔊 Unmute card in that agent's conversation to overrule it with one tap.
The journal lets a user enrol approval keys: key pairs held in a phone's or a Mac's secure hardware, which sign a short statement naming exactly what is being approved (see "Approval keys (step-up)" in the journal's protocol doc). The bridge takes part in three ways. Every one of them is off unless you turn it on, and a box with none of the settings below behaves exactly as before.
item_create and item_comment take step_up: true and an optional subject. A step-up question's buttons can only be tapped with Face ID or Touch ID; a typed reply never satisfies it. subject is a flat object of short strings (at most 12 keys) that the user is shown verbatim and signs, for example {"op":"drop_table","table":"orders"}. When the user's account has no approval key the journal files an ordinary question and the tool result says that step_up was ignored.
MATRON_STEP_UP_ALL=1 marks every question this box files, and every comment with buttons, as step-up.
A tap the journal accepted with a signature reaches the agent as tapped "Approve" (signed with the user's device key 7734f909), and item_get shows the same on that comment.
Set MATRON_APPROVAL_KEYS_FILE to a file of pinned keys and the bridge stops taking the journal's word for the user's approval. It then refuses, with approval_required:
- a session start, whether an approved spawn from another agent or a client's own New Chat, unless it carries a statement signed by a pinned key over this box's name, the working directory and the hash of the task;
- an inbound chat invite or join request, unless it carries one over the room, the device and the hash of the justification;
- a file transfer, unless it carries one over the transfer's id, size and SHA-256 (the downloaded bytes must then match that hash before the file is released);
- another person's talk request, always;
- Coordinator session control that starts or redirects work (
carry_on,set_model, a routine's fire).compactand an alert still go through, except on a locked box with no unlock window open; - the typed
/startand/workdircommands: a session is started from New Chat in the app, which signs the start. Messages to a conversation that already exists, and so resuming it, are ordinary chat and are not gated (locked mode covers those).
The bridge builds what the statement must say from what it is about to do, not from what the request claims, checks the signature against the pinned keys, requires the statement to be recent (five minutes for a tap or an unlock, ten for a direct start, thirty for a spawn, a day for an invite or a transfer; MATRON_APPROVAL_MAX_AGE_MS lowers all of them) and refuses a statement it has seen before. Sub-sessions, restarts and resumes of sessions already on the box need no new approval. On such a box a signed tap is also checked locally, against the item, the button and the question the agent is shown, and reads …, verified on this box or signature NOT verified on this box: <reason>.
The keys file:
{ "keys": [ { "key_id": "<64 hex characters>", "public_key": "<base64url>", "name": "phone" } ] }- It must be owned by root (or any user other than the one the bridge runs as), must not be writable by its group or by everyone, and must not be a symlink. Agents run as the bridge's user, and a file that user could write would let an agent pin a key of its own. The bridge refuses to start when the rule is broken, and it re-checks the rule and re-reads the file on every approval, so a change takes effect without a restart and a loosened file is refused at once.
- To produce it: on a signed-in machine,
GET /approval-keyson the journal lists the account's keys with theirkey_idandpublic_key, and itsjournalfield is the host a statement names. Copy the entries for the devices you trust, and compare eachkey_idwith the fingerprint that device itself shows (compare the whole id where the device shows it; the short fingerprint is its first 8 characters). An entry whosepublic_keydoes not hash to itskey_idis ignored. Never let a box fetch its keys from the journal by itself. - Install it with your configuration management, for example
install -o root -g root -m 0644 approval-keys.json /etc/matron/approval-keys.json.
A statement names the journal by its public host. When the bridge dials the journal by another address, set MATRON_APPROVAL_JOURNAL_HOST to that public host.
Limits worth knowing: spent statements, an open unlock window and a lockdown are remembered in files in the bridge user's home, so an agent on the box could delete or edit them; a deleted ledger lets a statement be replayed after the next bridge restart, within its freshness window. Keep the keys file's parent directories unwritable by the bridge's user too. An approval is spent when it is checked, so a start that then fails needs a new one. If the box is renamed in the journal while connected, approvals are refused as mismatch until the bridge reconnects.
MATRON_LOCKED=1 (which needs the keys file) makes the box take instructions from outside only during an unlock window:
- The user unlocks the box from an app with a statement over
{op:'unlock_box', box:<this box's name>, minutes:'<n>'}, sent as theunlockrequest.MATRON_UNLOCK_MAX_MINUTEScapsn(default 120).lockends the window early and needs no signature. A bridge restart neither reopens nor extends a window. - During a window the bridge accepts the user's messages, files, voice notes, replies and commands only from the device that signed the unlock (the journal stamps each user event with the sending device's key), and room messages from other sessions.
- Outside a window those are dropped, not queued, and wake no session: the user gets
This box is locked. Unlock it to send instructions.in the conversation, and another session is told in the room that the box is locked. A signed tap on a step-up question that verifies against the pinned keys is delivered either way, once: the statement is spent, and must be under thirty minutes old. - Turns already running continue, and a session's own reminders still fire. Locked mode does not defend against an agent already on the box.
A genuine app never sends a bad signature. Three approvals within ten minutes that are forged or malformed (a bad signature or a malformed statement) put a box with a keys file into lockdown: it refuses every request that needs an approval, valid or not, for thirty minutes, then an hour, doubling up to a day, closes any unlock window, and files a notice (label box-lockdown). The only early way out is a statement over {op:'clear_lockdown', box:<this box's name>}, sent as the clear_lockdown request. Refusals an honest app can cause (a stale statement, a mismatch, a missing approval, a key the user enrolled but this box does not pin) never count. Anyone who can send requests to the box can trip a lockdown again after it is cleared; it fails towards refusing work, never towards allowing it.
- The box's state. A box with a keys file reports
locked: {on, until, lockdown_until}onhelloandbox_status, and again whenever it changes or a window or lockdown runs out.on: the box is in locked mode with no window open;until: the end of the open unlock window (ms), elsenull;lockdown_until: the end of a lockdown (ms), elsenull. Locked:{on:true, until:null, lockdown_until:null}. Unlocked for a while:{on:false, until:<end>, lockdown_until:null}. Locked box in lockdown:{on:true, until:null, lockdown_until:<end>}; a box that is not in locked mode, in lockdown:{on:false, until:null, lockdown_until:<end>}. A box without a keys file sends nolockedkey. unlock,lock,clear_lockdown(agent requests;unlockandclear_lockdowntakeparams: {approval}). Success:result: {locked: {on, until, lockdown_until}}, the state as it now is. Failure:error: {code, detail?}withcodeone ofapproval_required(no approval sent),approval_invalid(detailis the reason:bad_statement,unknown_key,signature,mismatch,stale,nonce_reused,keys_file,identity_unknown,nonce_ledger,check_failed),bad_minutes(not a whole number from 1 to the cap),lockdown(an unlock during a lockdown),not_locked(an unlock on a box that is not in locked mode). A box with no keys file does not have these requests (unknown_method).- A refused start.
error: {code:'approval_required'}when the request carried no approval,{code:'approval_invalid', detail:<reason>}otherwise (the reasons above, andtalk_request),{code:'lockdown'}during a lockdown, and{code:'account_locked'}under the account lock. A refused invite is answeredaccept: falsewith the reasonapproval_required: <reason>(oraccount_locked), and a refused transfer is reported failed with the same text. - A dropped message. The conversation gets
🔒 This box is locked. Unlock it to send instructions.; a peer's room gets<box> is locked; ask the user to unlock it(at most once in ten minutes per room). - The lockdown notice. A notice item titled
<box> locked down: invalid approval signatures, with the labelbox-lockdownandBox: <box>as the first line of its body, filed in the box's own bridge conversation.
The journal can lock a whole account (its panic broadcast, sent to every box of the account and repeated in the answer to each connect while it lasts). A locked bridge interrupts its running turns and starts no new one from any source: the user's messages, other sessions' room messages, reminders, routines and session control are all refused, as are session starts, chat invites and file transfers. Each affected conversation gets one line: This account is locked. Sessions are stopped until it is cleared. Reminders that fall due meanwhile are kept and delivered after the clear.
Only the journal clears the lock; nothing a client, an agent or a signed approval sends to the bridge does. The state is kept on disk, so a bridge that restarts while it cannot reach the journal stays locked until its next connect says otherwise. This needs no setting and no keys file.
CLAUDE.md and AGENTS.md are read by every Claude Code or Codex run in a repository, so instructions there about the tracker, rooms or missions confuse a run outside Matron, which has none of those tools. A repository puts such instructions in a MATRON.md instead, and the bridge adds it to the instructions of Matron sessions only.
- Which files. Every
MATRON.mdfrom the repository root (the nearest ancestor of the workdir holding.git, so a worktree counts) down to the session's workdir, root first. Outside a repository, only the workdir's ownMATRON.md. AMATRON.mdin a subdirectory applies only to sessions whose workdir is inside it; nested files are not loaded on first touch the way Claude Code loads a nested CLAUDE.md. - Where it goes. In the system prompt the bridge already builds (
--append-system-promptfor Claude, developer instructions for Codex), after the bridge's own instructions, the Coordinator brief and the notices block, and before the user's memories. Its heading tells the agent that it adds to CLAUDE.md and AGENTS.md and wins over them in a Matron session, and that the user's memories and the bridge's rules on sensitive data and on other people's turns win over it. It is trusted like CLAUDE.md: whatever branch is checked out supplies it. - When. Read afresh at every spawn: start, resume,
/workdir,/restart,restart_session, a crash respawn and a bridge restart. An edit reaches a running session at its next restart; acdinside a session changes nothing. - Size. At most 24 KB in total. Anything past that is cut off, with a line in the prompt saying so and a warning in the bridge log.
- Seeing it. The start, resume, workdir and restart messages and
/statuslist the files a session loaded, e.g.MATRON.md: MATRON.md (3.1 KB).
MATRON.md or a memory? MATRON.md is part of the code: reviewed in pull requests, it follows the branch and applies to everyone's Matron sessions in the repository. Use it for how to work on that repository through Matron. A repo:<name> memory is one user's own standing rule, changed at once and private to them. Use it for personal preferences and anything about particular machines.
- User messages arrive via the matron-journal WebSocket connection
- Claude Code is spawned with
--print --input-format stream-json --output-format stream-json, or Codex uses a persistent stdio app-server - User messages are sent as Claude stream JSON or Codex native turn/steer requests
- Structured JSON events are parsed from stdout and normalized into the shared bridge session lifecycle
- The complete response is published to the journal when the provider reports that the turn is complete
- Long responses are split at 32K-char boundaries
- Sessions persist across restarts via Claude
--resume <session-id>or Codexthread/resume - Agent handoffs persist one native session ID per provider plus a shared transcript cursor; the next prompt carries a bounded unseen transcript delta
- Crashed sessions auto-restart up to 3 times
- Messages sent while an agent is busy are queued and sent when the turn completes
Off by default: messages queue in Matron. With MATRON_CLAUDE_QUEUE_IN_MATRON=0, a plain message typed while a print-mode Claude turn is running goes straight into the running claude process instead of the busy queue, through the same flushQueue seam that Codex steering uses. The chat shows ↪ Sent into the running turn in place of the queued card. Steering is not an interrupt: no tool call is cut off. Claude sees the message once the current tool call finishes, in the same turn, or, if the turn ends first, as its own turn right after it; the session stays busy through that follow-up turn so queued work does not jump ahead of it. The message's cause (the user's message, or the item reply) is merged into the running turn, as for a Codex steer. A steered message Claude never ran goes back on the queue when the session carries on (a failed write, a cancellation by an interrupt, or a crash that respawns the session).
Anything that cannot steer is queued exactly as before: slash and ! commands, reminders, timers and carry-on taps, media, room messages, a turn another person started, a session with an open question or plan, a pending interrupt, a parked restart or command, a turn the CLI has not opened yet, and any message arriving while something is already queued (order is preserved). send and cancel still apply to whatever is queued.
npm test # vitest suite
npm run lint # eslint, zero warnings allowed
npm run check # node --check on every .js/.mjs file
npm run ci # lint + check + test + npm audit (high)CI runs the same gates on Node 22, on Linux and on Windows.
Two operator checks are kept out of npm test, because each needs a local install or an API key:
node scripts/check-codex-app-server.mjs # Codex app-server contract check: ephemeral threads, no model turns, no tool runs
node scripts/spoken-sample.mjs [conversation] # print the spoken summary lines for a saved conversation (one real model call)check-codex-app-server.mjs needs the Codex CLI installed and logged in; run it after a Codex upgrade. spoken-sample.mjs needs OPENAI_API_KEY or GEMINI_API_KEY (SUMMARY_MODEL overrides the model) and reads a JSON array of {role, text} messages, by default test/fixtures/spoken-sample.json. Neither prints keys or config values.
matron-bridge/
├── index.js # Main bridge (journal wiring, session lifecycle)
├── lib/ # Bridge modules: journal-* (Matron transport), command
│ # dispatch, prompt detection/buttons, PTY interactive mode,
│ # media mirroring, transcription, session summaries, …
├── ask-user.js # MCP server: secret requests, sensitive-data links, attachments, agent-chat room tools
├── BRIDGE_CLAUDE.md # Extra instructions for bridge-spawned Claude sessions
├── BRIDGE_CODEX.md # Extra instructions for bridge-spawned Codex turns
├── BRIDGE_NOTICES.md # Notices block, appended while the user's notices setting is on
├── docs/codex.md # Codex setup, switching, security, and troubleshooting
├── mcp-config.json # MCP server config for Claude Code
├── viewer/ # HMAC-signed file viewer
├── setup/ # OS-dispatching installer, service, optional local whisper
├── hooks/ # Claude Code hooks used by bridge sessions
├── bin/run-named-node # macOS: runs node under a Matron name (firewall-friendly)
├── test/ # Vitest suite
├── SECURITY.md
├── package.json
└── .env.example
Summaries come from the journal by default: the bridge posts the prompt to
the journal's POST /summarise under its own journal token, and the journal
calls the model with a key it holds server-side, so no provider key needs to
sit on the box. Local keys are fallbacks only, used when the journal has no
key, is rate limited, fails upstream, or predates the route. To keep a box on
local keys only, set SUMMARY_PROVIDERS without journal (for example
anthropic,openai,gemini).
Local Claude fallback: supplying SUMMARY_ANTHROPIC_API_KEY is what turns it
on, so provision it through the secure credential store only once its
Console billing organisation is confirmed, and start with one bridge. Check
titles, roster and spoken summaries, and inspect debug summary usage counts
for cache reads after repeated turns. The fixed prompt prefix has a 5-minute
cache breakpoint; transcript and prior roster text are never in it. Cache
hits depend on the model's minimum prompt length. Provider failures log a
fallback event without prompt or response content. Roll back by removing the
key, or by setting SUMMARY_PROVIDERS to an order without anthropic, and
restarting the bridge.