@koda-sl/baker-bridge
v0.134.0
Published
HTTP server wrapping the Claude Agent SDK. Persists chat events to Convex; the dashboard subscribes via Convex realtime
Maintainers
Readme
@koda-sl/baker-bridge
HTTP server wrapping the Claude Agent SDK. Runs inside a sandbox, persists every chat event directly to Convex, and exposes HTTP endpoints for dispatch / abort / answer-question. The dashboard subscribes to Convex realtime — there is no chat WebSocket.
Installation
npm install @koda-sl/baker-bridge
# or
pnpm add @koda-sl/baker-bridgeQuick Start
CLI
npx @koda-sl/baker-bridge hono
npx @koda-sl/baker-bridge --versionProgrammatic
import { createServer } from "@koda-sl/baker-bridge/hono";
const server = createServer();
await server.start();
// Server listening on http://0.0.0.0:3000Environment Variables
| Variable | Description |
|---|---|
| AUTH_TOKEN | Bearer token for /message/* and /answer-question |
| BAKER_CONVEX_SITE_URL | Convex site URL for chat persistence (e.g. https://your-deployment.convex.site) |
| BAKER_API_KEY | API key for Convex callbacks (bk_...) |
| BAKER_BASE_BRANCH | Git base branch for sandbox baseline lookups. Defaults to main; /company, /brand, /knowledge, landing definition, and /flows/:slug endpoints use the merge-base with this ref when ?baseline=1 is requested |
| INTERNAL_SECRET_KEY | AES key for encrypting flow _data.json config at rest |
| BAKER_RUNTIME_COMPANY_ID | Optional. The company the Runtime works for; stamped into each Form destination the bridge seals. Deliberately not BAKER_COMPANY_ID, which the scaffold build reads as a production deploy setting |
| ANTHROPIC_API_KEY | Anthropic API key (required by the Agent SDK) |
| ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN | OpenRouter routing (base URL + per-org key) injected into the sandbox. Forwarded to the Agent SDK subprocess so the agent can reach its model |
| OPENROUTER_API_KEY | The same per-org key under its own name. The bridge reads the user's and the agent's words with it before letting two kinds of call through: a question asking the user to pick an avatar's face (the casting picker shows real portraits), and an avatar accent in a language nobody asked for. Unset or unreachable, both calls go through unchecked |
| CLAUDE_CODE_EXTRA_BODY | Optional JSON merged by the CLI into every model request body. The bridge sets/extends it per turn with a per-thread session_id so OpenRouter sticky-routes the whole conversation to one provider instance and its instance-scoped prompt cache is re-read across the turn's many tool-call requests (see Prompt-cache pinning). It also defaults provider to Bedrock first (see Bedrock is the preferred provider). Any inherited value is merged; our session_id and the web-plugin opt-out win (see OpenRouter web plugin is forced off). An inherited provider replaces the Bedrock default |
| BAKER_CHAT_ID | Present only when running inside a chat sandbox. When set, the bridge relocates Claude Code auto-memory into the cloned repo (.claude/memory) — see Session journal |
| BAKER_ACTING_USER_ID | Injected into the Agent SDK subprocess env per turn (when the turn's sender is known) so in-sandbox baker CLI calls can scope work to the acting user — e.g. baker mcp add --scope user |
| CLAUDE_CODE_ENABLE_TASKS | Set to 0 on every Agent SDK subprocess. Turns off the CLI's built-in task backlog and its task_reminder — see Session surface |
| CLAUDE_CODE_MODEL_CAPABILITIES | Set to -per_turn_effort on every Agent SDK subprocess so no request carries a per-message effort, which OpenRouter cannot route to Bedrock — see Bedrock is the preferred provider |
| NO_COLOR | Set to 1 on every Agent SDK subprocess so baker --help output carries no ANSI escapes — see Session surface |
| BAKER_AGENT_MODEL | Optional. Runs every turn in this runtime on the named model instead of the default, and is forwarded to subagents as CLAUDE_CODE_SUBAGENT_MODEL. Ignored unless it names a model with a known context window — see Model selection |
| CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_SUBAGENT_MODEL_FORCE | Set on the Agent SDK subprocess only when BAKER_AGENT_MODEL is set. Together they override the model: each subagent pins in its own frontmatter |
| BAKER_CREDENTIALS | Set to 1 in a Runtime with Credentials (the credential broker is running). Only then is request_credential offered to the agent; otherwise a call to it is refused |
All variables are validated at startup via @t3-oss/env-core + zod.
Credential broker (baker-credential-broker)
A second bin, run as root in the sandbox before the agent starts, so the agent can use the user's stored credentials without ever seeing them. A third, baker-credential-broker-supervisor, takes the same environment, runs the broker and starts it again whenever it exits, stops answering GET /health, or fails to start at all; on SIGTERM it stops the broker and kills it if it is still running 5 s later; it is what a Runtime starts. It runs out of the OOM killer's reach with the broker's heap capped, and the bridge marks every process its agent starts as the OOM killer's first choice — through a command wrapper it passes as CLAUDE_CODE_SHELL_PREFIX (unless the Runtime set one), backed by a 2-second scan — so memory pressure ends one command, never the turn. src/hono/oom-priority.agent.test.ts runs the SDK's own agent binary through the wrapper, so an upgrade that changes how it calls CLAUDE_CODE_SHELL_PREFIX fails the suite. The agent writes {{credential:NAME}} in a request; the broker is an HTTP(S) proxy on 127.0.0.1 that swaps in the real value and swaps it back out of the response.
| Variable | Description |
|---|---|
| CREDENTIAL_BROKER_TOKEN | Required. Authenticates the broker to Baker's credential routes |
| BAKER_CONVEX_SITE_URL | Required. Baker's site URL |
| BAKER_CREDENTIAL_PROXY_PORT | Proxy port (default 8877) |
| BAKER_CA_DIR | Where the broker's CA key (0600) and cert live (default /etc/baker-ca); run as root it also installs the cert into the system bundle |
| BAKER_BROKER_SOCKET | Unix socket for the bridge (default /run/baker/broker.sock): POST /scrub, GET /health |
The agent's processes get agentProxyEnv(): HTTP(S)_PROXY pointing at the broker, NO_PROXY for loopback plus the model gateway, NODE_USE_ENV_PROXY=1, NODE_EXTRA_CA_CERTS set to the broker's CA, and REQUESTS_CA_BUNDLE / SSL_CERT_FILE / CURL_CA_BUNDLE set to the system bundle.
Security model: a value is only substituted over https, and only for a host the user allowed for that credential. A placeholder in the URL or a header that is not allowed for the host, or that names no stored credential, is refused (403 / 404, or 503 while credentials can't be fetched) before anything leaves the sandbox. A placeholder that appears only in the body is left as plain text unless it names a credential allowed for that host. Response headers and text bodies up to 10 MB are scrubbed back to placeholders; those responses arrive buffered rather than streamed. The socket never returns a value. Once one fetch has succeeded, /scrub answers from the last good table while a refresh is in flight; the bridge retries a failed /scrub three times (0.25 s, 1 s, 3 s) before withholding a tool result.
Session surface
The Agent SDK ships developer-facing surfaces a marketer's session can never
use, and each one costs context on every turn. The bridge trims them at the
query() options:
- Skills are allowlisted to the workspace's own.
Options.skillsis set to the directory names under the workspace's.claude/skills/, read off disk per turn so the list can't drift from the workspace. Leaving the option unset is not "skills off" — the CLI's defaults still load its built-in developer skills (init,review,security-review,run,simplify, …). Thedocx/pdf/pptx/xlsxrenderers are workspace skills backing real deliverables, so they stay. This is a context filter, not a sandbox: unlisted skill files remain readable on disk. If the workspace cannot be read the option is omitted rather than emptied, so a bad read never strips the real skills. - The CLI task backlog is disallowed —
TaskCreate,TaskGet,TaskList,TaskUpdate. The product's durable backlog isbaker actions; anything filed in the CLI's own backlog is invisible to the user and dies with the session. Claude Code 2.1.233 withholds these from Sonnet-5-and-newer models by itself, so on Opus 5.5 the entries are belt-and-braces — but Haiku 4.5 predates that gate and is still offered all four, so they remain load-bearing whereverBAKER_AGENT_MODELpoints at an older model.TaskStopis not disallowed: despite the prefix it is a background-execution tool (the SDK canonicalizesKillShell/KillBash→TaskStop). Claude Code 2.1.277 removedTaskOutput; the agent reads a backgroundedbakercommand's output file withRead.TodoWriteis no longer offered on any model. - Cron and wakeup tools are disallowed —
CronCreate,CronDelete,CronList,ScheduleWakeup. Scheduling belongs tobaker scheduled-agents. - Tools that reach past one session are disallowed —
EnterWorktreeandExitWorktree(the workspace's branches belong to the chat-branch and publish model, which has no account of a second checkout),Workflow(fans out dozens of agents in one call on the organization's key).Taskitself is not disallowed — it is the subagent spawn the workspace's own agents ride — and neither isSendMessage, which continues a subagent the session already ran; the turn stays open until the continued subagent finishes.
To see what a session actually ends up with, read the tool list off its own
init message (--output-format stream-json --verbose) rather than this list:
the CLI gates some tools on the model, so the resolved set differs per model.
CLAUDE_CODE_ENABLE_TASKS=0suppresses thetask_remindersystem-reminder that pushed the agent toward that same ephemeral backlog. It is a binary-only, undocumented flag — noOptionsfield backs it.NO_COLOR=1stopscittywriting ANSI escapes intobaker --help. They are never TTY-gated, so without it every help read spends ~5-10% of its tokens on colour codes the model cannot see.
Options.agents is deliberately left unset: the workspace ships its own
subagents in .claude/agents/, discovered from disk via
settingSources: ["project"], and setting the option risks replacing them.
Model selection
Every turn runs on claude-opus-5-5 by default — a deliberate quality-over-cost
choice. Opus 5.5 is $4/$20 per MTok on OpenRouter against Sonnet 5.5's $2/$10, i.e.
2x on input, output and cache reads. Two things contain the cost: the main
thread runs at medium effort (see Reasoning effort below), and the shared
subagents pin themselves to claude-sonnet-5-5 in their own frontmatter rather
than inheriting this default — without that pin every fan-out worker would move
to Opus pricing too.
Overriding the model for a whole runtime
Set BAKER_AGENT_MODEL to run every turn in that runtime on another model. It
accepts either form of id (claude-haiku-5-5 or anthropic/claude-haiku-5.5)
and is normalised to what the Agent SDK expects. Development deployments set it
to claude-haiku-5-5, where turnaround matters more than capability.
Two things it does that a plain model option does not:
- It also moves the fan-out workers. The bridge forwards it to the CLI as
CLAUDE_CODE_SUBAGENT_MODELplusCLAUDE_CODE_SUBAGENT_MODEL_FORCE=1. Since Claude Code 2.1.251 the first alone is only the default beneath themodel:each subagent pins in its own frontmatter; the second makes it win again. Unset, no key is sent at all — an empty value would strip those pins instead of leaving them alone. - An id we do not know is ignored, not forwarded. The value must name a model with a known context window; anything else falls back to the default. An unknown id would disable the auto-compact pin and leave the context meter reading against whatever the runtime reports.
Output style
No output style is sent: turns run on the CLI's default voice, and the
workspace's CLAUDE.md ("Talking to the User") says when and how the agent
writes to the user. The built-in Concise style used to be pinned here; on
Opus 5.5 it suppressed the short progress notes users read during long turns.
Reasoning effort
Every turn is sent with effort: "medium" (the API default is high). Effort
governs thinking depth, preamble, and how many tool calls the model makes; the
tool-call reduction matters most, because each tool call is another request that
re-reads the whole prompt, so fewer calls also means less cache-read volume.
It must stay a single constant. The resolved effort is rendered into the prompt, so changing it between requests invalidates every cache breakpoint above it — on a session carrying hundreds of thousands of tokens, that re-write costs far more than any per-turn tuning saves. To vary reasoning per task, steer with prompt text on the newest user message instead; that leaves earlier breakpoints intact.
Subagents set their own effort (and model) in their markdown frontmatter and
do not inherit either from the main thread. The four scaffold-shared agents
all run claude-sonnet-5-5: landing-finish-reviewer, research and
visual-check at effort: low, creative-producer at effort: medium
(rebuilding a reference's winning mechanism for a brand is judgment, not a
mechanical build). Either way it is cache-safe —
a subagent carries its own context, so it cannot invalidate the parent's prefix.
Prompt-cache pinning
The agent reaches its model through OpenRouter's Anthropic-compatible passthrough
(ANTHROPIC_BASE_URL), so the CLI owns the request body and provider-routing
fields cannot be added to it directly. OpenRouter's prompt cache is scoped to a
provider instance, and its default sticky routing keys on the opening messages —
which shift every turn on a live session (growing history, auto-compaction,
injected context). Left unpinned, OpenRouter re-routes across (e.g.) Vertex
instances and the cache is never re-read: long sessions were observed at a ~2%
hit rate versus an ~88% account average, so hundreds of tool-call requests each
re-paid full input price on the same ~30k-token prefix.
The fix (openrouter-session.ts): the bridge injects a stable per-thread
session_id via CLAUDE_CODE_EXTRA_BODY, which the CLI merges into every model
request body. That pins the whole conversation to its warm provider instance "even
if the opening messages change". session_id alone does not disable fallbacks, so
provider resilience is preserved. See OpenRouter → Prompt Caching (best practices). A manual
provider.order bypasses this sticky routing — see
Bedrock is the preferred provider for how the
cache stays warm while the Bedrock default is set.
Bedrock is the preferred provider
The same CLAUDE_CODE_EXTRA_BODY value carries a provider with
allow_fallbacks: true and this order:
["amazon-bedrock", "amazon-bedrock/eu-west-1", "amazon-bedrock/us-east-1"]Without it, OpenRouter load-balances Claude across Anthropic, Vertex, Azure and
Bedrock by price and uptime, and the session_id pin then holds the conversation
on whichever it picked first. With it, every request goes to Bedrock first, then
to the two regional Bedrock endpoints, and only moves to another provider when all
three fail. The regions are there because Bedrock's failure mode is AWS capacity,
not our quota: on weekday mornings the default endpoint answers 503 to most Opus
5.5 requests for hours, and AWS's advice for a 503 is another region. All three
run on our own Bedrock key; the providers after them bill OpenRouter credits. An
inherited provider (set in the sandbox's CLAUDE_CODE_EXTRA_BODY) replaces this
default as an operator override.
A manual order bypasses OpenRouter's sticky routing, so while it is set the
session_id pin above is inert and the order is what keeps the cache warm. The
bare amazon-bedrock slug is a single endpoint (regional endpoints are separate
slugs), so while it is healthy every request lands on the same one; measured,
consecutive requests read the whole cached prefix back. The cost is on fallback.
Each endpoint keeps its own cache, the regional Bedrock ones included, so a
request that moves to the next endpoint starts cold and pays its whole prefix;
and each request retries the default endpoint first, so a cache warmed further
down the order is not reused while Bedrock is flapping. During a capacity
window every request also waits for the default endpoint's 503 (about 7 s)
before moving on.
The session_id pin still applies whenever an inherited provider drops the
order.
The preference only holds for a request Bedrock can serve, and OpenRouter drops
Bedrock from the route without an error when it can't. From Opus 5.5 the CLI
restates the effort on its mid-conversation role: "system" message
(output_config.effort, beta per-turn-control), which no Bedrock endpoint
supports — so the day Opus 5.5 shipped, 52 of ~4,600 of its requests reached
Bedrock and the rest were billed to OpenRouter credits. The bridge sets
CLAUDE_CODE_MODEL_CAPABILITIES=-per_turn_effort to stop that; the request-level
effort still applies. A request with a structured-output schema (the CLI's
short title request) is still served elsewhere, because Bedrock lacks structured
outputs on OpenRouter.
OpenRouter web plugin is forced off
The same CLAUDE_CODE_EXTRA_BODY value carries plugins: [{ id: "web", enabled: false }].
OpenRouter's web plugin is its own server-side web search, billed per search on
top of the model, and it can be switched on account-wide under Settings → Plugins —
in which case it augments (and bills) every request the agent sends without the
agent ever asking for it. The explicit enabled: false overrides that account
default per request. This does not touch the agent's own WebSearch tool,
which the CLI declares in the request tools. OpenRouter's openrouter:web_fetch
and other server tools are opt-in via tools only, so there is nothing to disable
for those. An admin who sets "Prevent overrides" on the plugin wins over this field —
the account setting is then the only off-switch.
Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | /health | None | Health check — returns { status, projectDir } |
| GET | /commands | None | List available slash commands (cached 5m). Allowlisted from disk: skills under the workspace's .claude/skills/, plus the bridge's own /compact and /publish. Everything else the Agent SDK reports — Claude Code built-ins and bundled skills — is hidden, as are the docx/pdf/pptx/xlsx renderers that document-build routes to |
| GET | /company | None | Return COMPANY.md as { content, title }; ?baseline=1 also includes baselineContent from the git merge-base when available |
| GET | /brand | None | Return BRAND.md as { content, title }; ?baseline=1 also includes baselineContent from the git merge-base when available |
| GET | /brand/entry?path=<sub-path> | None | Return one file inside src/brand/ by its path (e.g. logos/logo.webp). Markdown paths return { content, title } (+ baselineContent with ?baseline=1); every other asset (logos, fonts) streams raw bytes with its Content-Type. ?baseline=1 serves the version at the git merge-base (the "before"), used by the per-file Brand changes preview |
| GET | /knowledge/:collection/:entry or /knowledge/:entry | None | Return knowledge entry as { content, title } — content is the whole file, frontmatter included (the dashboard shows it as properties); title-only Chat Change slugs are resolved by matching entry ids/titles. ?baseline=1 also includes the whole-file baselineContent from the git merge-base when available |
| GET | /documents/:slug | None | Serve document binary (pdf, pptx, docx, xlsx) |
| GET | /checks/:slug | None | Return a check as { slug, scope, manifest, readme, files, related }: every file of __tooling__/checks/extensions/<slug>/ (for a base check, only its waivers.json) with content, baselineContent from the git merge-base and a status (added, modified, deleted, unchanged), plus the __tooling__/checks/sdk/ modules its scripts import |
| GET | /landings/:slug/definition | None | Return landing _definition.md as { content, title }; ?baseline=1 also includes baselineContent from the git merge-base when available |
| GET | /flows/:slug | None | Read flow _data.json (decrypted). ?baseline=1 returns { flow, baselineFlow, baselineStatus } — used by the dashboard's structural changes view. baselineStatus is found (baseline decoded, baselineFlow set), new (flow absent at the merge-base, i.e. not yet published), unavailable (no base ref / merge-base to compare against, or an unexpected git failure), or undecodable (baseline present at the merge-base but not readable — invalid JSON or not decryptable with the current key); the last two keep the UI from rendering a misleading full "added" diff |
| PUT | /flows/:slug | None | Write flow _data.json (encrypts at rest) |
| POST | /message/async | Bearer token | Dispatch a chat turn — fire-and-forget, 202 immediately. Idempotent on turnId: a redelivery of the turn already in flight (Convex retries when a busy sandbox is slow to ack) is adopted onto the running run instead of aborting and restarting it. Convex must include turnId, may include rendered history for deterministic rehydration, currentReference ({ type, slug, title }) for the output the user is viewing in the preview pane (sent to the agent as a <system-reminder> block beside the message, never inside it), and currentSender ({ name, role?, userId? }) identifying the human who sent this turn (userId scopes their user-owned MCP tools), and planMode (bool) to run the turn read-only under the SDK plan permission mode until the user approves, and unattended (bool) when a schedule started the turn and nobody can answer a prompt — every blocking prompt tool is then denied up front with an instruction to decide and state the assumption, because waiting one out parks the thread on a card that will never be answered, and continuedAttachments ([{ url, contentType, filename }]) on a turn started by a late answer to a blocking prompt — the files of the request that answer continues, handed to the agent only when the bridge rebuilds the Session from history rather than resuming the one that already received them |
| POST | /message/abort | Bearer token | Abort a running query — kills the SDK iterator |
| POST | /answer-question | Bearer token | Resolve a pending AskUserQuestion |
| POST | /plan-decision | Bearer token | Resolve a pending plan-mode ExitPlanMode review with { decision: approve \| edit \| reject } |
| POST | /tag-input-decision | Bearer token | Resolve a pending request_tag_input secure tag form with the redacted { result } from Convex (never secret values) |
| POST | /connection-input-decision | Bearer token | Resolve a pending request_connection: the redacted { results } naming the platform, account label, and selected accounts — never credentials |
| POST | /flow-input-decision | Bearer token | Resolve a pending request_flow_input form: writes the user's resolved values (secrets/OAuth refs/picked resources) into the branch flow file via the encrypting writeFlow, then returns the redacted per-tab { results } |
In-process MCP server (baker_ui)
Every tool on this server must keep z.record(...) out of its input shape. The CLI converts each tool's shape to JSON Schema and refuses any tool carrying propertyNames, which is exactly what zod renders a record's key type as — and the refusal is silent and total: the server still reports status: "connected" while offering zero tools, so all three forms disappear from the session at once with nothing logged. Say "object with arbitrary keys" as z.object({}).catchall(v) instead; it converts to plain additionalProperties and registers. baker-ui-tool-schemas.test.ts fails if a record comes back.
Every turn also carries an in-process SDK MCP server named baker_ui hosting interactive dashboard tools. Its first tool, request_tag_input, is the blocking secure tag form: the agent invokes it when a marketing tag needs secret values (API keys, access tokens, OAuth connections). The bridge intercepts the call in canUseTool (same pattern as AskUserQuestion), POSTs /api/chat/tag-input-request to Convex so the dashboard renders the form, and suspends the turn until Convex delivers the resolution via POST /tag-input-decision. Secret values flow dashboard → Convex tag draft directly; the bridge and the model only ever see the redacted result ({ status, ref, type, identifier, secretFieldsSet }). Secret values a model puts into prefilledConfig are stripped bridge-side (and again backend-side) — their names become requested fields instead. prefilledConfig keys must be real config fields of the tag type (the tool description lists them per type); a call that invents field names is denied with the valid ones, because such a key renders nowhere in the form and would otherwise fail at stage time, after the user already approved. Live wait matches AskUserQuestion (15 min); like all blocking prompts, the form itself never expires — the thread parks and a later submission recovers the turn. It is not a connection route: a googleTagManager tag is the container snippet on the site, while the Tag Manager connection that unlocks the container's contents also has to name a container — which this form cannot do — so oauthProviderId is not requestable there and the backend rejects it, pointing the agent at request_connection.
A ChatGPT Ads tag pre-fills its Pixel ID and requests the optional Conversions API key in this secure form. The bridge classifies apiKey as a secret, so the agent never sees it and the published page receives only the Pixel ID and a signed server-event reference.
Its second tool, request_flow_input, is the blocking form-configuration form: the agent invokes it to configure the confidential parts of a flow ("Form") it cannot author itself — side-effect credentials/connections (encryptedConfig, oauthProviderId) and widget third-party field definitions (nodeData.form.external for HubSpot/Calendly/HighLevel/SavvyCal). The agent authors the surrounding structure in _data.json and references it by nodeId/sideEffectId. Same canUseTool park pattern: the bridge POSTs /api/chat/flow-input-request, the dashboard renders a tabbed form (secret inputs / OAuth connect+account picker / live resource picker), and on submit Convex delivers the resolved values to POST /flow-input-decision. Unlike tags, the resolved values are written into the branch flow file through the encrypting writeFlow (read → patch node/side-effect → write); the model only ever sees a redacted result: { status, nodeId, sideEffectId?, secretFieldsSet, destination?, connected?, fields?, note? }. destination is the non-secret half of what the user chose — the spreadsheet, the sheet, the portal, the Calendly event — so an agent that has just configured an integration does not have to spend a turn on baker flows show --full to learn what it configured, and does not map columns against a guess. For a widget node, fields carries the autofill keys the picked resource accepts — literally the left-hand side of an autofilledFields entry, adapter prefix included, so a SavvyCal custom field arrives as questions.<id> because that is what its adapter reads. They come from each provider's own field container (fieldGroups[].fields[], customQuestions[], customParams.formFields[], customFormFields[], fields[], customFields[]) rather than from a search for name-shaped strings, so a select's option labels and the resource's own title are not among them (a HubSpot form's name is "Contact us"; it is reported under destination). A widget with no field list of its own returns none rather than a guess. Both targets go through the same filter: only plain strings and numbers are echoed, and anything in that type's secret list is redacted to a name in secretFieldsSet. A few non-secret keys are withheld from destination on top of that, because they are not an answer either — an httpWebhook's basic-auth username is not a typed credential, so it is not in the secret list, but echoing it hands the model half a credential and nothing on the side effect is addressed by it. Leaked secret values in prefilledConfig are stripped bridge-side (and again backend-side), and a call whose prefilledConfig names a key the side effect's card does not render (an email card reads email and subject, not to) is denied with the valid keys, because the card would drop it silently. A request for an email side effect is denied outright with the baker flows connect … --email … --subject … call that sets it: nothing in an email destination is secret, so it is the agent's to state, never the user's card. Live wait matches AskUserQuestion (15 min); like all blocking prompts, the form itself never expires — the thread parks and a later submission recovers the turn.
Its third tool, request_connection, is the blocking connection request: the agent invokes it when a data platform the task needs — Google Ads, LinkedIn Ads, Meta Ads, X Ads, Google Analytics 4, Google Search Console, or Google Tag Manager — is not connected yet, instead of telling the user to go to Settings. The same card connects Baker's tool connections (HubSpot, Google Drive, GoHighLevel, Pipedrive, Calendly, SavvyCal) and the key-pasted ones: ChatGPT Ads (chatgpt-ads, an API key) and ActiveCampaign (activecampaign, its API URL and API key), typed into the card and never into the chat. Same canUseTool park pattern: the bridge POSTs /api/chat/connection-input-request, the dashboard renders one tab per platform with the OAuth connect button and the account/property/site/container picker, and on submit Convex delivers the outcome to POST /connection-input-decision. Nothing confidential passes through the bridge or the model: the dashboard writes the selection onto the company's connection, and the resolution carries only { status, platform, accountLabel?, selected[] }. A Google Drive tab may add choose: "media-folder" (kept only on Google Drive): the user picks a folder that Baker imports as a media source, and the connected result also carries its mediaSourceKey. An ad or data platform tab may add pick: true (kept only on platforms with accounts to choose): when the platform is already connected, the card opens its account picker straight away, for the agent that found the account it needs is not selected. Live wait matches AskUserQuestion (15 min).
The same card adds tools. A { server: { name, url, auth?, keyHeader?, scope? } } entry opens a remote MCP server filled in from the user's instructions (auth: none | key | sign-in; the key is typed into the card, never into the chat), and an { app } entry connects a managed app from baker mcp catalog — the fallback for a vendor with no server of its own. When an app or server comes through, the bridge fetches the turn's MCP servers afresh (McpServersProvider with { fresh: true }) and loads them into the running query with setMcpServers — baker_ui included, since the SDK drops any in-process server left out — before the tool answers, and adds toolsReady to the resolution. The provider keeps that set for the next turn, so the tool list changes once. A platform-only resolution never reloads.
Its fourth tool, request_avatar_casting, is the blocking face picker: an avatar invented from a description gets several casting portraits rendered, the user picks one in the dashboard, and the identity sheet every later render grounds on is fused from that one. Same canUseTool park pattern — the bridge POSTs /api/chat/avatar-casting-request, Convex renders the candidates and parks the thread, and the pick is delivered to POST /avatar-casting-decision as { status: "cast", avatarHandle, imageId } or { status: "declined", avatarHandle, reason? }.
It is also the one blocking tool the bridge opens on its own. The agent forgot to call it in every recorded run, in a new way each time, so the trigger is the EVENT rather than the prose around it: a baker avatars create for a synthetic avatar registers a pending casting, and it opens before whatever the agent does next. The create is read off the assistant message as well as off canUseTool, because canUseTool is raised only for the ask path and the scaffold allows Bash(baker:*) — so every create the product actually runs bypassed the permission callback entirely, and the avatar sat in generating with no portraits and no sheet while the agent reported the picker was open. When the create is the turn's last tool call there is no next call to open it before, so the request is posted at turn end and the thread parks on it. An unattended Session has nobody to ask, so the create is rewritten to --face auto instead and nothing is left pending — the flag is inserted right after avatars create rather than appended, so a piped or redirected command does not hand it to the next program, and an explicit --face ask is replaced rather than joined.
The picker does not wait forever. Once every face is on screen, Convex schedules a pick 30 seconds out: if the user has not chosen (or pressed "Give me more time", which clears the deadline), the first face is kept and delivered as { status: "cast", avatarHandle, imageId, chosenBy: "auto" }. The pick runs server-side, so a tab left in the background never stalls the job, and the result text tells the agent nobody chose that face so it never credits the user with it.
A decline comes back to the agent with the remedy attached, not as a bare {"status":"declined"}. The tool's description says to sharpen the subject with baker avatars update and call it again, but that text sits thousands of tokens up the context and was written before the turn began; read against the bare object, the agent replied "the casting portraits should be showing on your screen right now" to a user who had just rejected all four and whose picker was gone. The result now states the outcome and the next move together, the way a CLI error.fix does, and says to ask what to change when no reason was given.
When the agent does call request_avatar_casting for the avatar a pending casting is already owed to — the tool's description says the picker opens by itself, but the call is tolerated — that call becomes the casting rather than triggering a second one, so the pick is returned to the tool that asked for it. Every other blocking prompt that follows a create still reaches its own handler; the auto-cast runs first, then the original call is routed normally.
MCP servers (Composio)
Before each turn the bridge fetches the company's scoped Composio MCP endpoints from Convex — POST {BAKER_CONVEX_SITE_URL}/api/chat/mcp-servers with the bridge's BAKER_API_KEY bearer, sending { threadId, actingUserId? }. The response { mcpServers } map (up to four streamable-HTTP entries: composio_user, composio_user_org, composio_company, composio_org — plus any custom servers) is passed straight into options.mcpServers. No Composio secret is injected into the sandbox env; the capability URLs live only in the bridge process.
The fetch is resilient by design: results are cached per (thread, actor) for 60s, and a failed or timed-out refetch (2.5s) serves the last set that (thread, actor) received, however old — the tool list is part of the prompt, so dropping it mid-chat would rewrite the prompt cache. Only an actor that never fetched successfully in that chat runs the turn with no MCP; nothing here fails a turn. The actor is part of the key because personal connections (user, user_org, per-user custom-server sign-ins) belong to the person sending the message, so a teammate's turn never reuses them. When the Agent SDK reports an MCP server failed to connect, the bridge fires POST /api/chat/mcp-invalid: a composio_* entry is reported by scope, so Convex re-mints that scope's session on the next turn, and a custom server is reported by name (customServerName), so Convex re-checks its address and records why it failed instead of the entry being silently dropped.
Tool mentions
A user can @-mention a tool in a message (a green chip in the composer). Two kinds arrive, and the bridge treats them differently because the agent has to reach them differently:
@[Label](mcp:<key>)— a tool that arrives in the session over MCP. The bridge appends an instruction naming the requested tools so the agent prefers them for that turn. A hint, not a restriction: every other tool stays available and the server map is unchanged.@[Label](integration:<provider>)— a Baker integration (GA4, Search Console, Tag Manager, an ad platform). There is no MCP server for these; they are read withbakercommands. The bridge appends an instruction naming the command for each one, so an agent told to "read Google Search Console" does not go looking for a tool of that name, find nothing, and report a connected platform as unavailable.
Both kinds are stripped back to their plain Label first — the mention syntax is dashboard serialization and means nothing to the model. The older mcp:<provider>:<key> spelling is still read, for messages written before integrations had their own type.
Agent scheduling
The bridge disables Claude Code's native cron tools (CronCreate, CronDelete,
CronList, and ScheduleWakeup) in Agent SDK sessions. Scheduled work must use
Baker's product surface instead: baker scheduled-agents create/update/delete/run.
Chat flow
Convex action ──POST /message/async──▶ Bridge AgentSession
│
▼
SDK query() iterator
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
/api/chat/stream /api/chat/event /api/chat/complete
(throttled ~50ms (assistant events, (final cost / status)
text + tool list, idempotent on uuid)
replaces preview)
│
▼
Convex (single source
of truth — UI reads
via realtime query)- Live preview is throttled to ~50ms — the bridge buffers text deltas and
in-flight tool starts and POSTs them to
/api/chat/stream. The Convex mutation overwritesthread.streamingState. The UI reactively re-renders. - Persisted messages go to
/api/chat/eventwith the SDK'sevent.uuidfor idempotent insert. The mutation also clearsstreamingStateinline so the UI snaps from preview to persisted text without a flicker. - Completion is never blocked by a dropped preview. A failed final preview
flush is recorded but does not abort the terminal
/api/chat/completecallback — completion is still delivered (and retried on failure) so a lost preview can't strand a thread instreaminguntil the stale-thread cron times it out. - Stop button flips the thread to
cancelledvia Convex first (UI is freed immediately) and best-effort POSTs/message/abortto kill the SDK./api/chat/completeresponses distinguish delivered callbacks from accepted completions. Accepted completions trigger scaffold publish recovery; ordinary stale completions withaccepted: falseare not retried and do not run recovery. User-cancelled completions are the exception: Convex may reject the terminal callback because the thread is already cancelled, so the bridge runs scaffold publish recovery locally to reconcile any pending/publishstate. Accepted foreground completions wait for recovery before closing the Bridge Job. Accepted background retries are tracked in the same terminal lifecycle barrier, and the next Bridge Job waits behind any in-flight retry or recovery before SDK execution starts, so abandoned/publishattempts send a terminalcompletedorpublish_failedcallback instead of leaving chats stuck inpublishing. - AskUserQuestion posts
/api/chat/input-requestand blocks until the dashboard answers via Convex action → bridge/answer-question, with a 15m bridge-side LIVE wait window so unanswered questions do not hold the sandbox forever. Blocking prompts never time out for the user: when the live wait expires the tool is denied with guidance and the agent ends its turn cleanly, but the bridge does NOT post input-resolved — the pending prompt survives, the backend parks the thread on it (question card stays up, sandbox pauses at no cost), and the user's answer, whenever it comes, dispatches a recovery turn on a fresh sandbox. The same applies torequest_tag_input,request_flow_input, and plan review. Convex persists the answer before proxying it so a bridge restart does not erase the user's response. - Plan mode (
ExitPlanMode) posts/api/chat/plan-reviewand blocks thecanUseToolcallback until the dashboard returns a decision via Convex action → bridge/plan-decision. Approve lets the same turn exit plan mode and execute; edit denies with feedback so the agent revises and re-proposes; reject denies with a stop instruction (never interrupts — an interrupt aborts the turn mid-tool-call and Claude Code returns an error-during-execution result that surfaces as a failed turn). A turn runs under the SDKplanpermission mode (read-only until approval) when the dispatch carriesplanMode: true(the dashboard's plan-mode toggle); a/plan …prompt is the fallback trigger. Every other turn passesdefaultexplicitly: an omitted mode starts an OpenRouter session in auto mode, where a classifier rather thancanUseTooldecides. The review loop fires only when plan mode is active; if the agent callsExitPlanModeoutside plan mode it is a no-op passthrough (no review card). When the SDK omits inline plan text, the bridge reads Claude Code's reported plan file path under~/.claude/plans/, falling back to the latestcurrent-*.mdfile. Shares the 15m timeout. - Spawned-agent activity is tracked from the Agent SDK's task lifecycle
system messages (
task_started/task_progress/task_notification/task_updated). Only subagent tasks are surfaced (shell/monitor/workflow andskip_transcripttasks are ignored); on every change the current turn's list of agents and their status (working/done/error/stopped) is POSTed to/api/chat/agent-activity. Each working agent also carries a live "what it's doing now" hint fromtask_progress— the rawlast_tool_name(the dashboard maps it to a friendly label) and the SDK's progresssummary— cleared when the agent settles. Convex persists the list on the thread so the dashboard can pin a status card at the bottom of the chat even after the spawning messages scroll away; the terminal/api/chat/completesettles any agent still markedworking. - Chat Changes are synced by the scaffold watcher, not by the bridge. The
workspace git watcher POSTs artifact changes to
/api/chats/changesas the agent works; the bridge's/api/chat/completecallback carries only cost, status, per-model token usage, and (for exact/publishturns)completeChat: trueso Claude Code handles the publish slash command directly. Document previews resolve both managedsrc/documents/<slug>/index.<format>outputs and flatsrc/documents/<slug>.<format>files. - Per-model token usage rides along on
/api/chat/completeasmodelUsage: [{ model, costUsd, inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens }], accumulated across every API round-trip of the turn — main conversation, subagents, and the router — and keyed by the canonical model id. Omitted when the turn produced no usage. This is the billing total, and is deliberately not the same reading as the context-window meter below, which must come from a single request. It is the turn's own spend. Since Claude Code 2.1.277 a resumed or forked session restores the totals its source saved at exit (acost-stateline in the transcript), so every result is cumulative over the whole conversation. The bridge reads that saved total before each resumed query and subtracts it (session-usage-baseline.ts); a retry after a stall first waits for the old CLI to exit so the total it saves cannot change underneath. - Context source diagnostics go to
/api/chat/contextso support can tell whether a turn usedlocal_resume,rehydrated_missing_session,rehydrated_invalid_resume, or hitrehydration_failed. - Context window usage is relayed after every turn to
POST /api/chat/context-usageas{ tokens, window }. Occupiedtokenscome from the last main-conversation request's own usage (input_tokens + cache_read_input_tokens + cache_creation_input_tokens) — the real fill of the window at that request. It is read from two places, because neither is reliable alone: the assembled assistant message, and the rawmessage_deltastream event (streamEventContextUsage). Anthropic reports a request's input counts onmessage_start, which is where the runtime builds the message'susagefrom — but the OpenRouter passthrough sendsmessage_startempty and puts the real numbers onmessage_delta, so the assistant message arrives all zeros. Reading only the message meant the meter never had a single reading in production: 3,910 assistant messages in one day, every one reporting zero input tokens, so no chat ever stored acontextUsageand the meter never rendered. A zero from either source is ignored rather than written, so the meter holds its last real value instead of resetting. The bridge does not use the turnresult's per-model token totals: those aggregate every API round-trip in the turn (one per tool call, plus any same-model subagent work), so summing them inflates the reading by roughly the number of tool calls and would pin the meter at 100% on an almost-empty chat. Subagent requests are skipped entirely (onlyparent_tool_use_id-free, non-subagent_typemessages count). Thewindowis the model's real OpenRouter context length, resolved from the model id (contextWindowForModelinmodel-capabilities.ts): opus 5.5, opus 5, sonnet 5.5, sonnet 5 and haiku 5.5 are 1M, haiku 4.5 is 200k — and resolved from the model that actually served the request, which is not always the session's. A skill can pin its own model for the turn viamodel:in its frontmatter, and measuring such a turn against the session's 1M reads a third full when it is in fact over — the mismatch that hid the publish failure. (Publish was that example until it was unpinned; the mechanism is what matters here, and any skill can still use it.) We resolve it ourselves because the runtime reaches these models through the OpenRouter passthrough (ANTHROPIC_BASE_URL=openrouter.ai) while the CLI resolves the window from its own bundled catalog, which is keyed to the first-party API (see the 1M context correction below for where they disagree). A retired 4.x Opus or Sonnet id is treated as unknown. For an unknown model we fall back to the runtime-reportedmodelUsage.contextWindow. The dashboard renders a meter and warns as the conversation fills. - Auto-compaction window. When the CLI summarizes the chat is a separate
number from the meter above. The CLI resolves it in precedence order:
CLAUDE_CODE_AUTO_COMPACT_WINDOWenv →settings.autoCompactWindow→ a remote client-data value → an experiment gate → a per-model default. Every branch ismin(cliModelWindow, candidate), so the setting can only ever lower the threshold. The two branches below ours are controlled remotely by Anthropic and either can cap compaction well under that ceiling, with no change on our side and no warning to the user. So the bridge pinssettings.autoCompactWindowto the window it resolved (autoCompactWindowForModel), which takes the threshold out of remote control and up to the CLI's ceiling. Unknown models are left unset: the CLI's own default beats a guess. That ceiling is not the window the meter uses, and the pin cannot raise it — see the 1M context correction below, which does. - Subagent prompt cache TTL. Outside a Claude subscription the CLI caches
every request on a 5-minute TTL. The bridge sets
settings.subagentPromptCacheTtl: "1h"so subagents, workflows and helper requests keep their cache across a wait longer than that (a render can take up to 30 minutes). An hour-long cache write costs 2× base input instead of 1.25×. The main conversation is unchanged (promptCacheTtlstays unset). - 1M context correction. The bridge sends the
context-1m-2025-08-07beta for every 1M model it routes to (betasForModel), Opus 5.5, the default, included. This is not an API requirement: Anthropic serves 1M by default on Opus 5.5, Opus 5, Sonnet 5.5 and Sonnet 5, and that beta is the legacy Sonnet 4/4.5 header — verified against the OpenRouter endpoint as identical HTTP 200s with and without it. What it corrects is the CLI's client-side idea of the window: the CLI resolves that locally and reads 1M only for a model id carrying the[1m]suffix, a request carrying this beta, or anative_1mmodel whoseANTHROPIC_BASE_URLis unset orapi.anthropic.com. The passthrough points that env var at OpenRouter, so the native path is refused and it falls back to 200k for all three models. That number never reaches Anthropic; it only drives local decisions — when to compact, what the meter shows, when theAutocompact is thrashingbreaker trips. So chats compacted at a fifth of their real capacity while the meter, dividing by the window above, read five times low and never warned. The beta lifts that ceiling, which is also what lets theautoCompactWindowpin reach the window it asks for — the two only work together._CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URLwould also work and is arguably more honest, but it additionally changes model-alias resolution, refusal-fallback behavior, and adds a dispatch header, so the beta is the narrower lever. Haiku 4.5 is excluded — genuinely a 200k model, and the CLI refuses the beta for it. - Control-channel circuit breaker. If the CLI's stdin (its permission
control channel) is closed while the process is still alive — work outliving
the turn it started in, such as a long command or a subagent whose completion
wakes the model back up after the turn's result — every subsequent
permission-gated tool call is denied and the channel never recovers. Detection
keys on the
Tool permission request failed:envelope the SDK emits for a failed permission round-trip on an error result, covering both the… Stream closedand… AbortErrorteardown tails; a normal command error never carries that envelope. The trailing colon and an explicit hook-error exclusion keep prose that merely names the failure from counting — a scaffold hook that blocks an action and explains the failure it prevents also arrives as anis_errortool_result. Rather than let the agent flail through a poisoned turn, the bridge counts these denials and, once a small threshold is crossed, ends the turn with a legible error and clears the session so the next turn spawns a fresh, healthy CLI. - Verbatim slash commands (
/publish,/compact) are forwarded to the Agent SDK exactly as typed. The SDK only recognizes a slash command when it is the leading token of the prompt, so these bypass speaker attribution, reference context, and history rehydration — otherwise the prepended lines would push the command off the front and it would land as ordinary message text./compactresumes the live session and asks the SDK to summarize older turns. - Workspace skill commands (
/audit-google-ads-account 123, any skill under.claude/skills/) are forwarded as typed too, arguments included, skipping speaker attribution, reference context and history rehydration. A skill withdisable-model-invocation: trueis reachable only this way. - Publish runs detached from the chat session.
/publishneither resumes the chat's session nor replaces it on the way out (detachedSessionon the attempt): it starts empty, every time, whatever the conversation is doing.- Why, originally. The publish skill used to pin its own model — Haiku 4.5, a genuine 200k window — while the chat ran on a 1M one. Resuming the chat's session therefore handed a conversation sized for 1M to a model with a fifth of the room. Measured on a failure in production: the last chat request was 356,707 tokens (36% of its own window, 78% over the publish model's), and the provider refused the publish pre-flight while the chat's own meter showed plenty of space. Summarizing could not rescue it either — the summary request has to send the same conversation. Detached publishes measured ~51k against the same 200k window.
- That pin is gone (the skill now inherits the session model, because it rewrites branch history and resolving a conflict badly can cost a whole Session), so the window mismatch above no longer applies. Detaching stays, on the two reasons below plus the size result, which is the same either way: a publish turn that sends ~51k instead of the conversation's 356k is cheaper and further from any limit, whatever model serves it.
- It costs nothing, because publish does not read the conversation: the skill says so outright ("Do not reference or act on prior conversation context"), and it works from the state of the workspace — scripts, git, and the files verify complains about.
- The chat's session is left intact, on success and on failure. Handing
publish's throwaway session back as the thread's boundary would make the next
message resume a transcript holding one
/publishand nothing else, and clearing the boundary on a failed publish would charge the user their conversation for a publish that did not happen.
- Publish still never summarizes as a precaution. Auto-compact fires on a
threshold, not on a real overflow, so a long chat once paid for a summary
before anything happened — the user watched "Summarizing this chat to free up
space…" instead of a publish starting. A
/publishturn runs withsettings.autoCompactEnabled: false, because it still runs a build and a verify inside its own session and that output alone can cross the threshold. If the turn's own work then overflows (isContextOverflowError— Anthropic'sprompt is too long, OpenRouter'smaximum context length), it retries once with compaction allowed, resuming the in-flight session (forked) so the steps already completed are not repeated. Gated on work having been produced (resumeSessionAt), which is the only state where a summary has anything to shrink — before that, the turn stops rather than re-sending a request the provider just refused. - The refusal itself is never shown. The CLI renders a refused request as an
assistant message before the query throws, and relayed text is never
retracted — so recovery used to leave a raw
Prompt is too longin the chat, above the publish that then succeeded. That message is dropped outright (isContextOverflowRefusal: a single text block, ≤200 chars, leading with the provider's error and matchingisContextOverflowError— soAPI Error: 400 … prompt is too longis dropped,API Error: 500and an answer that merely mentions a size limit mid-sentence are kept). The request is rejected pre-flight, so the refusal is a synthetic message with no streamed deltas before it; the drop still clears the turn's accumulated text, so if that ever changes the leftover cannot come back as the turn's end-of-turn post. Dropping it before the session observer also stops it from counting as work done, which would otherwise make the retry resume with "continue where you left off" instead of delivering the/publishthe turn still owes. A turn that cannot recover reports the failure once, in product language — the provider's wording never reaches the chat. - Compaction progress + confirmation. A compaction summarizes the session to
free context. A manual
/compactturn emits no assistant text, so it would otherwise complete with nothing visible; an automatic compaction happens mid-turn when the context fills and silently shifts what the agent can recall. Both are relayed toPOST /api/chat/compaction:state: "compacting"when the SDK emitsstatus: "compacting"(dashboard shows a spinner), thenstate: "done"on thecompact_boundary(dashboard shows a "Summarized this chat" notice). Manual and automatic compactions are surfaced identically, so users understand why answers shift; the"compacting"relay is preceded by a context usage post carrying the occupancy that triggered it, so a compaction that fired too early is visible in the data rather than only in a screenshot. Continuity is preserved because the SDK resumes the compacted session. (When there is too little to compact the SDK itself replies "Not enough messages to compact.", which surfaces normally.) - Model refusals. When the model refuses a request and no fallback model
retries it (
model_refusal_no_fallback), the turn ends as an error the user reads in product terms ("I couldn't help with that request. Try rephrasing what you need."). The CLI's own refusal frame (stop_reason: "refusal", written for a terminal user) is never relayed, and no session boundary is saved, so the next message resumes from before the refused exchange. If the main conversation carries on after the notice (a refused subagent), the turn finishes normally. The category is logged and recorded as amodel_refusalturn event. When a fallback model did retry (model_refusal_fallback), the messages the refused leg already relayed are removed from the chat throughPOST /api/chat/retract-events(eventIdsare the SDK uuids/api/chat/eventstored). No fallback model is requested today — see the comment onmodelinbridge-job.ts.
Attachment validation
When the bridge downloads attachments referenced in a chat dispatch, it sniffs each file's magic bytes before handing it to the agent:
- A file whose extension claims to be an image (
.jpg,.jpeg,.png,.gif,.webp,.avif) but whose bytes are a different image format is renamed on disk to match the real format. Anthropic infersmedia_typefrom the extension; mislabeled files causeCould not process image400s. - AVIF is accepted from the user but is not a format the vision endpoint can
read, so it is re-encoded to PNG with ImageMagick before the agent sees it,
and only the PNG is written to disk. The image is downscaled to a 1568px long
edge during the re-encode: the vision endpoint downsamples past that anyway,
and encoding at full resolution pushes an ordinary phone photo well past the
5 MB image cap. If the PNG still exceeds the cap, or the re-encode fails, the
attachment is reported as
NOT AVAILABLEasking for a JPG, PNG, or WebP instead. (ImageMagick rather than ffmpeg: the Runtime is Debian 12, whose ffmpeg 5.1 predates still-image AVIF demuxing.) - A file claiming to be an image but whose bytes are HTML, plain text, or any non-supported format is not handed to the agent — this typically means the source URL returned an error page. Better to ship the run without that one attachment than to abort everything.
- Images larger than 5 MB raw are rejected — past Anthropic's effective vision endpoint ceiling once base64-encoded.
- A file whose extension or content type claims PDF must start with
%PDF-. HTML, plain text, or other non-PDF bodies are rejected before the agent run. - PDFs larger than 24 MB raw are rejected before download/write completes. The cap stays below Anthropic's request limit after base64 and prompt overhead.
- Valid PDFs are written to the sandbox and extracted locally with
pdftotextbefore the turn. Extracted text is capped at 30,000 characters and the subprocess is bounded by a 10s timeout. When the cap is reached the subprocess is stopped because additional output would be discarded. - A video (
.mp4,.m4v,.mov,.webm,.avi,.mkv, or avideo/*content type) must open with a real container header — ISO base media (ftypand the QuickTime atoms that predate it), EBML for Matroska/WebM, orRIFF….AVI. Only the container is checked; the codecs inside are ffmpeg's problem. A file that merely claims to be a clip is reportedNOT AVAILABLElike any other mislabeled attachment, rather than reaching the agent and failing later as an opaqueffprobeerror. Nothing is re-encoded — the agent never looks at a clip, it edits one — so a valid container is written through as-is. Clips larger than 100 MB are rejected, matching the ceiling the composer and the upload action enforce, so a file accepted upstream never dies here for an unannounced reason. - A rejected attachment is never dropped silently. It stays in the prompt
block marked
NOT AVAILABLEwith a reason written in product terms (sizes in MB, no status codes or byte counts), and the agent is told to relay it rather than answer around the missing file. The diagnostic cause — HTTP status, measured bytes vs the cap, whether the content-length or the downloaded body tripped it, exception name and message — goes to the bridge log only, never into the prompt. - The recovery matches the failure. A transient download failure asks the user to send the file again; an oversized file or one whose bytes don't match its type says so and asks for a smaller or corrected version, because re-sending the same file would fail identically.
Attachment prompt block
Attachments are handed to the agent as a delimited === ATTACHED FILES (n) ===
block appended to the turn's prompt. The failure it guards against is an agent
that answers the message and never opens the file, so the block:
- states the obligation ("Open and use them before you answer") and forbids searching the workspace for files that are already downloaded — scoped to the files that actually landed, so a partly- or wholly-failed set never promises paths that aren't there;
- labels every local path with the original attachment filename from Convex, so the agent sees the same filename the user saw in chat;
- tells the agent what a video actually is for —
ffprobefor its properties, a canvas ingest node (source: "path",expect: "video") to edit or restyle it,baker videos uploadto put it in the media library,ffmpegframes when it needs to see the content — and forbidsReadon it, which returns nothing an agent can interpret; - inlines extracted PDF text, while still allowing
Readon the PDF path as a fallback for layout, images, exact pages, or when extraction failed or truncated. (Earlier versions bannedReadon PDFs outright, which left the agent with no recovery when extraction returned nothing.)
The block is appended to the turn's own prompt, so it never changes the cached prompt prefix (system prompt and prior messages) — attachments cost a cache write on the turn that carries them and nothing on later turns.
The same magic-byte rules are enforced as a pre-commit hook in
apps/scaffold-template (validate-images). Extension mismatches are
auto-fixed: the hook renames the file to match its real format, updates
all references in source files, and stages the changes. Corrupted or
oversized images still block the commit.
Session journal (agent memory)
The bridge points Claude Code's auto-memory at <repo>/.claude/memory (instea
