claude-bridge
is a bridge-owned replacement for common claude -p
automation.
Instead of delegating to raw claude -p
, it starts normal interactive Claude Code inside a detached tmux pane, sends your prompt through tmux, tails Claude's own on-disk transcript, formats the reply, and exits at turn end.
That keeps prompt dispatch, transcript capture, --output-format
, JSON schema validation, and process exit behavior inside the bridge.
claude -p "say hi" --output-format json
bunx @desplega.ai/claude-bridge -p "say hi" --output-format json
The transcript source is the same JSONL file Claude writes under
~/.claude/projects/<slug>/<session-uuid>.jsonl
. Outside print mode, piped
consumers get bridge envelopes and TTY users get a compact readable view. The
transcript tailing follows the
Shannon technique: snapshot the
pre-existing *.jsonl
set before launch, poll for a fresh file, and poll-and-reparse it every 100 ms.
The orchestrator also pre-clears the prompts that would otherwise block Claude's UI:
- Claude's global config is edited so
projects[<workdir>].hasTrustDialogAccepted
andhasCompletedProjectOnboarding
are set. This is~/.claude.json
by default, or$CLAUDE_CONFIG_DIR/.claude.json
whenCLAUDE_CONFIG_DIR
is set. The previous file is backed up alongside it as.claude.json.claude-bridge-backup
. - A per-workdir
.claude/settings.local.json
setsdefaultMode: "bypassPermissions"
andskipDangerousModePermissionPrompt: true
. claude
is launched with--dangerously-skip-permissions
.- Theme/security startup prompts are auto-accepted by watching
tmux capture-pane
for marker text and sendingEnter
. With--desplega-local-auth
, the custom API key confirmation prompt is also auto-accepted. Login-method selection is deliberately not auto-accepted.
+--------------------+
| claude-bridge |
| - tmux paste |
| - transcript tail |
+----------+---------+
|
| tmux paste-buffer + Enter
v
+------+-----------------------------+
| tmux session claude-bridge-<id> |
| pane 0: claude --dangerously-... |
+------------------------------------+
Bun(>= 1.1
)claude
CLI on PATH, version>= 2.1.80
tmux
on PATH.- Claude Code authenticated for the spawned
claude
process.
Run without installing:
bunx @desplega.ai/claude-bridge -p "say hi"
bunx @desplega.ai/claude-bridge -p "say hi" --output-format json
bunx @desplega.ai/claude-bridge -p "say hi" --output-format stream-json
bunx @desplega.ai/claude-bridge -p "say hi" --output-format stream-json --desplega-format
Install globally with Bun:
bun install -g @desplega.ai/claude-bridge
claude-bridge -p "say hi"
claude-bridge --help
Install globally with npm:
npm install -g @desplega.ai/claude-bridge
claude-bridge -p "say hi"
The installed command is claude-bridge
. Bun is still required at runtime
because the published bin uses #!/usr/bin/env bun
.
claude-bridge -p "say hi"
claude-bridge -p "say hi" --model sonnet
claude-bridge -p "say hi" --output-format json
claude-bridge -p "say hi" --output-format stream-json
printf 'say hi\n' | claude-bridge --print
Print mode is intended for shell automation that would otherwise call
claude -p
:
claude -p "say hi" --output-format json
claude-bridge -p "say hi" --output-format json
claude -p "say hi" --output-format stream-json
claude-bridge -p "say hi" --output-format stream-json
This is intended as a drop-in replacement for common claude -p
automation. In print mode the wrapper starts an interactive Claude session in tmux, waits for the pane to become ready, sends the prompt through tmux, prints the requested format, then kills the tmux session.
By default, print-mode stdout is reserved for the requested Claude-compatible
output. Bridge envelopes and bridge debug events are not written to stdout in
json
or stream-json
mode unless you explicitly pass --desplega-format
.
claude-bridge
does not call the Anthropic API itself. It launches the local
claude
CLI and relies on whatever authentication that claude
process can use.
For local interactive machines, first make sure claude
works:
claude auth status
claude -p "say hi"
Then run the bridge:
claude-bridge -p "say hi"
For headless CI, use the long-lived Claude Code OAuth token from:
claude setup-token
Set it exactly as printed:
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
claude-bridge -p "say hi" --output-format json
By default, the spawned Claude process receives HOME
, CLAUDE_CONFIG_DIR
,
and CLAUDE_CODE_OAUTH_TOKEN
; Anthropic provider env vars such as
ANTHROPIC_API_KEY
and ANTHROPIC_AUTH_TOKEN
are cleared so the bridge does not accidentally test a different auth path.
Use --desplega-local-auth
when you intentionally want the spawned Claude process to receive local auth-related env vars. If Claude shows the custom API key confirmation prompt, this mode selects the API-key path:
ANTHROPIC_API_KEY=... claude-bridge --desplega-local-auth -p "say hi"
If Claude shows a browser login or login-method selector, the bridge will not
auto-select it. Run claude auth status
, run claude setup-token
, or attach to the tmux pane shown in the banner and complete the prompt manually.
-p
/--print
requires a prompt argument or piped stdin. --output-format
requires print mode and accepts text
, json
, or stream-json
; the default is
text
. --json-schema
is also print-only.
Compatibility mode is the default. If you are replacing claude -p
in scripts,
do not pass --desplega-format
.
The final result comes from the transcript. When Claude writes a system
turn_duration
row, the wrapper uses the latest assistant text it saw in that turn.
text
: prints only the final answer text plus a trailing newline. Wrapper errors go to stderr and exit non-zero.json
: prints one final Claude-compatible JSON result object with the answer inresult
, plus available transcript metadata such assession_id
,duration_ms
, andusage
.stream-json
: streams raw Claude transcript JSONL rows as they are written. The bridge does not wrap them in custom envelopes.
Use --desplega-format
when you want the older bridge-owned JSON envelopes in
json
or stream-json
modes. This flag is for bridge-specific consumers, not
drop-in claude -p
replacement scripts:
claude-bridge -p "say hi" --output-format stream-json --desplega-format
With --desplega-format
, json
includes bridge debug metadata when
--desplega-verbose
is set, and stream-json
prints newline-delimited bridge
events as the run progresses, then a final result
event. This is a custom
claude-bridge
event stream, not Claude's native stream-json
schema.
Typical --desplega-format --output-format stream-json
event types are:
{"type":"push","id":"ab12cd34","content":"say hi"}
{"type":"transcript_folder","path":"/Users/.../.claude/projects/..."}
{"type":"transcript_open","path":"/Users/.../<uuid>.jsonl","session_id":"..."}
{"type":"transcript","row":{"type":"assistant","message":{...}}}
{"type":"result","subtype":"success","is_error":false,"result":"Hi.","session_id":"..."}
These custom transcript
events only exist with --desplega-format
. In the default compatibility mode, the same Claude data is written as the top-level JSONL row.
--json-schema <schema|file>
is bridge-owned. It is not forwarded to raw
claude -p
; the wrapper keeps the normal tmux/transcript path, injects schema
guidance with --append-system-prompt
, extracts the last JSON value from the final assistant text, and validates it locally with Zod.
Existing user-provided --append-system-prompt
values are preserved. When a schema is present, the wrapper merges those prompts with its schema instruction instead of replacing them.
Schema print mode also installs a global Claude Code Stop
hook in
~/.claude/settings.json
. The hook is inert outside claude-bridge
schema runs; during a schema run it checks the final assistant text before Claude stops and blocks the stop if it does not validate. That gives Claude a bounded number of extra turns to answer with valid JSON before the wrapper exits.
Control that hook explicitly with:
claude-bridge --desplega-install
claude-bridge --desplega-uninstall
Install is append-only and idempotent: unrelated hooks are preserved, and stale
old claude-bridge
hook commands are replaced with the current command.
The schema argument may be inline JSON or a path to a JSON file:
claude-bridge -p "Return the repo name" \
--json-schema '{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}' \
--output-format json
claude-bridge -p "Return the repo name" \
--json-schema ./schema.json \
--output-format text
Extraction is intentionally simple and deterministic:
- Try the whole reply as JSON.
- Otherwise use the last fenced
json
block. - Otherwise use the final balanced JSON object or array in the reply.
Validation uses Zod's z.fromJSONSchema()
converter. That API is still marked experimental by Zod, but it keeps the bridge aligned with Zod's JSON Schema support instead of maintaining a handwritten validator here. If Zod cannot convert the schema, the wrapper treats that as a print-mode error.
With --output-format text
, successful schema mode prints the extracted JSON
value as compact JSON. With --output-format json
, the final result includes
structured_output
alongside the original reply text in result
. With
--desplega-format
, bridge JSON results also include
structured_output_source
.
If schema extraction or validation fails after Claude replies, json
and
stream-json
error results include raw_response
with the unmodified Claude
reply. In text
mode the same raw reply is printed to stderr under
Raw Claude reply:
.
The compact stringified schema is capped before Claude starts. The default cap
is roughly 15000
tokens, estimated as ceil(chars / 4)
. Configure it with:
CLAUDE_BRIDGE_JSON_SCHEMA_MAX_TOKENS=30000 claude-bridge -p "..." --json-schema schema.json
claude-bridge -p "..." --json-schema schema.json --desplega-json-schema-max-tokens=30000
The wrapper owns these options and does not forward them to Claude:
-p
/--print
,--output-format
, and--json-schema
--desplega-verbose
,--desplega-local-auth
, and other--desplega-<name>[=<value>]
flags--claude-help
-h
/--help
-v
/--version
Most interactive claude -h
options pass through to the spawned Claude session,
for example --model sonnet
, --permission-mode acceptEdits
, --append-system-prompt
,
or --allowed-tools
. The wrapper always prepends its own launch flags:
--dangerously-skip-permissions
.
The initial prompt is wrapper-owned too. It is not passed to Claude as a CLI argument; once the pane is ready, the wrapper sends it through tmux. In non-print mode, stdin remains a small REPL that sends each entered line through the same tmux/transcript bridge.
Claude subcommands are intentionally blocked; run claude <cmd>
directly for
commands such as doctor
, mcp
, plugin
, update
, agents
, or auth
.
Claude modes that conflict with the bridge are also blocked: --tmux
,
--replay-user-messages
/--replay*
, and -w
/--worktree
.
Use --claude-help
to see raw Claude help, with the caveat that wrapper-owned
modes behave as described here. Use -v
/--version
to print the wrapper
package version, the full claude
path from which claude
, and the
claude -v
output.
Use --desplega-verbose
for extra wrapper debug output and raw transcript
rows. Other --desplega-<name>[=<value>]
flags are reserved for future wrapper features and are not forwarded to Claude.
The CLI prints a banner with the tmux session name and run state path:
tmux session : claude-bridge-2026abcd
cwd : /path/to/current/project
run state : /path/to/current/project/.claude-bridge/runs/2026-05-15T.../
attach to the Claude UI in another terminal:
tmux attach -t claude-bridge-2026abcd
Type a message + Enter on stdin to send it to Claude.
Assistant and useful transcript rows print below.
Use --desplega-verbose for raw transcript rows and wrapper debug.
Ctrl-D to quit (kills the tmux session).
>
When stdout is a TTY, the orchestrator pretty-prints a human-friendly feed:
14:02:17 transcript /Users/taras/.claude/projects/.../<uuid>.jsonl
14:02:21 → push id=a1b2c3 what files exist?
14:02:22 user what files exist?
14:02:22 assistant Let me check.
[tool_use Bash {"command":"ls"}]
14:02:23 user [tool_result] file.txt\n.gitignore
14:02:23 assistant I found two files: file.txt and .gitignore.
14:02:23 system turn_duration=2345ms
By default, TTY output hides raw transcript metadata and only shows useful
human-friendly rows. --desplega-verbose
adds wrapper debug output and the verbatim JSONL row dimmed below each friendly transcript summary.
claude-bridge --desplega-verbose # friendly rows plus raw rows
The orchestrator shows a >
prompt for stdin and redraws it after every output line, so you always know where you can type.
Attach the live Claude UI in another terminal if you want to see what Claude is doing:
tmux attach -t claude-bridge-2026abcd
The orchestrator pre-accepts trust and dangerous-mode prompts, and watches for theme/security prompts. You shouldn't need to touch the pane unless Claude asks for login selection or authentication.
Now type in the orchestrator window:
what's in the current directory?
Stdout will show, in order: the push
envelope, a stream of transcript
envelopes as Claude works (each row is whatever Claude wrote to the JSONL — user, assistant, tool_use, tool_result, system, etc.):
{"type":"push","id":"ab12cd34","content":"what's in the current directory?"}
{"type":"transcript_open","path":"/Users/.../<uuid>.jsonl","session_id":"..."}
{"type":"transcript","row":{"type":"user","message":{...}}}
{"type":"transcript","row":{"type":"assistant","message":{...}}}
{"type":"transcript","row":{"type":"tool_use","name":"Bash","input":{...}}}
{"type":"transcript","row":{"type":"tool_result","output":"..."}}
Ctrl-D on the orchestrator kills the tmux session and exits.
Install local dependencies:
bun install
Run the CLI from the repo:
bun ./src/cli.ts -p "say hi"
bun ./src/cli.ts -p "say hi" --output-format json
bun ./src/cli.ts --help
Run deterministic tests:
bun run test
bun run typecheck
The smallest hermetic smoke test does not require tmux or Claude:
A hermetic test stands up the Unix socket, spawns mcp-channel.ts
as a stdio
MCP subprocess, drives it through initialize
/ tools/list
/ tools/call
, and asserts that push envelopes become channel notifications and that reply tool calls produce reply envelopes back on the socket:
bun run test:smoke
Expected: 13 PASS lines and result: PASS
.
.github/workflows/ci.yml
runs deterministic tests and typechecking on pushes and pull requests.
The workflow also has a gated live smoke job. If the GitHub Actions environment
has CLAUDE_CODE_OAUTH_TOKEN
available, it installs tmux
and Claude Code, normalizes that token into the job environment, and then runs a matrix across:
--output-format text
--output-format json
--output-format stream-json
- schema mode enabled and disabled
If the secret is not available, the live smoke is skipped while the
deterministic job still runs. Use the CLAUDE_CODE_OAUTH_TOKEN
path exactly as
claude setup-token
prints it; do not remap it to ANTHROPIC_AUTH_TOKEN
.
The smoke command clears inherited ANTHROPIC_*
variables so unrelated provider headers or API-key configuration cannot change the auth path under test.
The workflow uses a reusable script that can be run locally:
CLAUDE_BRIDGE_SMOKE_OUTPUT_FORMAT=json \
CLAUDE_BRIDGE_SMOKE_SCHEMA=true \
bun run ci:live-smoke
To run that script with local auth env vars instead of the CI OAuth-token path:
CLAUDE_BRIDGE_SMOKE_LOCAL_AUTH=true \
CLAUDE_BRIDGE_SMOKE_OUTPUT_FORMAT=json \
bun run ci:live-smoke
The npm package is @desplega.ai/claude-bridge
.
See docs/releasing.md for the full release runbook.
Releases are automated from master
: when package.json
's version
changes,
.github/workflows/release.yml
validates the package, publishes the public npm
package with NPM_TOKEN
, creates the vX.Y.Z
git tag, and creates a GitHub Release.
Prepare a release on a branch:
npm version --no-git-tag-version patch
bun install
git add package.json bun.lock
The package tarball is intentionally allowlisted in package.json
. Keep tests,
CI scripts, .github
, AGENTS.md
, and CLAUDE.md
out of the public npm package.
Structured output should stay bridge-native. Future AI SDK integration can be a
repair or fallback layer after the transcript result, not a replacement for the
bridge-owned turn. Plausible provider knobs are
--desplega-structured-provider=anthropic|openai|google|openrouter
with the
usual ANTHROPIC_API_KEY
, OPENAI_API_KEY
, GOOGLE_GENERATIVE_AI_API_KEY
/
GEMINI_API_KEY
, or OPENROUTER_API_KEY
env vars. That mode would validate the transcript result first, then optionally ask a provider to repair invalid JSON into the schema.
Remote/SSH support should also keep the bridge boundary. The likely shape is a
transport abstraction (tmux
today, HTTP MCP later) plus a tunnel abstraction
(none
, Tailscale Serve/Funnel, SSH reverse tunnel, cloudflared, ngrok). For a
remote Claude session, the remote host still needs claude
, tmux
, Bun, and the bridge entrypoint. Tunnels only expose/connect the transport; they do not remove the need for a Claude Code process on the remote host. Public tunnels such as Tailscale Funnel must require a per-run bearer token and should default to localhost binding unless explicitly exposed.
src/cli.ts
— orchestrator (tmux launcher + stdin REPL + transcript tail).src/auth-env.ts
— auth environment forwarding and local-auth handling.src/mcp-channel.ts
— optional channel MCP kept for hermetic protocol tests and future transport experiments.src/bridge.ts
— newline-delimited JSON framing for the optional channel MCP.src/transcript.ts
— Shannon-style transcript discovery + poll-and-tail.src/preaccept.ts
— pre-writes Claude's global trust entry +.claude/settings.local.json
to suppress trust and permission prompts.src/hook-install.ts
andsrc/stop-hook.ts
— install and execute the schema-only global Stop hook.- Each run writes its run state and schema copy under
.claude-bridge/runs/<id>/
in the target cwd.
The default CLI path does not depend on Claude Code Channels. The channel MCP is still present as an optional experimental transport. Its envelopes are JSON, newline-delimited:
type Envelope =
| { kind: "hello"; pid: number; channel: string } // mcp -> orchestrator on connect
| { kind: "push"; id: string; content: string; meta?: Record<string,string> } // orchestrator -> mcp
| { kind: "reply"; chat_id: string; text: string }; // mcp -> orchestrator
push
becomes a notifications/claude/channel
event for Claude; the id
travels in meta.id
, so Claude sees:
<channel source="bridge" id="ab12cd34">what's in the current directory?</channel>
The channel's instructions
tell Claude to call reply
with chat_id
set to that same id so the orchestrator can correlate replies.
- This is a single-pane POC. A real version would multiplex multiple sessions per orchestrator and persist transcripts.
- This wrapper deliberately blocks Claude subcommands and bridge-conflicting
modes:
--tmux
,-w
/--worktree
, and--replay-user-messages
/--replay*
. Runclaude <cmd>
or rawclaude
directly for those modes. - The auto-acceptor for startup prompts is a regex over
tmux capture-pane
. If Claude's prompt copy changes the heuristic may miss it; you can still attach to the pane and pressEnter
yourself. - Permission prompts and tool approvals are pre-bypassed via
--dangerously-skip-permissions
.This effectively runs Claude in auto-execute mode against the target cwd. By default that is the current directory; use--desplega-cwd <path>
when you need to point the run somewhere else, and do not point this at sensitive paths. - To relay permission prompts off the pane instead of bypassing them, a future
transport can either parse the transcript/pane or revive the optional channel
path with
experimental['claude/channel/permission']
.