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, waits for Claude Code's Stop hook, reads the hook's
last_assistant_message and transcript_path, 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 without ever invoking
Claude Code's non-interactive runtime.
# Raw Claude Code print mode claude -p "say hi" --output-format json # Bridge-owned replacement bunx @desplega.ai/claude-bridge -p "say hi" --output-format json
The rich transcript source is the same JSONL file Claude writes under
~/.claude/projects/<slug>/<session-uuid>.jsonl. In print mode the Stop hook
hands the bridge that path together with the final assistant message. Outside
print mode, piped consumers get bridge envelopes and TTY users get a compact
readable view while the bridge tails the transcript for interactive display.
The orchestrator also pre-clears the prompts that would otherwise block Claude's UI:
- Claude's global config is edited so
projects[<workdir>].hasTrustDialogAcceptedandhasCompletedProjectOnboardingare set. This is~/.claude.jsonby default, or$CLAUDE_CONFIG_DIR/.claude.jsonwhenCLAUDE_CONFIG_DIRis set. The previous file is backed up alongside it as.claude.json.claude-bridge-backup. - A per-workdir
.claude/settings.local.jsonsetsdefaultMode: "bypassPermissions"andskipDangerousModePermissionPrompt: true. claudeis launched with--dangerously-skip-permissions.- Theme/security startup prompts are auto-accepted by watching
tmux capture-panefor 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 |
| - Stop hook wait |
+----------+---------+
|
| tmux paste-buffer + Enter
v
+------+-----------------------------+
| tmux session claude-bridge-<id> |
| pane 0: claude --dangerously-... |
+------------------------------------+
Billing Invariant
claude -p, Agent SDK, and headless --output-format stream-json MUST NEVER
be used in the bridge. This is a hard invariant, not a guideline.
Claude Code distinguishes interactive subscription-billed usage from
non-interactive programmatic-credit-billed usage via an isInteractive flag
computed as:
isInteractive = !(hasPrint || hasInitOnly || hasSdkUrl || !stdout.isTTY)
Any of these triggers set isInteractive=false, which bills against the
separate programmatic credit instead of the subscription:
- The
-p/--printflag. stdoutnot being a TTY, including piped or redirected headless runs.- The
--init-onlyflag. - The
--sdk-urlflag used by Agent SDK mode.
The bridge stays on subscription billing by spawning Claude as a real
interactive TUI session inside a tmux pty: no -p, no Agent SDK, no headless
stream-json, and stdout remains a TTY. Output formats are bridge-owned views
over that one interactive path.
Requirements
- Bun (
>= 1.1) claudeCLI on PATH, version>= 2.1.80tmuxon PATH.- Claude Code authenticated for the spawned
claudeprocess.
Use From npm
Run without installing:
# Drop-in print-mode usage. 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 # Opt in to Desplega/bridge envelopes for bridge-specific consumers. 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 --helpInstall 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.
Print Mode
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.
Auth
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 interactive claude works:
claude auth status claude
Then run the bridge:
claude-bridge -p "say hi"For headless CI, use the long-lived Claude Code OAuth token from:
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.
Output Formats
-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 Claude Code's interactive Stop hook. The hook
provides both last_assistant_message and transcript_path; the bridge uses
the assistant message as the fast path and hydrates transcript metadata from
the JSONL file.
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 metadata:session_id,duration_ms,stop_reason,usage, and atotal_cost_usdrecomputed from token usage and models.dev pricing.stream-json: emits the same event stream as Claude Code's headlessclaude -p --output-format stream-json— asystem/initevent,assistantevents,userevents for tool results, and a terminalresultevent — by reshaping the live interactive transcript into that schema. Interactive-only rows (mode,permission-mode,attachment,ai-title,stop_hook_summary,turn_duration) are dropped and wrapper fields are remapped (sessionId→session_id,requestId→request_id, etc.).total_cost_usdandmodelUsageare recomputed from token usage and models.dev pricing (seescripts/update-model-pricing.ts) and match the headless cost to the cent. Rate-limit / overloaded / retry rows (system/api_error) and safetymodel_refusal_fallbackrows are surfaced — a superset of headless, which retries silently. Failed turns emit a headless-shapedsubtype:"error_during_execution"result. Fields only the headless API client can produce (ttft_ms,duration_api_ms) are emitted asnull, and theinittool/mcp/agent inventory is empty placeholders, because the bridge drives the interactive TUI and never sees them. Full field-level contract in docs/stream-json-compat.md. The bridge learnstranscript_pathfrom early runtime hook events and falls back to transcript discovery / Stop-time catch-up if needed. It does not invoke Claude Code's headless--output-format stream-json.
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-formatWith --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 headless stream-json schema.
Typical --desplega-format --output-format stream-json event types are:
These custom transcript events only exist with --desplega-format. In the
default compatibility mode, stream-json emits the claude -p event schema
(system/init, assistant, user, terminal result) synthesized from the
live transcript as rows are appended — not the raw interactive rows — and never
emits bridge-owned delta/final rows.
The compatibility is structural, not byte-identical: some headless-only fields are synthesized, approximated, or omitted. See docs/stream-json-compat.md for the field-level contract and the bridge / Claude Code / models.dev version pins it was verified against.
Structured JSON
--json-schema <schema|file> is bridge-owned. It is not forwarded to raw
claude -p; the wrapper keeps the normal interactive tmux 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
jsonblock. - 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
Wrapper-owned vs forwarded
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 or the billing invariant are also
blocked: --tmux, --replay-user-messages/--replay*, -w/--worktree,
--init-only, and --sdk-url.
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.
Interactive Mode
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 rowsThe 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.):
Ctrl-D on the orchestrator kills the tmux session and exits.
Contributing and CI
Install local dependencies:
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 typecheckThe 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:
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
Release
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.
Future Notes
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.
Layout
src/cli.ts— orchestrator (tmux launcher + stdin REPL + hook/transcript result handling).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.jsonto suppress trust and permission prompts.src/hook-install.tsandsrc/stop-hook.ts— install and execute the runtime Stop/MessageDisplay hooks and the schema validation Stop hook.- Each run writes its run state and schema copy under
.claude-bridge/runs/<id>/in the target cwd.
Optional channel protocol
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.
Notes / known limitations
- 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 rawclaudedirectly 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 pressEnteryourself. - 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'].