# B4.run — Full Reference Generated reference for coding agents. This file is the concatenation of every B4.run documentation page, task-specific prompt, and agent config template served by b4.run. For the compact summary: https://b4.run/llms.txt For source: https://github.com/cacheplane/b4run ## Brand Assets Official B4.run logos, icons, favicons, and social assets: - Asset manifest: https://b4.run/brand/assets.json - Full brand kit ZIP: https://b4.run/brand/b4-run-brand-assets.zip --- ## Documentation ### Getting Started # Getting Started Build a typed AI agent in 60 seconds. B4.run is file-system routing for agents — no registry, no hand-written tool schemas, no glue. By the end of this guide you'll have a working deep-research assistant at `/research` that plans sub-questions, searches a local corpus, and writes cited reports. Live runs use a real model and API key; the included tests and evals use deterministic fixtures and run offline. Prefer to build with a coding agent? Copy the prompt above and paste it into Claude Code, Cursor, or your agent of choice. ## 1. Install ```bash npm create b4-app@latest my-agent cd my-agent npm install ``` Requires Node.js 24 or later and npm 11. For a minimal greeter scaffold instead, pass `--template basic`: ```bash npm create b4-app@latest my-app -- --template basic ``` ## 2. What you got The scaffold is a complete deep-research assistant, generated as a two-package npm workspace: `server/` is the B4.run app, and `web/` is the B4.run Workbench — a browser client in front of it. The root `npm install` installs both. These are the important files — no registration step is required. ``` package.json # workspace root: "workspaces": ["server", "web"] server/ # the B4.run app src/ app/research/ index.ts # research coordinator agent state.ts # route state shape plan.md # seeds the thread's planning todos memory.md # route-specific persistent prompt guidance memory.ts # typed cross-session research memory subagents/ researcher/index.ts # specialist dispatched per sub-question skills/ cite-sources/SKILL.md # loaded on demand: citation rules synthesize-findings/SKILL.md # loaded on demand: report structure evals/ research-quality.eval.ts # quality eval with scorers and a gate tools/ searchCorpus.ts # shared keyword search over the corpus readDoc.ts # shared full-document reader test/ research.test.ts # main offline harness suite sandbox-docker.test.ts # optional gated Docker sandbox smoke test workspace/ AGENTS.md # persistent prompt guidance injected every turn corpus/ # bundled documents the agent searches scripts/ fetch-source.mjs # network fetch script called via runBash AGENTS.md # contributor guidance for coding agents b4.config.ts # app config: permissions, tool-output offloading .env.example # provider environment template web/ # the B4.run Workbench (Next.js + CopilotKit) app/ page.tsx # the CopilotKit provider tree and thread state components/ # thread rail, transcript, composer, activity cards api/copilotkit/route.ts # registers an AG-UI agent on the B4.run endpoint api/b4/[...path]/route.ts # allowlisted same-origin proxy to the server theme.css # the whole palette, as CSS variables ``` The route entry (`index.ts`) is a research coordinator. It recalls durable context, plans sub-questions, dispatches the `researcher` subagent for each one, and synthesizes a cited report. Shared tools under `server/src/tools/` are available to both agents. `b4 typegen` writes their generated types from the function signatures; `b4 check` validates the app without writing files. ```ts title="server/src/app/research/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", recursionLimit: 100, description: "A deep-research assistant: plans sub-questions, dispatches researchers, and writes a cited report.", systemPrompt: `You are a deep-research coordinator. Given a question: 1. Start by checking durable context with \`recall({ query: "" })\`. 2. Plan the sub-questions to investigate and record them in your todos. 3. For each sub-question, dispatch a specialist with \`task({ subagent: "researcher", input: "" })\`. 4. You may also \`searchCorpus({ query })\` and \`readDoc({ path })\` directly for quick lookups. 5. When the corpus lacks coverage, you may run \`runBash({ command: "node scripts/fetch-source.mjs " })\` — the human must approve it. 6. Synthesize the findings into a cited report and save it with \`writeFile({ path: "reports/.md", content: "" })\`. 7. When the user gives a durable preference or you verify a reusable finding, call \`remember({ data, content })\` so it can be reviewed and recalled later. Cite every claim with its source path in square brackets, e.g. [corpus/agent-architectures.md]. Keep the final answer concise.`, }) ``` ```ts title="server/src/app/research/subagents/researcher/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", description: "Researches one sub-question against the bundled corpus and returns a focused, cited answer.", systemPrompt: `You are a research specialist. Answer the single sub-question you are given using the corpus. - Use \`searchCorpus({ query })\` to find candidate documents, then \`readDoc({ path })\` to read the most relevant ones in full. - Return a focused, factual answer. Cite each claim with its source path in square brackets. - If the corpus does not cover the question, say so plainly.`, }) ``` ```ts title="server/b4.config.ts" export default { appDir: "src/app", permissions: { allow: { bash: ["ls", "cat", "head", "wc"] }, deny: { bash: ["rm -rf", "sudo", "chmod 777", "curl", "wget"] }, }, toolOutput: { offloadThresholdChars: 1500, previewLines: 10, }, memory: { writes: "candidate", }, // Persistence (SQLite checkpointer + threads store) is on by default. // Threads survive a restart. } ``` The `plan.md` file opts the route into B4.run's planning capability — its checklist items seed each thread's todos. `workspace/AGENTS.md` and route-local `memory.md` provide persistent prompt guidance; `memory.ts` defines typed cross-session records exposed through `recall` and `remember`. Skills under `skills/` are loaded on demand by name. ## 3. Verify and test Run everything from the workspace root; each root script delegates into the package that owns it. ```bash npm run typegen npm run check npm run typecheck ``` `typegen` writes `server/.b4/b4.generated.d.ts`; `check` then validates route discovery, tool definitions, and configuration without writing generated files; `typecheck` validates the TypeScript sources in both packages. The validation output looks like: ``` B4.run app is valid: 2 routes discovered. - /research (agent) - /research/subagents/researcher (agent) ``` ```bash npm test ``` Runs both packages' suites. The server half is the harness suite in `server/test/research.test.ts`: it replays fixture responses while exercising corpus search and citation, durable-memory recall and candidate approval, subagent dispatch, tool-output offloading, and the human-in-the-loop permission gate. The web half is the workbench's own Vitest unit tests over its proxy allowlist, thread source, transcript mapping, and components. No API key is needed for either. The separate `npm run test:sandbox:docker --workspace server` script enables the optional Docker sandbox smoke test. ```bash npm run eval ``` Runs the quality eval in `server/src/app/research/evals/research-quality.eval.ts`. Its dataset exercises research questions against the corpus; the scorers require corpus search, source citations, and a passing model-graded quality verdict. The inline fixtures cover both agent and judge requests, so the scaffolded eval runs without an API key. These fixture-backed tests and evals provide deterministic offline confidence; they are not a keyless product demo. ## 4. Run it live The model credentials live in the server package. Copy its environment example, add a real API key, run the preflight, and start the generated dev script: ```bash cp server/.env.example server/.env # Add a real OPENAI_API_KEY to server/.env npm run verify npm run dev:server ``` The `verify` preflight covers app integrity, type declarations, dependencies, Node, the selected provider environment, and configured infrastructure. The generated dev script serves `http://127.0.0.1:3002` and exposes Agent Protocol and AG-UI. In another terminal, create a thread, capture its id, then run the research agent on that thread: ```bash THREAD_ID=$(curl -s -X POST http://127.0.0.1:3002/threads \ -H "Content-Type: application/json" \ -d '{}' | jq -r .thread_id) curl -s -X POST http://127.0.0.1:3002/threads/$THREAD_ID/runs/wait \ -H "Content-Type: application/json" \ -d '{ "route": "/research#agent", "input": { "messages": [{ "role": "user", "content": "What are common agent architectures?" }] } }' | jq . ``` The `#agent` suffix is required — it selects the agent entry on the route. The report lands in `server/workspace/reports/` inside the project. Thread state checkpoints to `server/.b4/checkpoints.sqlite`; threads persist across dev-server restarts. ## 5. See it in a UI `curl` is not the only surface. The `web/` package is the B4.run Workbench, a chat UI over the same agent. Leave the server running and start it in a second terminal: ```bash npm run dev:web ``` Open [http://localhost:3010](http://localhost:3010) and ask a research question. The workbench has a thread rail, a streaming transcript with plan and researcher activity cards, generic tool cards, inline permission prompts, and a memory-candidate review panel. Until the server answers it shows a connect screen instead, and re-probes every few seconds. It talks to B4.run over [AG-UI](/docs/ag-ui) and holds no model credentials of its own — those stay in `server/.env`. The plan and researcher cards are not hand-built. B4.run ships them from [`@b4run/ag-ui/react`](/docs/ag-ui), and the workbench restyles them through that package's `classNames` prop before handing them to CopilotKit's `renderActivityMessages` — validation and layout stay in the package. A client that wants B4.run's default look passes the packaged `b4ActivityRenderers` array instead and is done. The [Research assistant web UI](/docs/recipes/research-web-ui) recipe walks through that wiring if you want to build your own client. The scaffold also installs `@b4run/inspector`, so a third terminal can open the [Inspector](/docs/inspector) — a browser UI over the app's live memory store, where the records the agent writes with `remember` show up for review: ```bash npx b4 inspect --cwd server ``` When you are ready to ship, compare [Deployment Options](/docs/deployment) and follow [Node and Docker](/docs/deployment/node) for the default self-hosted path. ## Where to go next --- ### Mental Model # Mental Model B4.run is a meta-framework for LangGraph. Not because LangGraph is missing anything. It runs the graph well. Not because we needed another framework. We did not. It exists because every team building agents on LangGraph ends up writing the same conventions by hand: project structure, type wiring, dev tooling, deployment scripts. We watched it happen at every company. B4.run writes those conventions once. You write the agent. ## tl;dr - A *route* is a folder. Its path is the agent endpoint. - A route's `index.ts` exports one of: `agent`, `workflow`, `graph`, `chain`. - *Tools* live in shared `src/tools/` or next to a route in its `tools/` directory. Their parameter types are inferred at build time. - *State* is the JSON payload passed through the route runtime. - Agent routes can opt into built-in behavior such as memory, planning, skills, subagents, and reasoning effort. - *Middleware* is an optional app-level request gate for B4.run's dev server and built HTTP runtimes. - LangGraph runs the graph. B4.run does everything else. ## The pieces A *route* is a folder under `src/app/`. The folder path becomes the agent endpoint at runtime — `src/app/research/` answers to `/research`. Everything a route needs lives next to it. A *route entry* is the route's default behavior. Each route has exactly one `index.ts` that exports an `agent`, a `workflow`, a `graph`, or a `chain`. Pick the shape that fits the problem; mix shapes across routes in the same project. **Two ways to drive the model.** Export an `agent` when the model should decide what to do — it picks tools at runtime and can pause for a human. Export a `workflow` when you own the order of operations — a deterministic, typed async function. Same routing, same types, same dev loop; you choose who's in charge. Drop to `graph` or `chain` for raw LangGraph anytime. See [Agents](/docs/agents) and [Routes](/docs/routes) for each shape. *Tools* are default-exported async functions in shared `src/tools/` or a route's local `tools/` directory. B4.run reads each tool's parameter type at build time and turns it into a JSON schema. Shared tools are available across routes; a route-local tool can specialize or shadow one for that route. Agents call tools by name; the model picks when. *State* is an optional typed shape declared in `state.ts` next to the route. Dynamic segments — `[tenant]`, `[...rest]` — are preserved in the route id and generated route metadata; callers pass the corresponding values in the JSON input they send to the runtime. Agent route features are discovered from files and descriptors. `workspace/AGENTS.md` supplies persistent prompt guidance on every turn; route-local `memory.ts` defines typed cross-session memory; episodic records retain run history for later recall and distillation. `plan.md` provides planning, `skills//SKILL.md` provides route-local skills, `subagents/` provides child specialists, and `reasoning` tunes OpenAI-backed agent routes. *Middleware* runs before route execution in `b4 dev` and the built Node and Hono HTTP runtimes. It lives at `src/middleware.ts` (or root `middleware.ts`) and can reject a request or attach request-scoped context for tools. ### Access control: three layers Three independent layers govern what a route can do once it's running: [tool scoping](/docs/tools) decides *which* tools the model may call, [permissions](/docs/permissions) decide *whether* a given call runs (human-in-the-loop approval), and the [execution sandbox](/docs/sandbox) decides *what* an allowed, approved call can actually touch. They compose — none substitutes for another. See [Access Control](/docs/access-control) for the combined picture. ## The runtime A typical request through B4.run looks like this: ```text POST /threads//runs/wait { "route": "/research#agent", "input": { "messages": [...] } } │ ▼ thread lookup → .b4/threads.sqlite (created on POST /threads) │ ▼ route id match → src/app/research/index.ts │ ▼ input state → { messages: [{ role: "user", content: "What are common agent architectures?" }] } │ ▼ middleware → src/middleware.ts (auth, request context, ...) │ ▼ route entry → index.ts (agent | workflow | graph | chain) │ ▼ tool calls → searchCorpus({ query: "agent architectures" }) → [{ path: "corpus/agent-architectures.md", snippet: "..." }] │ ▼ typed output → "ReAct and plan-and-execute are common. [corpus/agent-architectures.md]" ``` Each arrow is real code: 1. **Thread lookup.** The Agent Protocol endpoint resolves the thread from `.b4/threads.sqlite`. A thread is created with `POST /threads`; its id is passed in subsequent run requests. 2. **Route id match.** B4.run resolves the route id against the discovered file tree. Route groups (`(public)/`) drop from the path; the `#agent` suffix selects the agent entry on the route. 3. **Input state.** The JSON input is passed to the route. Agent routes may apply defaults from `state.ts`; workflow, graph, and chain routes receive the runtime input for their adapter. 4. **Middleware.** The app-level middleware runs when present. It can short-circuit the request or pass through with request-scoped context for tools. 5. **Route entry.** The exported `agent`, `workflow`, `graph`, or `chain` runs. Agent routes receive materialized tools, and `workflow(state, ctx)` receives the typed `RuntimeContext`; raw graph and chain routes keep their native invocation and imported dependencies. 6. **Tool calls.** Tools called by name are typechecked against the schemas B4.run generated at build time. The `searchCorpus` tool searches the bundled corpus; `readDoc` reads a document in full; the model picks when to call each. 7. **Typed output.** The route returns a typed value. B4.run streams or returns it depending on the call (`runs/wait` vs `runs/stream` on the thread). Runs execute on a thread. Between turns, B4.run checkpoints the LangGraph state to SQLite under `.b4/checkpoints.sqlite`. **Durable by default.** Every B4.run app ships a working checkpointer and thread store — no setup. Threads survive a `b4 dev` restart, and an `agent` route that pauses for human input resumes exactly where it left off. LangGraph defines the checkpoint interface; B4.run ships the default implementation (`@b4run/sqlite-storage`), so durability is the path of least resistance — not a wiring task. ## Build vs runtime B4.run does most of its work at *build time*. `b4 build` and `b4 typegen` walk your routes, infer tool parameter types from function signatures, and emit `.b4/b4.generated.d.ts`. That generated file is the route registry, the tool registry, and the typed `RouteTools

` map. After build, your editor can autocomplete `ctx.tools.searchCorpus` and complain when a tool's signature drifts from its caller. At *runtime*, `b4 run`, `b4 dev`, and built B4.run HTTP entries execute routes. There is no manual `tools: [...]` array and no per-route registration for agent and workflow routes. The build-time types and runtime dispatcher are wired up by B4.run — you write the agent. ## Where B4.run ends and LangGraph begins The boundary is sharp. B4.run does not replace LangGraph; it packages LangGraph applications for several hosts. | Component | Role | |-----------|------| | B4.run | File-system routing, tool and state typegen, the HMR dev server, CLI commands, generated entries, and the default SQLite stores. | | LangGraph | `StateGraph`, channels, graph execution, and the checkpoint interface. | | Node target | B4.run's full Agent Protocol and AG-UI HTTP server, including Node filesystem-backed capabilities. | | Hono target | B4.run's fetch-based HTTP runtime for edge hosts; Node-only filesystem features are gated. | | LangSmith target | Generated graph entries and `langgraph.json` for LangSmith to deploy; LangSmith provides hosting and observability. | On persistence: LangGraph defines the checkpoint interface; B4.run ships the default implementation — `@b4run/sqlite-storage` — which backs both the checkpointer (`.b4/checkpoints.sqlite`) and the threads store (`.b4/threads.sqlite`). You can swap it for your own implementation if needed. On LangSmith: LangSmith is the deployment and observability platform. It is not part of LangGraph itself. B4.run generates the artifacts LangSmith consumes (`langgraph.json`, per-route entry files); you bring your own LangSmith project and tracing setup. **B4.run deletes the boilerplate. Not your stack.** You bring your own LangGraph workflows, your own LCEL chains, your own model providers, your own LangSmith tracing. B4.run does not change any of those. It puts conventions around them so the rest of your codebase stops being plumbing. ## What to read next Pick the path that fits where you are. 1. **I want to build something now.** → [Getting Started](/docs/getting-started) 2. **I want to understand a piece in depth.** → [Routes](/docs/routes), [Agents](/docs/agents), [Tools](/docs/tools), [State](/docs/state), [Memory](/docs/memory), [Planning](/docs/planning), [Skills](/docs/skills), [Subagents](/docs/subagents), [Reasoning Effort](/docs/reasoning-effort), [Middleware](/docs/middleware), [Retry](/docs/retry) 3. **I want to see the surface.** → [CLI](/docs/cli), [Dev Server](/docs/dev-server), [Deployment](/docs/deployment) ## Related --- ### Migrating from LangGraph # Migrating from LangGraph This page is for teams with a working LangGraph project who want to know what a B4.run conversion actually costs. B4.run does not replace LangGraph. A graph's nodes, edges, imported tools, and state definitions can stay in place, while the deployment and invocation boundary still needs validation. What changes is the code around the graph: project layout and deploy config. B4.run's co-located tool convention applies to agent and workflow routes, while sibling `state.ts` is agent-only; a raw graph keeps its own definitions. The migration is mostly *moving code*, not rewriting it. ## tl;dr - Your `StateGraph` nodes and edges can stay. Export the compiled object as a named `graph` route. - Your graph can keep its imported tools; B4.run agent and workflow tools can use co-located TypeScript files instead. - Your raw graph keeps its existing channel and state definitions. Dynamic segment values still come from the caller's JSON state. - Your `langgraph.json` is replaced by `b4 build`. The output is still a `langgraph.json`. - Model providers and LangChain packages can stay; validate checkpointer configuration at each target boundary. - LangSmith consumes the build output generated by `b4 build`. ## The shape of the move Before — a typical LangGraph TypeScript project: ```text my-agents/ ├── langgraph.json ├── package.json ├── tsconfig.json └── src/ ├── graphs/ │ ├── support.ts │ └── triage.ts ├── tools/ │ ├── lookupOrder.ts │ └── escalate.ts └── state.ts ``` After — the same project under B4.run: ```text my-agents/ ├── .b4/b4.generated.d.ts ├── b4.config.ts ├── package.json ├── tsconfig.json └── src/ ├── app/ │ ├── support/ │ │ └── index.ts │ └── triage/ │ └── index.ts ├── graphs/ │ ├── support.ts │ └── triage.ts ├── tools/ │ ├── lookupOrder.ts │ └── escalate.ts └── state.ts ``` Flat directories named by kind become folder routes named by endpoint. Tools you move into B4.run's tool convention can live next to a route; an imported graph keeps its existing tool imports. The route registry — what graph answers which path — is read from the file tree, not maintained by hand. ## Construct by construct ### StateGraph → route The graph's nodes and edges do not need to change. Its hand-maintained `assistant_id` registration is replaced by a small route module that re-exports the compiled graph. Before: ```ts title="src/graphs/support.ts" import { StateGraph, START, END } from "@langchain/langgraph" import type { SupportState } from "../state.js" import { lookupOrder } from "../tools/lookupOrder.js" import { escalate } from "../tools/escalate.js" export const support = new StateGraph({ channels: { messages: { reducer: (a, b) => [...a, ...b], default: () => [] }, orderId: null, }, }) .addNode("lookup", async (state) => { const result = await lookupOrder.invoke({ orderId: state.orderId }) return { messages: [result] } }) .addNode("escalate", async (state) => { await escalate.invoke({ reason: "no order" }) return state }) .addEdge(START, "lookup") .addEdge("lookup", END) .compile() ``` After — the route re-exports the real compiled graph without changing its nodes, edges, or imported tool calls: ```ts title="src/app/support/index.ts" export { support as graph } from "../../graphs/support.js" ``` The route re-exports the graph object you authored. The folder path `src/app/support/` becomes the endpoint `/support`; only its exported name changes to B4.run's `graph` route convention. The generated `assistant_id` is `/support#graph`. If the graph is the only thing you want to migrate, this completes the route entry. Tools and state can stay imported from their old locations; validate the runtime boundary described next before cutover. The B4.run local HTTP runtime does not translate its Agent Protocol thread id into a precompiled raw graph's `configurable.thread_id`. If that graph's checkpointer requires the configurable id, add an explicit target-boundary wrapper or configuration adaptation and validate that target boundary before cutover. Do not assume a checkpointer that worked behind another server receives the same invocation config from B4.run. ### Raw graph state stays with the graph A raw `graph` route does not use a sibling `state.ts`. Keep the graph's existing TypeScript state type, channels, reducers, and defaults with the compiled graph; callers send the JSON state that graph already expects. Dynamic folders still describe a parameterized route id rather than injecting values. For `/support/[tenant]`, the caller includes `{ "tenant": "acme", ... }` in the JSON state, with the field name aligned to the segment. Sibling `state.ts`, its defaults, and `reducers/` are B4.run agent-route features; adopt them only when deliberately converting the graph to an `agent` route. See [State](/docs/state) for that path. ### LangChain tools → B4.run agent and workflow tools When converting behavior to a B4.run agent or workflow route, tools become default-exported async functions in the route's `tools/` directory. Type inference at build time replaces every hand-written schema. A raw graph may instead keep its existing LangChain tools and imports unchanged. Before: ```ts title="src/tools/lookupOrder.ts" import { tool } from "@langchain/core/tools" import { z } from "zod" export const lookupOrder = tool( async ({ orderId }: { orderId: string }) => { const res = await fetch(`https://api.example.com/orders/${orderId}`) return await res.json() }, { name: "lookupOrder", description: "Look up an order by id.", schema: z.object({ orderId: z.string() }), }, ) ``` After: ```ts title="src/app/support/tools/lookupOrder.ts" export default async ( input: { readonly orderId: string }, ctx: { signal: AbortSignal }, ) => { const res = await fetch(`https://api.example.com/orders/${input.orderId}`, { signal: ctx.signal, }) return (await res.json()) as { readonly status: string } } ``` The file basename is the tool name. The input type is read from the parameter annotation. The output type is read from the return type. `b4 typegen` writes both into `.b4/b4.generated.d.ts`; `b4 build` uses the generated tool schemas when materializing deployment entries. Inside an `agent` route, the LLM picks when to invoke. A `workflow(state, ctx)` receives `RuntimeContext`, so it can call `ctx.tools.lookupOrder({ orderId })` with full IntelliSense. A raw `graph` route does not receive `ctx.tools`; an existing graph keeps calling the tools it imports. ### Conditional edges and routing → middleware + dispatch Two different mechanisms in LangGraph become two different mechanisms in B4.run. Don't conflate them. *Graph-level conditional edges stay where they are.* `addConditionalEdges` is a runtime concern of the graph. B4.run does not touch it. ```ts title="inside a graph route — unchanged" .addConditionalEdges("triage", (state) => { if (state.priority === "p0") return "escalate" return "respond" }) ``` *Request-level branching* — auth, tenant gating, routing requests between assistants — moves to `middleware.ts`. The middleware decides whether the request runs at all and what context flows into tools. Before — branching inside the graph entry point: ```ts title="src/server.ts (sketch)" app.post("/runs/wait", async (req, res) => { if (!req.headers["x-api-key"]) return res.status(401).end() const which = req.body.tenant === "internal" ? internalGraph : publicGraph const result = await which.invoke(req.body.input) res.json(result) }) ``` After: ```ts title="src/middleware.ts" import { allow, defineMiddleware, reject } from "@b4run/sdk" export default defineMiddleware(async (req) => { if (!req.headers["x-api-key"]) return reject(401, { error: "Missing x-api-key" }) return allow({ tenant: req.params.tenant ?? "public" }) }) ``` Routing between assistants is a route concern: `/support/internal` and `/support/public` are two routes, each with its own graph. The route id is the dispatch. ### `langgraph.json` → `b4 build` The hand-maintained config becomes a build output. Before: ```json title="langgraph.json" { "dependencies": ["."], "graphs": { "support": "./src/graphs/support.ts:support", "triage": "./src/graphs/triage.ts:triage" }, "env": ".env" } ``` After — there is no source `langgraph.json`. There is a `b4.config.ts`: ```ts title="b4.config.ts" export default { appDir: "src/app", } ``` `b4 build` walks `src/app/`, runs typegen, and writes `.b4/build/langgraph.json` plus per-route entry files. Every route's `assistant_id` is `#` — `/support#graph`, `/support/[tenant]#agent`. That `.b4/build/` directory is what LangSmith deploys. `.b4/b4.generated.d.ts` is the type side of the same step: the route registry, the tool registry, the typed `RouteTools

` map. The starter template ignores `.b4/`, so regenerate it during development and CI unless your project chooses to commit generated artifacts. ## What can stay, with boundary validation - **LangSmith.** Tracing, evaluations, datasets — B4.run does not wrap or proxy. Set `LANGSMITH_API_KEY` and traces flow; `b4 dev` auto-sets `LANGCHAIN_TRACING_V2=true` when that key is present. - **Checkpointer and persistence.** The checkpointer attached with `.compile({ checkpointer })` remains attached to the raw graph. Its required invocation config does not appear automatically: B4.run's local Agent Protocol thread id is not translated into `configurable.thread_id`, so adapt and test any graph that relies on that value. - **Model providers.** Raw `graph` and `chain` routes keep whatever LangChain-compatible providers you instantiate yourself. The built-in `agent()` route materializes to a LangChain chat model; B4.run infers providers for known model families and lazy-loads the matching LangChain integration package. Set `provider` explicitly to one of the supported built-in provider ids for aliases, ambiguous model names, local models, or provider-router model ids. - **LangChain ecosystem packages.** `@langchain/core`, `@langchain/openai`, retrievers, document loaders — every one works inside a route. - **LangSmith deploy.** Same target. `b4 build` emits the generated `langgraph.json` and entry files LangSmith consumes. ## Migration order The conversion is incremental. Don't try to land it in one branch. 1. **Scaffold a B4.run project alongside the existing one.** Run `pnpm create b4-app my-agents-b4` and let it generate the scaffold. Don't merge the two repos yet. The existing project keeps shipping; the B4.run project is where the new shape lives. 2. **Move one graph at a time, route by route.** Pick the lowest-risk graph first. Create `src/app//index.ts` and named-export the existing `StateGraph` as `graph` — tools, state, and prompts keep their old import paths. Once it deploys and runs at parity in staging, repeat with the next graph. 3. **Cut over deployment last.** Both projects can deploy to LangSmith side by side under different `assistant_id`s. When every graph has a B4.run equivalent at parity, switch the production `assistant_id`s to the B4.run-built ones and retire the old project. ## What to read next 1. **I want to scaffold a B4.run project now.** → [Getting Started](/docs/getting-started) 2. **I want the boundary in one page.** → [Mental Model](/docs/mental-model) 3. **I want construct-level depth.** → [Routes](/docs/routes), [Agents](/docs/agents), [Tools](/docs/tools), [State](/docs/state), [Middleware](/docs/middleware) ## Related --- ### Routes # Routes A route is a folder under `src/app/`. Its path becomes the agent endpoint. A route's `index.ts` exports exactly one entry shape: `agent`, `workflow`, `graph`, or `chain`. ## Route entry Every route has an `index.ts` that exports one of: - **`agent`** — an LLM-driven flow with auto-discovered tools. The default scaffold export. See [Agents](/docs/agents). - **`workflow`** — a deterministic async function with typed state. See [Routes / workflow](#workflow) below. - **`graph`** — a LangGraph graph. Branching, looping, conditional edges. - **`chain`** — a LangChain LCEL `Runnable`. Simple linear pipelines. ## Pathname rules Routes follow the same conventions as the Next.js App Router: - `(group)/` — route group, excluded from the path - `[segment]/` — dynamic segment, preserved in the route id and exposed in generated route params - `[...rest]/` — catch-all - `[[...optional]]/` — optional catch-all Example tree: ``` src/app/ (public)/ hello/ [tenant]/ index.ts ← exports agent | workflow | graph | chain state.ts ← optional, route state schema tools/ greet.ts ← auto-discovered for agents and RuntimeContext ``` The `(public)/` segment is excluded from the path, so this route is `/hello/[tenant]`. ## workflow A `workflow` is a deterministic async function. The first argument is the typed route state; the second is a `RuntimeContext` that exposes the route's discovered tools. ```ts title="src/app/(public)/hello/[tenant]/index.ts" import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" import type { z } from "zod" import type state from "./state.js" type HelloState = z.infer export async function workflow( state: HelloState, ctx: RuntimeContext>, ) { const r = await ctx.tools.greet({ tenant: state.tenant }) return { ...state, greeting: r.greeting } } ``` The `RouteTools<"/hello/[tenant]">` generic resolves to the union of tools auto-discovered in this route's `tools/` directory. ## graph Export a compiled LangGraph graph as a named `graph` export: ```ts title="src/app/(public)/hello/[tenant]/index.ts" import { StateGraph, START, END } from "@langchain/langgraph" import type { HelloState } from "./state.js" export const graph = new StateGraph({ channels: { /* ... */ } }) .addNode("greet", async (state) => state) .addEdge(START, "greet") .addEdge("greet", END) .compile() ``` The graph receives the same typed state and runs through B4.run's runtime. State channels still need to be declared (LangGraph requirement). ## chain Export a LangChain LCEL `Runnable` as a named `chain` export: ```ts title="src/app/(public)/hello/[tenant]/index.ts" import { RunnableSequence } from "@langchain/core/runnables" export const chain = RunnableSequence.from([ // chain steps ]) ``` ## Running a route Routes dispatch by path: ```bash $ echo '{"tenant":"acme"}' | b4 run '/hello/[tenant]' $ b4 dev # HMR + dev server ``` Programmatic dispatch is also available — see [Agent Protocol](/docs/dev-server/agent-protocol) for the runtime API and the `runs/wait` / `runs/stream` endpoints. ## Related " }, { href: "/docs/middleware", title: "Middleware", subtitle: "auth, logging, request gates" }, { href: "/docs/retry", title: "Retry", subtitle: "retry strategy for agent model calls" }, { href: "/docs/recipes/dispatch-from-route", title: "Dispatch from a Route", subtitle: "recipe calling one route from another over /runs/wait" }, ]} /> --- ### Agents # Agents An agent is the default scaffolded route in B4.run — an LLM-driven workflow that picks tools at runtime. It's the path we recommend when you want the model to decide what to do; for deterministic flows, prefer a `workflow`, `graph`, or `chain`. ## A minimal agent A route's `index.ts` exports an agent created by `agent({ model, systemPrompt })`. Tools in the sibling `tools/` directory are auto-discovered and wired into the generated graph; the LLM picks when to call them. ```ts title="src/app/(public)/research/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You are a research assistant. Answer questions thoroughly and cite your sources.", }) ``` ```ts title="src/app/(public)/research/tools/search.ts" export default async (input: { readonly query: string }) => { return { results: [`Result for: ${input.query}`] } } ``` Tools live next to the agent — `index.ts` is the agent entry, and any TS file in `tools/` is discovered for it. Param types are inferred from the function signature at `b4 build` time and made available to the generated graph. ## Model providers The built-in `agent()` route materializes to a LangChain chat model. B4.run infers providers for known model families and lazy-loads the matching LangChain integration package. Supported built-in provider ids are `openai`, `anthropic`, `google`, `mistral`, `groq`, `ollama`, `xai`, and `openrouter`; see [`ModelProviderId`](/docs/api/sdk#b4runsdk-1). Raw `graph` and `chain` routes can still instantiate any provider directly. Set `provider` to one of the supported built-in provider ids when the model name is an alias, ambiguous, local, or routed through a provider gateway: ```ts title="src/app/(public)/research/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "llama3.1", provider: "ollama", systemPrompt: "You are a helpful assistant.", }) ``` B4.run includes the OpenAI integration for the default path. Install other LangChain provider packages as your app needs them: ```bash pnpm add @langchain/anthropic # anthropic pnpm add @langchain/google-genai # google pnpm add @langchain/mistralai # mistral pnpm add @langchain/groq # groq pnpm add @langchain/ollama # ollama pnpm add @langchain/xai # xai pnpm add @langchain/openrouter # openrouter ``` `b4 check` (and `b4 verify`) warn when a model id isn't in B4.run's curated list for the resolved provider (`openai`, `google`, `anthropic`, `xai`), with did-you-mean suggestions; the runtime prints the same advisory once when the model is constructed. The lists are advisory — new, proxy, or gateway model ids run fine if your provider accepts them, and providers without curated lists (`mistral`, `groq`, `ollama`, `openrouter`) are never warned about. ## When to pick an agent B4.run supports four route entry shapes. Pick the one that fits the problem: - **Agent** — LLM-driven, model picks tools at runtime. Default for conversational and discovery-style routes. - **Workflow** — deterministic async function with a typed state. Pick when you control the order of operations. - **Graph** — full LangGraph DSL with branching, looping, and conditional edges. Pick when the flow has structure the model shouldn't decide. - **Chain** — LangChain LCEL `Runnable`. Pick for simple linear pipelines. A route's `index.ts` exports exactly one of these. You can mix shapes across routes inside the same project. ## Tool auto-binding Any TypeScript file in a route's `tools/` directory is discovered for the agent. No manual `tools: [...]` config — B4.run wires them into the generated graph at build time. ```ts title="src/app/(public)/research/tools/search.ts" export default async (input: { readonly query: string }) => { return { results: [`Result for: ${input.query}`] } } ``` The exported parameter type is read by B4.run's compiler integration and turned into a JSON schema for the LLM. The agent calls `tools.search({ query })` at runtime; the model decides when. See [Tools](/docs/tools) for the full input/output rules and the generated declarations. ## Retry Agent calls accept a `retry` config for transient model/provider execution failures: ```ts title="src/app/.../index.ts" export default agent({ model: "gpt-5-mini", systemPrompt: "...", retry: { maxAttempts: 3, baseDelay: 250 }, }) ``` See [Retry](/docs/retry) for the backoff strategy, what is retried, and the streaming caveat. ## Built-in agent features Agent routes can opt into higher-level behavior from files and descriptor fields. - [Memory](/docs/memory) loads `workspace/AGENTS.md` into the prompt. - [Planning](/docs/planning) adds `plan.md`, `writeTodos`, `todos`, and `plan_update`. - [Skills](/docs/skills) adds `skills//SKILL.md` and `readSkill`. - [Subagents](/docs/subagents) adds child routes and `task`. - [Reasoning Effort](/docs/reasoning-effort) maps `reasoning.effort` to OpenAI-backed `agent()` routes. - [Workspace](/docs/workspace) activates workspace tools (`listDir`, `readFile`, `writeFile`, `runBash`) when a `workspace/` directory exists. ## Streaming The local dev server exposes streaming via `runs/stream`. See [Agent Protocol](/docs/dev-server/agent-protocol) for the protocol details. ## Related --- ### Tools # Tools Tools are the units of work a route's entry can invoke. They live in a `tools/` subdirectory inside a route, are discovered automatically, and have their **input and output types inferred from TypeScript source** — no Zod schemas required for tools. (Route state in `state.ts` does use Zod; see [State](/docs/state).) ## A minimal tool ```ts title="src/app/(public)/hello/[tenant]/tools/greet.ts" export default async (input: { readonly tenant: string }) => { return { greeting: `Hello, ${input.tenant}!` } } ``` That's it. B4.run extracts the input and output types at build time using the TypeScript compiler API and writes them into `.b4/b4.generated.d.ts`. The tool becomes available as `ctx.tools.greet` inside a `workflow` or a callable `graph` function that explicitly receives B4.run's `RuntimeContext`, and is wired into generated `agent` deployment entries by `b4 build`. A precompiled raw LangGraph object has a different invocation boundary: its `.invoke()` treats the second argument as LangGraph `RunnableConfig`, not B4.run's typed `RuntimeContext`, so it keeps the tools its implementation already owns or imports instead of expecting B4.run's typed `ctx.tools`. Every `.ts` file in a route's `tools/` directory is automatically discovered. Drop a file in, save, and the type appears in `ctx.tools` on the next `b4 typegen` or `b4 dev` reload. ## Shared tools Tools resolve from two locations. Each route gets its own `tools/` directory for tools that belong to that route, and there's a shared `src/tools/` directory for tools reused across routes: ```text src/ ├── tools/ ← shared across every route │ ├── lookupOrder.ts │ └── escalate.ts └── app/ └── support/ ├── index.ts └── tools/ ← route-local to /support └── escalate.ts ``` Both sets are merged for agent-route materialization and for the typed `ctx.tools` given to workflow or callable-graph functions. When the same tool name exists in both, **the route-local tool shadows the shared one** — in the tree above, `/support` sees its own `escalate` and the shared `lookupOrder`. Put a tool in `src/tools/` when more than one route needs it; keep it in the route's `tools/` when it's specific to that route or you want to override a shared one. The same discovery rules apply when the route is a subagent: | Tool source | Top-level agent route | Subagent route | |---|---|---| | Shared authored `src/tools/*` | Available by default | Available by default | | The route's own local `tools/*` | Available by default | Available by default | | A parent route's local `tools/*` | Not applicable | Not inherited | | Active capability tools | Available by default | Withheld unless named in `tools.allow` | ## Scoping a route's tools By default a top route's agent sees every public tool available to it — its own `tools/*.ts`, the shared `src/tools/`, and capability-contributed tools such as `writeFile`, `runBash`, `writeTodos`, `readSkill`, and `remember`/`recall`. You can narrow that surface per route by passing `tools: { allow, deny }` to `agent()`: - **`deny`** revokes a tool — it is never offered to the model. - **`allow`** grants a withheld capability tool back into the set. - **`deny` wins** when a name appears in both. - Omitting `tools` entirely keeps the route's default set shown above. ```ts title="src/app/research/index.ts" // src/app/research/index.ts — top route: everything except shell export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } }) ``` Scope is enforced at composition time: a withheld tool is never wired into the generated entry, so the model cannot call it. `b4 check` validates the names you reference — an unknown tool name (absent from the route's available set) is a build-time error, so typos fail loud. ### Subagents and capability tools A subagent keeps its authored tools: shared `src/tools/*` plus its own route-local `tools/*.ts`. It does not inherit the parent route's local tools. Capability tools such as `writeFile`, `runBash`, `writeTodos`, and `remember`/`recall` are withheld unless you name them in `allow`. Because shared authored tools are available to children by default, explicitly deny any sensitive one a child should not receive: ```ts title="src/app/research/subagents/researcher/index.ts" // deployProd is authored in src/tools/, so deny it explicitly for this child. export default agent({ model: "gpt-5-mini", systemPrompt: "…", tools: { allow: ["readFile"], deny: ["deployProd"] }, }) ``` ### The internal task mechanism `task` is the model-facing mechanism B4.run contributes when a route has at least one dispatchable child. It is internal and is not part of public tool scoping. A nested subagent with its own dispatchable child receives `task` independently, without adding it to `tools.allow`. Do not reference `task` in `tools.allow`, `tools.deny`, `tools.approve`, or `tools.constrain`. Every form is invalid and reports `B4_E1004` during `b4 check` and route preparation. Parent-owned [`delegation`](/docs/subagents#delegation-policy) is the sole policy boundary for dispatch. Tool scoping controls which tools the model can *call* — it does not constrain what an allowed tool may *do* once invoked. A granted `writeFile` can still write anywhere its implementation permits. This is a capability boundary on the tool surface, not a sandbox. ### Requiring approval per call `approve` is the third `tools` knob, alongside `allow` and `deny`. Any tool named in `approve` — an authored route tool or a capability tool — requires a human-in-the-loop prompt before each call: ```ts title="src/app/ops/index.ts" export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"], approve: ["deployProd", "sendEmail"] }, }) ``` The prompt shows the call's arguments (a display-only JSON preview), but the decision itself is name-level: - **Once** — this call runs; the next call to the same tool prompts again. - **Always** — adds the exact tool name to the configured permissions store, so future calls proceed without prompting. The default Node store is `.b4/permissions.json`; custom stores, including Postgres, may persist it elsewhere. - **Deny** — the call is blocked; the model receives the denial reason as the tool result. See [Permissions](/docs/permissions#per-tool-approval) for the full interrupt payload and resume flow. `runBash`, `readFile`, `writeFile`, and `listDir` already have their own pattern-aware allow/deny gates (see [Permissions](/docs/permissions)). Adding them to `approve` is redundant and would double-prompt — `b4 check` warns if you do. ### Constraining arguments `constrain` is the fourth scoping knob: a predicate per tool, run at call time against the model's arguments. It returns `true` (allow), a string (deny — returned to the model as the tool result), or `{ approve: true }` (escalate to the [approval prompt](/docs/permissions#per-tool-approval)). ```ts title="src/app/ops/index.ts" export default agent({ model: "gpt-5", systemPrompt: "…", tools: { constrain: { deployProd: (args, ctx) => { const { env } = args as { env?: string } // args is typed `unknown` if (env === "prod") return { approve: true } // human-in-the-loop if (env === "staging") return true // allow return `Unknown environment "${env}".` // deny, model sees this }, }, }, }) ``` The predicate receives the parsed `args` and a read-only `ctx` (`{ toolName, routeId, threadId?, signal, params? }`); it may be async. A predicate that throws — or returns anything other than the three shapes above — **fails closed** (the call is denied). Predicate bodies are not statically validated; `b4 check` validates only the tool names. Don't list a tool in both `approve` and `constrain`: `constrain` wins (it can escalate via `{ approve }`) and `b4 check` warns. ## The runtime signature The full runtime signature accepted by tool discovery is `(input, ctx) => ...`, where `ctx` is `B4ToolContext` from `@b4run/sdk` — carrying `signal`, `middleware?`, and `fs`: ```ts title="src/app/(public)/hello/[tenant]/tools/greet.ts" import type { B4ToolContext } from "@b4run/sdk" export default async ( input: { readonly tenant: string }, ctx: B4ToolContext, ) => { // Cooperate with cancellation — the AbortSignal is always present. const res = await fetch(`https://example.com/hello?tenant=${input.tenant}`, { signal: ctx.signal, }) // ctx.middleware is the readonly bag populated by allow({ ... }) in src/middleware.ts. // ctx.fs is a sandboxed WorkspaceFs handle for reading and writing workspace files. const notes = await ctx.fs.readFile("notes.txt").catch(() => "") return { greeting: await res.text(), notes } } ``` The second parameter is optional but recommended for any tool that does network I/O, holds long-running work, needs middleware-derived context, or reads/writes workspace files. See [Middleware](/docs/middleware) for how `defineMiddleware` and `allow(context)` populate `ctx.middleware`, and [Workspace Filesystem](/docs/workspace) for `ctx.fs`. ## Tool descriptions The description the LLM sees for an `agent`-route tool comes from a JSDoc comment directly above the default export: ```ts title="src/app/(public)/hello/[tenant]/tools/greet.ts" /** Greet the tenant organization by name. */ export default async (input: { readonly tenant: string }) => { return { greeting: `Hello, ${input.tenant}!` } } ``` `b4 typegen` extracts the comment into the route's `tools.json` manifest. To set it programmatically instead, export a string constant alongside the default export — an explicit `export const description` takes priority over the JSDoc comment: ```ts title="src/app/(public)/hello/[tenant]/tools/greet.ts" export const description = "Greet the tenant organization by name." export default async (input: { readonly tenant: string }) => { return { greeting: `Hello, ${input.tenant}!` } } ``` Tools without a description still work, but the LLM only sees the tool name — write the one-liner. ## Invoking a tool Inside a `workflow`, tools are invoked through `ctx.tools.(...)`: ```ts title="src/app/(public)/hello/[tenant]/index.ts" // workflow form import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" import state from "./state.js" export async function workflow( input: unknown, ctx: RuntimeContext>, ) { const parsed = state.parse(input) const result = await ctx.tools.greet({ tenant: parsed.tenant }) return { ...parsed, greeting: result.greeting } } ``` `ctx.tools.greet` has full IntelliSense — input shape, return shape, everything. A callable `graph` function may explicitly accept the same B4.run `RuntimeContext` and use `ctx.tools`. A precompiled raw LangGraph object is different: its `.invoke()` treats the second argument as LangGraph `RunnableConfig`, not B4.run's typed `RuntimeContext`. That graph keeps the tools its implementation already owns or imports. Inside an `agent` route, you do not call tools yourself. `b4 build` wires the route's tools into the generated LangGraph entry and the LLM invokes them as needed. See [Routes](/docs/routes) for both forms side by side. ## Input and output rules B4.run's compiler pass extracts serializable input shapes written inline, declared as local aliases or interfaces, or imported from another module. Keep the root input object-shaped so B4.run can emit the model-facing tool schema. ```ts export default async (input: { readonly tenant: string; readonly limit?: number }) => { ... } ``` `readonly` is preserved through type generation. Use it on every field — tool inputs are pure data, never mutated. B4.run serializes through the runtime boundary. Classes, Dates, Maps, functions — none of them survive. Use primitives, plain objects, and arrays. Output types may be inferred or annotated inline, declared as local aliases or interfaces, or imported from another module. Serializable primitives, arrays, and plain objects are supported; avoid the non-serializable values listed above. ## The generated declarations `b4 typegen` writes two artifacts: - **`.b4/b4.generated.d.ts`** — the ambient type module that backs `import type { RouteTools } from "b4:routes"`. The emitted shape pivots over a `RouteTools

` lookup keyed by each discovered route pathname; `b4 typegen` populates the entries with concrete tool signatures inferred from source. - **`.b4/routes//tools.json`** — per-route tool-schema manifests consumed by `b4 build` when emitting LangGraph entries. To inspect the exact shape B4.run emits in your app, run `b4 typegen` and open `.b4/b4.generated.d.ts`. `.b4/b4.generated.d.ts` and the per-route `tools.json` files under `.b4/routes/` are regenerated on every `b4 typegen` and by the dev server on save. Your edits will be wiped. Regenerate manually if needed: ``` b4 typegen ``` ## Common patterns - **External API wrappers** — one tool per endpoint. Input shape mirrors the endpoint's parameters; output is the parsed response. - **Database reads** — input includes the filters; output is the record(s). Keep queries cheap — tools are meant to be fast. - **LLM calls** — input is the prompt variables; output is the parsed model response. Defer orchestration to the route entry. - **Pure transformations** — no side effects, just shape-to-shape mapping. B4.run's testing makes these trivial to verify. - **Wrapping an existing LangChain tool** — instantiate the community tool at module scope, then expose it through a plain function with a typed input. B4.run tools are plain functions (not `tool()` wrappers) so the input/output types can be inferred from the signature: ```ts title="src/app/(public)/research/tools/search.ts" import { TavilySearch } from "@langchain/tavily" const tavily = new TavilySearch({ maxResults: 5 }) /** Search the web for current information. */ export default async (input: { readonly query: string }) => { const results = await tavily.invoke({ query: input.query }) return { results: String(results) } } ``` This example wraps the community tool's loosely typed output in a plain object to give it a concrete, serializable return shape. Outputs that already have a serializable primitive, array, or object type do not need that wrapper. ## Related --- ### State # State Routes may declare a state contract in a colocated `state.ts` file. State is the JSON payload passed to the route entry and returned by the route runtime. ## The shape When present, `state.ts` default-exports a Zod schema or Standard Schema value. Agent routes discover and apply state defaults from it, and you can derive the TypeScript type via `z.infer`: ```ts title="src/app/(public)/hello/[tenant]/state.ts" import { z } from "zod" export default z.object({ tenant: z.string().default(""), /** Accumulated context from tool call results */ context: z.string().default(""), }) ``` ```ts title="src/app/(public)/hello/[tenant]/index.ts" // workflow form import type { RuntimeContext } from "@b4run/sdk" import type { z } from "zod" import state from "./state.js" type HelloState = z.infer export async function workflow(input: unknown, ctx: RuntimeContext): Promise { const parsed = state.parse(input) ctx.signal.throwIfAborted() return { ...parsed, context: parsed.context } } ``` Agent-state discovery extracts defaults by validating `{}`. A schema that rejects `{}` is skipped, so its agent state defaults and generated types are not produced. Make every top-level field accept missing input, usually with `.default(...)`. Agent routes apply defaults from a successfully discovered schema automatically. Plain workflows must parse their unknown input explicitly, as above; their schema defaults appear only after `state.parse(input)` succeeds. State discovery accepts a Zod-compatible schema or any value that implements [Standard Schema](https://standardschema.dev/). It calls `~standard.validate({})` when that slot is present, otherwise `.parse({})`, and requires a successful object value. The scaffold uses Zod. ## Dynamic segments are route params If your route's directory contains a dynamic segment like `[tenant]`, B4.run records that segment in generated route metadata. Today, route execution is input-driven: pass the value in the JSON input you send to `b4 run`, `runs/wait`, or `runs/stream`. ``` src/app/(public)/hello/[tenant]/ → route id /hello/[tenant] ``` For example: ```bash echo '{"tenant":"acme"}' | b4 run '/hello/[tenant]' ``` The schema covers state your entry reads or the caller supplies. Do not rely on a concrete pathname with an inline tenant value to populate `tenant`; the current resolver matches the parameterized route id (`/hello/[tenant]`) or the route entry file path. When you include a dynamic segment value in input state, keep the field name aligned with the segment name. Generated route parameter types use the segment names. ## Custom reducers Each route may include a `reducers/` directory with one file per state field. The default export is a `(current, incoming) => merged` function that overrides B4.run's default merge for that field. ```ts title="src/app/(public)/hello/[tenant]/reducers/context.ts" export default (current: string, incoming: string) => current ? `${current}\n${incoming}` : incoming ``` The file basename must equal the state-field name (`context.ts` → reduces `state.context`). Reducers are useful for accumulation patterns (concatenating logs, summing counters, deduplicating lists) where the default object spread is too coarse. ## Rules B4.run serializes state across runtime boundaries (`b4 run`, `b4 dev`, the Node runtime, Hono builds, and scenario tests), and LangSmith serializes state at its own boundary. Use primitives, plain objects, arrays. No classes, no Dates, no Maps. Mark fields `readonly` (or use Zod's `.readonly()`) when they should not be mutated. The entry function returns a new state object rather than mutating the input. This keeps scenario tests deterministic and LangGraph checkpoint-safe. A workflow can return `{ ...state, newField }` when you want to preserve incoming state. B4.run returns the route output; it does not automatically merge workflow output with the input for you. ## State flow State crosses the runtime boundary at every entry point: - `b4 run` — JSON via stdin, JSON via stdout. - `runs/wait` and `runs/stream` under `b4 dev`, the Node runtime (`b4 start` or `.b4/build/server.mjs`), and Hono builds — B4.run's HTTP envelope uses `{ route, input }`, where `route` is `#`. - LangSmith deployments are a separate product boundary. They use LangSmith's own request envelope, keyed by `assistant_id`; do not send B4.run's `{ route, input }` body unchanged. Tool results, intermediate values, and other in-route data live inside the entry for the duration of a single run; only the route result crosses the boundary. ## Related --- ### Workspace Filesystem # Workspace Filesystem A B4.run app's sandboxed file area is the `workspace/` directory at the app root. Creating that directory opts an agent route into four built-in tools: `listDir`, `readFile`, `writeFile`, and `runBash`. No configuration is required — the directory's presence is the activation signal. All file I/O in the runtime flows through one permission gate, whether the request comes from the LLM calling an agent-facing tool or from your own code calling `ctx.fs`. There are three layers: - **The pluggable backend** (`@b4run/workspace`) — a plain-object interface that reads, writes, and lists files. `localFilesystem()` ships as the default; you can substitute any backend in `b4.config.ts`. - **Agent-facing workspace tools** — `readFile`, `writeFile`, `listDir`, and `runBash`, wired into agent routes when a `workspace/` directory exists at the app root. The LLM calls these by name; they gate through the permission system before touching the backend. - **`ctx.fs`** — the `WorkspaceFs` handle available on `B4ToolContext` (route tools) and `RuntimeContext` (workflow/graph entries). Same gate, same backend, but your code drives it. Always present on the context; reads simply surface `ENOENT` if the `workspace/` directory doesn't exist yet. ## The pluggable backend `FilesystemBackend` is the contract every backend must satisfy: | Method | Required | Notes | |---|---|---| | `readFile(path, ctx, opts?)` | Yes | UTF-8; `opts.maxBytes` overrides the default cap | | `realPath(path, ctx)` | Yes | Canonicalize an absolute path (resolve symlinks) so the permission gate compares real targets; backends without symlinks return the path unchanged | | `readBinaryFile(path, ctx, opts?)` | No | Raw bytes (`Uint8Array`); required for `ctx.fs.readBinaryFile` | | `writeFile(path, content, ctx)` | Yes | Returns `{ bytesWritten }` | | `listDir(path, ctx)` | Yes | Returns leaf names, not full paths | | `statFile(path, ctx)` | No | Required for offload GC | | `removeFile(path, ctx)` | No | Required for offload GC eviction | | `touchFile(path, ctx)` | No | Used by LRU-by-access offload tracking | | `mkdir(path, ctx)` | No | Used to create the `tool-outputs/` directory | `localFilesystem()` implements all of them. Its defaults: - **256 KiB read cap** per call. Override per-call with `opts.maxBytes` (e.g. `Number.POSITIVE_INFINITY` for uncapped reads). - **`writeFile` creates missing parent directories** — writing to `reports/result.md` works without a separate `mkdir` call. Configure a custom backend in `b4.config.ts`: ```ts title="b4.config.ts" import { myRemoteBackend } from "./backends/remote.js" export default { backends: { filesystem: myRemoteBackend(), }, } ``` ## Middleware `FilesystemMiddleware` is `(next: FilesystemBackend) => FilesystemBackend`. Wrap the default backend with `compose()` to add cross-cutting behavior: ```ts title="b4.config.ts" import { compose, withFilesystemLogging } from "@b4run/workspace" import { localFilesystem } from "@b4run/workspace/node" export default { backends: { // compose(...) takes middlewares and returns a wrapper; apply it to the base backend. filesystem: compose(withFilesystemLogging())(localFilesystem()), }, } ``` `withFilesystemLogging` writes method names and arguments to `stderr` by default — note that for `writeFile` this includes the full file content, so route logs accordingly. Supply a `destination` function for structured output: ```ts withFilesystemLogging({ destination: ({ method, args }) => { structuredLogger.debug("workspace", { method, args }) }, }) ``` `readBinaryFile` is logged with the **path only** — the bytes are never serialized into the log entry. A middleware that returns only `{ readFile, writeFile, listDir }` silently drops `readBinaryFile`, `statFile`, `removeFile`, `touchFile`, and `mkdir`. Dropping `statFile`/`removeFile` disables offload GC; dropping `readBinaryFile` disables `ctx.fs.readBinaryFile`. Use a conditional spread to preserve methods you don't need to intercept: ```ts return (next: FilesystemBackend) => ({ readFile: async (path, ctx, opts) => { // ... your logic return next.readFile(path, ctx, opts) }, writeFile: (path, content, ctx) => next.writeFile(path, content, ctx), listDir: (path, ctx) => next.listDir(path, ctx), ...(next.readBinaryFile && { readBinaryFile: (p, c, o) => next.readBinaryFile!(p, c, o) }), ...(next.statFile && { statFile: (p, c) => next.statFile!(p, c) }), ...(next.removeFile && { removeFile: (p, c) => next.removeFile!(p, c) }), ...(next.touchFile && { touchFile: (p, c) => next.touchFile!(p, c) }), ...(next.mkdir && { mkdir: (p, c) => next.mkdir!(p, c) }), }) ``` `withFilesystemLogging` does this correctly and is safe to use as a reference. ## The four agent-facing tools All paths are workspace-relative. Paths outside `workspace/` are permission-gated — see [Permissions](#permissions) below for the full decision table. ### listDir Lists the leaf names (not full paths) of a directory inside the workspace. ``` listDir({ path: "corpus" }) // → ["intro.md", "api-reference.md", "changelog.md"] ``` `path` defaults to `"."` (the workspace root) when omitted. ### readFile Reads a UTF-8 file. The default cap is **256 KiB** — files larger than that return an error rather than partial content. ``` readFile({ path: "corpus/intro.md" }) ``` The 256 KiB cap applies to agent tool calls. Files inside `workspace/tool-outputs/` (where offloaded tool results are stored) are read without a size limit — the agent retrieves them with the same `readFile` call. ### writeFile Writes a UTF-8 file. **Missing parent directories are created automatically** — calling `writeFile({ path: "reports/2024-q1.md", content: "..." })` works even if `reports/` does not exist yet. ``` writeFile({ path: "reports/summary.md", content: "# Summary\n\n..." }) // returns: "wrote 1234 bytes to reports/summary.md" ``` ### runBash Executes a shell command with the workspace root as the working directory. `runBash` is gated by the [permissions](/docs/permissions) system: a command must match an allow rule or pass an interactive prompt before it runs. ``` runBash({ command: "node scripts/fetch-source.mjs https://example.com/api" }) ``` ## ctx.fs for tools and routes `ctx.fs` is a `WorkspaceFs` handle — a narrower, workspace-relative surface over the backend: ```ts interface WorkspaceFs { readFile(path: string, opts?: { readonly maxBytes?: number }): Promise readBinaryFile(path: string, opts?: { readonly maxBytes?: number }): Promise writeFile(path: string, content: string): Promise<{ readonly bytesWritten: number }> listDir(path?: string): Promise } ``` Paths are **workspace-relative** — `"images/logo.png"` resolves to `/workspace/images/logo.png`. The handle resolves relative paths against the workspace root and permission-gates anything that lands outside it (see [Permissions](#permissions) below). **Example: a route tool reading a binary file** ```ts title="src/app/(public)/describe/tools/describeImage.ts" import type { B4ToolContext } from "@b4run/sdk" export const description = "Describe an image stored in the workspace." export default async ( input: { readonly path: string }, ctx: B4ToolContext, ) => { const bytes = await ctx.fs.readBinaryFile(input.path) const dataUrl = `data:image/png;base64,${Buffer.from(bytes).toString("base64")}` // Pass dataUrl to a vision model call, return the description, etc. return { dataUrl } } ``` `Buffer.from(bytes).toString("base64")` converts the `Uint8Array` to a base64 string. If the configured backend does not implement `readBinaryFile`, the call throws with a message naming the fix. **Example: a workflow entry listing and reading files** ```ts title="src/app/(public)/report/index.ts" import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" export async function workflow( state: { readonly topic: string }, ctx: RuntimeContext>, ) { const entries = await ctx.fs.listDir("drafts") const first = entries[0] if (!first) return { ...state, summary: "no drafts found" } const content = await ctx.fs.readFile(`drafts/${first}`) return { ...state, summary: content.slice(0, 500) } } ``` `listDir()` with no argument defaults to the workspace root. Both `readFile` and `listDir` run through the permission gate before touching the backend. ## Research scaffold example The `research` template (`create-b4-app --template research`) shows how these tools fit together. Paths below are relative to the B4.run app root, which is the generated workspace's `server/` package: ```text workspace/ AGENTS.md ← agent memory (see /docs/memory) corpus/ ← source documents the agent reads scripts/ fetch-source.mjs ← network fetch script called via runBash reports/ ← agent-written output (writeFile creates this) ``` The agent uses `listDir` and `readFile` to explore and read corpus documents. It calls `runBash` to run `scripts/fetch-source.mjs` when it needs to pull a new source — that command is intentionally left off the `allow` list so the fetch step requires human approval. Reports land in `workspace/reports/` via `writeFile`; the directory is created on the first write. Agent memory lives at `workspace/AGENTS.md` and is automatically injected into the system prompt. See [Memory](/docs/memory) for details. B4.run re-reads the host app root's `workspace/AGENTS.md` into prompt context on every turn for every consuming `agent()` route and subagent. With the default local app-root filesystem, `writeFile({ path: "AGENTS.md", ... })` targets that same file, so a route can persist instructions that affect later requests and other consuming agents sharing the app root. A sandbox or custom filesystem backend may send `writeFile` to a different filesystem and does not automatically change the host file this prompt marker reads. Raw graph, workflow, and chain routes do not consume this prompt file. Use separate app roots or prompt-memory trust domains for different tenants. A per-thread sandbox alone does not isolate this host prompt source. Constrain or deny writes from untrusted agents when they can reach it, review seeded content, and never copy raw untrusted text into `workspace/AGENTS.md`. ## Tool-output offloading When a tool returns a large result, B4.run can spill it to `workspace/tool-outputs/` and replace the in-context payload with a short stub the agent can read back on demand using `readFile`. This keeps the context window tidy without losing information. See [Context Management](/docs/context-management) for configuration options and how offloading composes with conversation summarization. ## Permissions All file I/O — whether initiated by the LLM calling a workspace tool or by your code calling `ctx.fs` — goes through the same permission gate. | Path | Decision | |---|---| | Inside `workspace/` | Always allowed silently | | Outside `workspace/` — allow rule matches | Allowed | | Outside `workspace/` — deny rule matches | Denied | | Outside `workspace/` — no rule (unknown), interactive mode, agent-route tool | Interactive prompt shown; `kind: "path"` interrupt pauses the run for human approval | | Outside `workspace/` — no rule (unknown), non-interactive mode | Fail-closed | | Outside `workspace/` — no rule (unknown), workflow/graph entry | Fail-closed with guidance | | `bypass` mode | Everything allowed (dev only) | **Non-interactive and workflow/graph entries fail closed.** Workflow and graph entries run outside the LangGraph graph, where the interrupt mechanism is not available. If a path outside `workspace/` hits an unknown permission, the gate returns an error telling you to add an allow rule: ``` Permission denied: /etc/hosts is outside the workspace and interactive permission prompts are not available in this execution context. Add an allow rule for "readFile" to the permissions config in b4.config.ts. ``` One permission model regardless of whether the LLM or your code initiates the I/O. **Allow rules match canonical paths.** Because the gate compares symlink-resolved paths, an allow rule for a path outside the workspace must reference the **canonical** (symlink-resolved) path. On systems where the workspace or target lives under a symlink — e.g. macOS `/var → /private/var`, or a symlinked home or project directory — a rule written against the non-canonical alias will not match and the operation fails closed. **Symlinks are resolved before the gate.** `localFilesystem` resolves symlinks before the gate decision (via the required `realPath`), so a symlink inside `workspace/` that points outside is correctly gated — prompted or denied in interactive mode, fail-closed otherwise — rather than silently followed. Custom backends get the same protection by implementing `realPath`, which the type system now requires. When a permission prompt fires from inside tool code — because the tool called `ctx.fs` with a path outside `workspace/` that needs interactive approval — LangGraph pauses execution mid-tool. On resume, LangGraph **re-runs the tool body from the top**. Any side effects that ran before the `ctx.fs` call execute again. Keep side effects idempotent around gated `fs` calls, or move them after the call so they only run once the permission is resolved. ## Related --- ### Memory # Memory B4.run has three memory mechanisms. Choose by who owns the data and how it should be read; route state and checkpoints preserve workflow execution, but they are not a fourth memory mechanism. | Mechanism | Scope and shape | Best for | Owner | |---|---|---|---| | `workspace/AGENTS.md` | App-wide Markdown, re-read each model turn | Stable instructions and facts shared by consuming agent routes | You or an agent with [`writeFile`](/docs/workspace) | | Route `memory.md` | Route-local Markdown, re-read each model turn | Versioned prompt facts for one route | Application developer | | Route `memory.ts` | Typed, store-backed records in declared namespaces | Facts, episodes, and reflections accumulated across sessions | Application and agent tools | Start with prompt files for small, reviewed context. When records need a schema, governance, retention, or retrieval, the smallest useful `memory.ts` is: ```ts title="src/app/research/memory.ts" import { defineMemory } from "@b4run/sdk" import { z } from "zod" export default defineMemory({ kind: "semantic", scope: ["workspace", "route"], schema: z.object({ subject: z.string(), predicate: z.string(), value: z.string(), }), }) ``` From this typed collection, continue with [Long-term Memory](/docs/memory/long-term), [Recall and Retrieval](/docs/memory/retrieval), [Episodes](/docs/memory/episodes), [Distillation](/docs/memory/distillation), or [Browse and Manage Memory](/docs/memory/browse), depending on the task. ## Workspace profile (`AGENTS.md`) If `workspace/AGENTS.md` exists and has content, B4.run injects it under `# Memory` on every model turn. It is shared by every consuming agent route and subagent using the same app root; raw graph, workflow, and chain routes do not consume it. ```md title="workspace/AGENTS.md" # Workspace Memory - Use pnpm for package commands. - Prefer short, direct customer replies. - Escalate billing exceptions to the finance queue. ``` The default local app root makes this a shared host prompt source. A per-thread sandbox does not isolate that host file automatically. Use separate app roots or trust domains for tenants, constrain untrusted writes, and review seeded content. The [Workspace Filesystem](/docs/workspace) guide explains the file tools and backend boundary. ### Updating it See [Workspace Filesystem](/docs/workspace) for controlled `writeFile` access and backend behavior. ## Route memory (`memory.md`) For stable context that applies to one agent route, place `memory.md` beside its `index.ts`: ```text src/app/research/ index.ts memory.md ``` ```md title="src/app/research/memory.md" # Research Route Memory - Every claim in a report must carry an inline citation to a source you read. - Prefer primary sources; flag anything you could not verify. ``` B4.run trims and injects the file under `# Route Memory`, after `workspace/AGENTS.md`, on every model turn. Presence is the opt-in: there is no import or registration call. It is a versioned prompt fragment, not a store, and the agent does not write it automatically. Empty files contribute nothing; files larger than 32 KiB are skipped and produce a prompt note. ## Long-term collection (`memory.ts`) Schemas and write lifecycle: [Long-term Memory](/docs/memory/long-term). ### Generated tools Generated tool contract: [Long-term Memory](/docs/memory/long-term). ### How recall ranks Ranking stages: [Recall and Retrieval](/docs/memory/retrieval). ### Semantic recall (opt-in) Embedding-based recall: [Recall and Retrieval](/docs/memory/retrieval). ### Postgres backend (pgvector) Shared vector storage: [Recall and Retrieval](/docs/memory/retrieval). ### The injected index Prompt index behavior: [Recall and Retrieval](/docs/memory/retrieval). ## Episodic memory Run-event concepts: [Episodes](/docs/memory/episodes). ### Enabling the run recorder Recorder setup: [Episodes](/docs/memory/episodes). ### What gets recorded Record shape: [Episodes](/docs/memory/episodes). ### Retention Expiry and caps: [Episodes](/docs/memory/episodes). ### Time-windowed recall Window filters: [Episodes](/docs/memory/episodes). ### Governance Write policy: [Episodes](/docs/memory/episodes). ### Agent-authored episodes Agent-written events: [Episodes](/docs/memory/episodes). ## Distillation Pass selection: [Distillation](/docs/memory/distillation). ### Consolidation Compaction details: [Distillation](/docs/memory/distillation). ### Reflection Insight derivation: [Distillation](/docs/memory/distillation). ### Distilled records are found by keyword Keyword reachability: [Distillation](/docs/memory/distillation). ### Provenance Source provenance: [Distillation](/docs/memory/distillation). ### Cost Cost controls: [Distillation](/docs/memory/distillation). ### Running it on a schedule Scheduling guidance: [Distillation](/docs/memory/distillation). ### Distillation configuration Configuration options: [Distillation](/docs/memory/distillation). ## Write governance Write modes: [Long-term Memory](/docs/memory/long-term). ### `ask` mode Supervision behavior: [Long-term Memory](/docs/memory/long-term). ## Reviewing candidates Candidate operations: [Long-term Memory](/docs/memory/long-term). ## Configuration Store settings: [Long-term Memory](/docs/memory/long-term). ## Testing Deterministic setup: [Long-term Memory](/docs/memory/long-term). ### Verifying against a real model Live verification: [Long-term Memory](/docs/memory/long-term). ## What's deferred Current limits: [Long-term Memory](/docs/memory/long-term). ## Related --- ### Long-term Memory # Long-term Memory Use a route-local `memory.ts` when an agent needs durable, typed records rather than another prompt file. The declaration controls the record shape, kind, namespace dimensions, identity, and whether model-authored writes become active immediately or wait as candidates. ## Declare a collection Place `memory.ts` beside the agent route: ```ts title="src/app/support/memory.ts" import { defineMemory } from "@b4run/sdk" import { z } from "zod" export default defineMemory({ kind: "semantic", scope: ["workspace", "route"], identity: ["subject", "attribute"], schema: z.object({ subject: z.string(), attribute: z.string(), value: z.string(), }), }) ``` - `kind` is `semantic`, `episodic`, `reflection`, or `procedural`. - `scope` is a subset of `workspace`, `route`, `tenant`, `user`, and `agent`. It determines the namespace dimensions that isolate records. - `schema` validates `remember.data` at typegen time and again at runtime. - `identity` selects fields used to reconcile semantic facts. It defaults to `subject` and `predicate`. `semantic`, `episodic`, and `reflection` writes are wired today. `procedural` is declared in the type system but the generated write path is not wired; `remember` returns a clear rejection rather than inventing semantics. ## Scope is addressing, not authentication The runtime constructs the route and workspace dimensions. Additional dimensions can come from `memory.resolveScope`: ```ts title="b4.config.ts" export default { memory: { resolveScope: ({ routePath, appRoot }) => { console.info("resolving memory scope", { routePath, appRoot }) return { agent: process.env.B4_AGENT_SLOT ?? "primary" } }, }, } satisfies import("@b4run/core").B4Config ``` Only dimensions declared by the route are retained, so this example requires `agent` in `memory.ts`'s `scope`. The callback receives only `routePath` and `appRoot`; it does not receive verified identity or middleware context. Never derive a tenant boundary from an untrusted route parameter, thread id, or request body. If a namespace needs a verified tenant or user, add application-owned wiring that has authenticated that identity and test cross-tenant isolation. See [Persistence and Tenancy](/docs/persistence#tenant-ownership). ## Generated `recall` and `remember` tools Typegen adds generated `recall` and `remember` tools to an agent route with `memory.ts`: - `recall({ query?, kind?, tags?, limit?, since?, until? })` reads active in-scope records. [Recall and Retrieval](/docs/memory/retrieval) covers ranking and time windows. - `remember({ data, content, tags?, confidence? })` validates the typed data and stores a human-readable `content` string. It is omitted when `memory.writes` is `"off"`. The model-facing data type follows your Zod schema. Runtime validation remains authoritative when a model calls the tool. ## Record identity and write behavior Semantic IDs are derived from the namespace and serialized data. With automatic writes, B4.run compares the configured identity fields against active semantic records: | Match | Result | |---|---| | No identity match | Append a new active record | | Same identity and same data | Update content, confidence, tags, and `updatedAt` on the existing record | | Same identity and different data | Write the new record and supersede the old one | Episodic and reflection records append instead of reconciling. Their IDs salt the namespace and data with the write timestamp, so repeated writes normally remain distinct. Because the store uses an ID-keyed upsert, identical namespace and data written in the same millisecond can produce the same ID and collide/upsert. Each record carries `createdAt` and `updatedAt`; append kinds also use that request timestamp as `effectiveAt`. The model can supply content, tags, and confidence, but not these identity or timestamp fields. ## Write governance Configure the generated write tool in `b4.config.ts`: ```ts title="b4.config.ts" export default { memory: { writes: "candidate", }, } satisfies import("@b4run/core").B4Config ``` | Mode | Behavior | |---|---| | `candidate` | Default. Writes are stored for review and do not appear in normal recall until approved. | | `auto` | Adds and reconciliations become active immediately. | | `ask` | Adds and idempotent updates act like `auto`; a semantic supersede crosses the memory permission gate. | | `off` | The generated `remember` tool is absent. | Candidate approval reconciles semantic identity before activation. Episodic and reflection candidates simply activate because append records do not contradict prior events or insights. ### `ask` mode `ask` gates only the semantic contradiction branch. If the new fact has no matching identity, or repeats the same data, it writes without that prompt. Append-only episodic and reflection writes also do not trigger the supersede gate. In headless/non-interactive mode, an unknown decision is allowed, so `ask` behaves like `auto`; without a permissions store, the supersede is also allowed. Explicit deny rules are still honored. This makes `ask` a supervision affordance, not a security boundary. Use an explicit deny policy and application authorization when a write must be prevented, and remember that an interactive decision held in one process is not distributed authorization. ## Reviewing candidates During development, inspect the queue with the CLI: ```bash b4 memory list b4 memory approve b4 memory reject ``` The local runtime also exposes candidate list, approve, and reject management routes. They are not blanket authentication: put them behind application-owned authentication and tenant authorization before exposing them. For a broader admin surface, follow [Browse and Manage Memory](/docs/memory/browse). ## Stores and lifecycle The default Node store is SQLite at `.b4/memory.sqlite`. Supply `memory.store` for another backend. `@b4run/memory-pgvector` provides a shared Postgres store and vector candidate retrieval; it remains a separate store from checkpoints, thread metadata, and permissions. Deleting a thread does not delete long-term memory. Use the store's `delete(id)` for an explicit record deletion and `prune({ now, namespacePrefix?, cap? })` for expired records and episodic caps. Design account deletion, retention, backups, and audit across every namespace-bearing store at the application layer. ## Configuration A complete Node configuration can choose a store, governance mode, ranking, embeddings, episode recording, distillation, and scope resolution independently: ```ts title="b4.config.ts" import { sqliteMemoryStore } from "@b4run/memory" export default { memory: { store: sqliteMemoryStore({ path: ".b4/memory.sqlite" }), writes: "candidate", recall: { candidatePool: 256 }, }, } satisfies import("@b4run/core").B4Config ``` The built-in SQLite ranker options are ignored by a custom store, which owns its search behavior. Keep provider credentials and application identity outside model-authored scope data. ## Testing Seed known records without asking a model to call `remember`: ```ts title="test/support-memory.test.ts" import { basename } from "node:path" import { fileURLToPath } from "node:url" import { sqliteMemoryStore } from "@b4run/memory" import { serializeNamespace } from "@b4run/memory/namespace" import { seedMemory } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const store = sqliteMemoryStore({ path: ":memory:" }) await seedMemory(store, [{ id: "customer-locale", namespace: serializeNamespace({ workspace: basename(appRoot), route: "/support", }), kind: "semantic", content: "Customer prefers French replies", data: { subject: "customer", attribute: "locale", value: "fr" }, status: "active", }]) ``` This creates the same workspace value as the runtime: the basename of the app root, not the full filesystem path. The `:memory:` database is process-local and leaves no file to clean up. Assert the namespace, schema rejection, candidate approval, semantic update/supersede behavior, and retention separately. Fix timestamps in store tests so recency and expiry do not depend on wall-clock time. ### Verifying against a real model After deterministic tool and store tests pass, run a narrow integration test that asks the configured model to recall a distinctive seeded fact and, separately, propose a write. Treat model phrasing as nondeterministic: assert the tool event or stored record rather than an exact natural-language sentence. Keep candidate mode enabled unless the test intentionally exercises automatic writes. ## What's deferred The `procedural` kind is typed but not generated-write wired. There is no automatic migration between namespace schemes, no account-erasure transaction across stores, and no verified request principal passed into `resolveScope`. Build those application boundaries explicitly instead of assuming a declared scope supplies identity. ## Related --- ### Recall and Retrieval # Recall and Retrieval Recall is the agent-facing search path. The generated `recall` tool builds a scoped query and delegates to `MemoryStore.search`; use it when the question is “which memories help this run?” Administrative filtering belongs in [Browse and Manage Memory](/docs/memory/browse), not in the ranker. ## The generated recall tool ```ts await recall({ query: "customer shipping preference", kind: "semantic", tags: ["customer"], since: "-30d", limit: 8, }) ``` The `memory.ts` scope declaration chooses which dimensions exist; runtime values plus `memory.resolveScope` construct the namespace for those dimensions. Built-in workspace and route values come from the app root and route path. `resolveScope` receives only `routePath` and `appRoot` and does not receive verified identity, so tenant/user scope still needs application-owned authenticated wiring. `since` and `until` accept ISO instants or relative expressions and are resolved against the request clock. The tool passes one fixed evaluation timestamp as `now`, which both filters expired rows and anchors recency scoring. Fix that clock in tests when asserting order or window boundaries. `MemoryStore.search` defaults to active records and a limit of eight. A query-less search filters by namespace, status, kind, tags, expiry, and optional event-time window without calculating relevance. Without a window it orders by `updatedAt`; with `since` or `until` it orders by `effectiveAt` (falling back to `createdAt`). Tags are applied after the result limit in both in-repo stores. Query-less search limits the ordered rows before filtering tags; ranked and hybrid search slice the ranked list first. Eligible tagged rows below that boundary are omitted, so a tagged result page can contain fewer than `limit` records. Treat this as narrowing a bounded recall result, not exhaustive tag pagination. ## How recall ranks A keyword query tokenizes the query and record content, then ranks a bounded candidate pool with three signals: `candidatePool` first keeps the newest token-matching rows before scoring them. A highly relevant older token match outside that recency-truncated pool cannot rank into the result; increase the pool and evaluate latency/quality when histories grow. | Signal | Default weight | Purpose | |---|---:|---| | IDF-weighted relevance | 0.6 | Rewards matching rare, specific tokens | | Recency | 0.3 | Exponential decay with a 14-day half-life | | Confidence | 0.1 | Uses the stored record confidence | Ties break by newest `updatedAt`, then `id`. Matching is exact-token rather than stemming, so use consistent words for IDs, product names, and domain terms. ```ts title="b4.config.ts" export default { memory: { recall: { weights: { relevance: 0.6, recency: 0.3, confidence: 0.1 }, recencyHalfLifeMs: 14 * 24 * 60 * 60 * 1000, candidatePool: 256, }, }, } satisfies import("@b4run/core").B4Config ``` Reproducible evaluation requires the same store snapshot, tokenization/tuning, and fixed evaluation timestamp. Advancing `now` can change expiry and recency even when no row is written. ## Semantic recall (opt-in) Add an embedder when paraphrases must match even without shared tokens: ```ts title="b4.config.ts" import { openaiEmbedder } from "@b4run/langchain" export default { memory: { vector: { embedder: openaiEmbedder(), weights: { keyword: 1, vector: 1 }, rrfK: 60, vectorK: 64, recencyWeight: 0.3, confidenceWeight: 0.1, }, }, } satisfies import("@b4run/core").B4Config ``` Hybrid search takes the union of the keyword list and vector-nearest list, combines their ranks with Reciprocal Rank Fusion, then applies bounded recency and confidence. Keyword matches remain present because embeddings are weak on exact IDs, codes, and names. Every stored embedding carries the embedder model id. Vector comparison includes only rows whose model id matches the active embedder, preventing comparisons across incompatible vector spaces. After changing embedders, older rows still participate in keyword search until you re-embed them. Writing or recalling degrades to keyword-only if embedding fails. Set `B4_DEBUG_MEMORY=1` when diagnosing silent fallbacks. For deterministic tests, use a fixed embedder such as `fakeEmbedder()` from `@b4run/testing`. ## Postgres backend (pgvector) Install `@b4run/memory-pgvector` when several app instances need a shared memory store: ```ts title="b4.config.ts" import { openaiEmbedder } from "@b4run/langchain" import { pgvectorMemoryStore } from "@b4run/memory-pgvector" const embedder = openaiEmbedder() export default { memory: { store: pgvectorMemoryStore({ connectionString: process.env.DATABASE_URL!, dimensions: embedder.dims, }), vector: { embedder }, }, } satisfies import("@b4run/core").B4Config ``` `dimensions` must match the embedder output dimension. The shared `embedder` value makes this a complete hybrid configuration; omitting `memory.vector` leaves the pgvector store on keyword-only recall even though it has a vector column. SQLite loads matching-model vectors and performs an exact vector scan in process. The pgvector store asks an HNSW index for approximate candidates before applying the shared hybrid stages. Candidate sets can therefore differ, and the two backends do not promise identical order. Evaluate retrieval quality per deployed backend and data distribution. A custom store owns ranking behavior, so the built-in `memory.recall` and `memory.vector` tuning is not automatically applied to arbitrary implementations. Document and test that store's `MemoryStore.search` contract explicitly. ## The injected index Before the model starts, B4.run performs a query-less search for a small active-memory index and adds `id: content` lines to the prompt. It is a recency-ordered orientation aid, not the whole store and not semantic ranking. The agent should call `recall` when it needs a specific topic or time window. ## Evaluate retrieval Build an evaluation set with expected record IDs, not exact prose. Include: 1. literal codes and names that require keyword matching; 2. paraphrases that need vectors; 3. expired and out-of-window records; 4. equal-relevance records with different timestamps or confidence; 5. rows embedded by an older model id; 6. enough data to exercise the configured candidate bounds. Pin the store contents, `now`, embedder output, and ranking config. Run the set against SQLite and pgvector independently instead of expecting backend-identical sequences. ## Troubleshooting - No results: confirm the exact namespace, active status, kind, tags, and time window. - A record vanished: compare `expiresAt` with the fixed evaluation timestamp. - Literal ID misses: keep keyword recall enabled and use the exact token. - Paraphrase misses: verify the query and rows have embeddings with the same model id. - Different Postgres ordering: inspect HNSW candidate recall and ties before changing second-stage weights. - Admin page looks unlike recall: that is expected; `browse` filters and sorts records without semantic ranking. ## Related --- ### Episodes # Episodes Episodes record what a run did so later runs can recall recent operational history. B4.run supports two paths: an opt-in runtime recorder for settled agent runs, and agent-authored `episodic` records written through the generated `remember` tool. ## Episodic memory Episodic records append. A later event does not reconcile or supersede an earlier event merely because their data looks similar. Recall can filter them by an event-time window; [Distillation](/docs/memory/distillation) can later compact old episodes or derive reflections. ## Enabling the run recorder The recorder is disabled by default. Enable it for agent routes that have a resolved memory context: ```ts title="b4.config.ts" export default { memory: { episodes: { enabled: true, ttlMs: 30 * 24 * 60 * 60 * 1000, cap: 500, includeFailedRuns: true, }, }, } satisfies import("@b4run/core").B4Config ``` The TTL defaults to 30 days, the cap to 500 episodes per namespace, and failed runs are included. The recorder does not embed episodes: `embed` currently resolves to false, and setting it to true warns once while the record still lands without an embedding. The recorder shares the long-term write switch: `memory.writes: "off"` makes the recorder a no-op even when `memory.episodes.enabled` is true. Use `off` when the entire route must remain recall-only. ## What gets recorded One completed run produces one active episodic record: ```json { "kind": "episodic", "content": "run ok: summarize invoice 8821 (2 tools, 1.4s)", "data": { "input": "summarize invoice 8821", "outcome": "ok", "toolsUsed": ["readFile", "lookupInvoice"], "durationMs": 1400, "threadId": "thread-123", "runId": "run-456" }, "source": { "type": "run", "id": "run-456" }, "effectiveAt": "2026-08-10T18:00:00.000Z" } ``` The source id prefers `runId`, then `threadId`, then the start instant. Input is bounded, tool names are unique, and `outcome` is `ok` or `error`. Successful episodes derive tool names from final route state; failed episode records currently use `toolsUsed: []` because no final state is available, even if tools ran before the error. The run's event timestamp is `effectiveAt` at start; `createdAt` and `updatedAt` are the recorder's finish/write time. The record expires relative to the start time. Recorder errors never fail the user run; the first failure is logged and later failures are muted in that process. Monitor the store if episode capture is operationally required. ## Settled, failed, and parked runs - A settled successful run records `outcome: "ok"`. - A settled thrown run records `outcome: "error"` when `includeFailedRuns` is true. - A parked interrupt is not completed until a later resume settles or fails. The parked turn records nothing, preventing a half-finished run from appearing as history. This distinction matters for human-in-the-loop permissions: seeing an interrupt event is not evidence that the route finished. ## Retention After writing an episode, the recorder calls `prune` for the namespace. Pruning removes rows whose `expiresAt` is at or before the fixed `now`, then removes the oldest episodic rows beyond the cap using `effectiveAt` (falling back to `createdAt`) and `id` as a tie-break. Retention is lazy: a write or explicit pruning command performs it. It is not a precise background timer. Run `b4 memory prune` on an application-owned schedule if stale rows must be removed without new traffic. Thread deletion does not remove episodes. Design tenant erasure and retention around the memory namespace, not the thread metadata lifecycle. ## Time-windowed recall ```ts await recall({ kind: "episodic", query: "deployment failure", since: "-24h", until: "2026-08-11T00:00:00.000Z", }) ``` The generated tool resolves relative expressions against one request timestamp. `since` is inclusive and `until` is exclusive over `effectiveAt`, falling back to `createdAt`. Query-less windowed recall orders by that event time; a query adds keyword ranking, recency, and confidence. ## Governance Auto-recorded episodes are active operational records and do not pass through candidate review. Limit who can run a route, derive tenant namespaces from verified application identity, and avoid putting secrets into model input that the recorder may retain. Agent-authored episodes follow `memory.writes`: `candidate` waits for review, while `auto` and `ask` activate append records without a supersede prompt. An application can therefore govern runtime telemetry and model-authored narratives differently only by choosing whether to enable the recorder and how to expose the remember tool. ## Agent-authored episodes Declare `kind: "episodic"` in the route's `memory.ts` when the agent should write domain events itself: ```ts title="src/app/support/memory.ts" import { defineMemory } from "@b4run/sdk" import { z } from "zod" export default defineMemory({ kind: "episodic", scope: ["workspace", "route"], schema: z.object({ event: z.string(), ticketId: z.string(), }), }) ``` Agent-authored episodes use the remember call's write timestamp for `effectiveAt` and have no automatic episode-recorder TTL. Apply explicit retention through the store or CLI. They append even when identity fields match. ## Testing Test episode behavior with a fixed clock and a real store implementation: 1. absent config records nothing; 2. success and permitted failure create the expected source/data shape; 3. a parked interrupt creates no episode before resume; 4. resume settlement creates one episode, not one per turn; 5. expiry and the 500-row default cap prune the correct oldest records; 6. `embed: true` still produces a record without an embedding; 7. `memory.writes: "off"` records nothing even when episodes are enabled; 8. a failed run records an empty `toolsUsed` list. Use an isolated namespace per test. Query by kind and time window rather than relying on wall-clock ordering. ## Related --- ### Distillation # Distillation Distillation is explicit maintenance for long-lived memory collections. Nothing runs automatically: B4.run consolidates or reflects only when you invoke `b4 memory consolidate` or `b4 memory reflect`, by hand or from an application-owned schedule. ## Distillation Use the two passes for different jobs: | Command | Input | Output | |---|---|---| | `b4 memory consolidate` | Old active episodes grouped by namespace and ISO week | One active episodic summary per selected batch | | `b4 memory reflect` | New active semantic and episodic records per namespace | Zero or more durable reflection insights | Both commands are threshold-aware no-ops. Their common flags are: ```bash b4 memory consolidate --dry-run --namespace 'workspace=my-app|route=/support' --max-batches 5 b4 memory reflect --dry-run --namespace 'workspace=my-app|route=/support' --max-batches 5 ``` - `--dry-run` selects and reports work but constructs no model, makes zero model calls, and writes nothing. - `--namespace ` narrows the pass to matching namespaces. - `--model ` and `--provider ` override model selection. - `--max-batches ` caps batches for consolidation and namespaces for reflection. - `--cwd ` selects another app root like other B4.run commands. `--namespace` is a raw prefix. A short `route=/support` prefix works only when `route` is the leading namespace dimension. If the route also declares `workspace`, use the full canonical namespace prefix, such as `workspace=my-app|route=/support`; derive it with the namespace helpers rather than guessing from a filesystem path. ## Consolidation By default, consolidation selects active episodes older than seven days, groups them by namespace and ISO week, skips groups smaller than five, and splits groups at 50 records. Oldest batches run first so a capped invocation advances the backlog predictably. For each batch it: 1. asks the model for a summary; 2. writes the summary record first; 3. links each source as superseded by that summary; 4. stamps each source with the configured source-retention expiry. The write-before-link boundary is deliberate. A failure after the summary write can leave a redundant active summary; linking first could hide sources without a durable replacement. The multi-record operation is not transactional. There is no generic safe-to-rerun guarantee. A failure before any source link, or a whole link failure that leaves the original batch active, may select the same batch again and retry its deterministic summary ID. After a partial link failure, however, the remaining active, unstamped sources can fall below `minBatchSize` or be regrouped, so a later pass may never reconcile them. An expiry update can also fail after supersession, leaving a superseded source without the planned expiry. The command reports the affected source IDs and exits non-zero; inspect the summary's `derivedFrom` list and source states, then perform manual reconciliation before scheduling another pass. The summary contains `data.period`, `sourceCount`, and `derivedFrom`, is tagged `consolidated`, and uses the end of the source window as `effectiveAt`. That keeps it newer than its own superseded sources under the episodic cap. Derived summaries are excluded from later consolidation. Superseded source rows remain inspectable for `sourceTtlMs` (seven days by default), then normal pruning removes them. Without that expiry they would remain invisible to recall while still consuming the status-agnostic episodic cap. A source can expire before a scheduled pass if its original retention window is shorter, so align episode TTL, consolidation age, and schedule. ## Reflection Reflection asks “what has been learned?” rather than “what happened?” It operates on each exact namespace's active semantic and episodic records. The pass reads the newest prior `data.coveredUntil` watermark, selects records strictly newer than it, requires at least ten by default, and feeds at most `maxRecords` (100 by default) to the model. If a namespace has a larger backlog, only the newest window is processed and the watermark advances through its newest record. Older excess rows then fall behind that watermark and are not picked up by a later pass. Run reflection often enough or raise `maxRecords` so a namespace cannot build an unreviewed backlog beyond the cap. Insights are reflection records with `derivedFrom` provenance and default to `candidate` status. Approve them through the [Long-term Memory candidate workflow](/docs/memory/long-term#reviewing-candidates), or set `memory.distill.reflect.writes` to `auto`. Each insight record carries the reflection watermark. Candidate rejection is a hard delete: rejecting every candidate insight deletes every persisted watermark for that pass. With no surviving watermark, a later run can select the same inputs, repeat the model call and its cost. Approve at least one valid insight or deliberately preserve/reconstruct the watermark before rejecting the entire batch. When the model returns no insights, B4.run writes a superseded no-insight sentinel. It advances the watermark without becoming recallable, so a schedule does not pay repeatedly for the same barren input. ## Model and data trust On a live pass, active memory content is sent to the configured model provider. That can include user text, tool output, semantic facts, and episodes. Apply data-classification, residency, retention, and provider policy before enabling distillation; redact or exclude secrets and regulated data at collection time. Treat stored memory as untrusted prompt input. Source content can contain prompt injection intended to steer the distillation model, and model output can be inaccurate or malicious. Consolidation model output is written active before source linking begins; reflection output is candidate by default but can also be configured active. Review and monitor derived records, restrict model/provider changes, and alert on unexpected summaries, insights, or link failures. ## Distilled records are found by keyword Consolidation and reflection writes currently do not create embeddings. Their summaries and insights remain available to keyword recall, query-less recall, and time filters. If your application requires semantic reachability for derived records, add an explicit re-embedding workflow and test its model-id handling; do not assume distillation did it. Use specific, stable vocabulary in prompts and outputs so future keyword queries can find the result. Exact names, ticket IDs, and domain terms are especially valuable. ## Provenance Every derived record lists source IDs in `data.derivedFrom`. Consolidation additionally links sources through supersession; reflection leaves its inputs active and records the covered watermark. These links are an audit trail, not a guarantee that all source rows remain forever—retention can delete them. Before deleting source records, decide how long operators need to inspect the evidence and whether an external audit store must preserve it. Distillation does not provide an account-erasure transaction or immutable archive. ## Cost Each selected consolidation batch uses one model call. Each selected reflection namespace uses one model call, even when it produces the no-insight sentinel. Control spend with thresholds, `maxBatches`, a narrow namespace prefix, and `--dry-run` before the first live pass. The default model is `gpt-5-mini`; provider selection follows the resolved model unless an authored provider deliberately overrides it. Pin model/provider in production if changes would affect cost or output shape. ## Running it on a schedule The CLI commands can run behind cron or a job runner, and empty passes do no model work. Start with a dry run using the same namespace and batch cap, then run the live command only after reviewing counts. Treat a non-zero exit as a reconciliation event, not an instruction to retry blindly. Schedule against a single application-owned store and prevent overlapping jobs for the same namespace. Consolidation's write/link sequence and reflection watermarks are retry-oriented, not a distributed lease. Alert on failed batches and “more not examined” output so a capped backlog does not grow unnoticed. ## Distillation configuration ```ts title="b4.config.ts" export default { memory: { distill: { model: "gpt-5-mini", maxBatches: 5, consolidate: { olderThanMs: 7 * 24 * 60 * 60 * 1000, minBatchSize: 5, maxBatchSize: 50, sourceTtlMs: 7 * 24 * 60 * 60 * 1000, }, reflect: { minNewRecords: 10, maxRecords: 100, writes: "candidate", }, }, }, } satisfies import("@b4run/core").B4Config ``` `consolidate.ttlMs` optionally expires summaries; leaving it unset keeps them. Validate that summary TTL exceeds the source review window if the summary is meant to outlive its evidence. ## Testing Use a fake model and fixed `now` to assert selection and writes: 1. `--dry-run` creates no model and performs no write; 2. consolidation writes before attempting supersession; 3. a link failure leaves the summary available for reconciliation; 4. source expiry is stamped and derived summaries are not selected again; 5. reflection respects the exact-namespace watermark and `maxRecords` bound; 6. a zero-insight response writes only the superseded sentinel; 7. reflection output defaults to candidates and remains keyword-reachable; 8. batch caps report unexamined work. Keep model-output parsing tests separate from store lifecycle tests so failures identify whether selection, generation, or persistence broke. ## Related --- ### Planning # Planning Planning gives an agent route a visible todo list that can be seeded from a file, updated by the model, stored in route state, and streamed to clients. Use planning when the route regularly does multi-step work and you want the plan to be inspectable instead of buried in the model's hidden reasoning. ## Quick start Add `plan.md` next to an `agent()` route: ```text src/app/support/[tenant]/ index.ts plan.md ``` Seed it with markdown checklist items: ```md title="src/app/support/[tenant]/plan.md" - [ ] Understand the customer request - [ ] Check account context - [ ] Decide whether to answer or escalate - [ ] Write the final response ``` When the route runs, B4.run adds four things: - a `writeTodos` tool - a `todos` state channel - a planning prompt fragment - a `plan_update` stream event after `writeTodos` runs The model can then keep the plan current during the run. ## The `plan.md` file The file is route-local. Only the route with that `plan.md` gets planning. The parser reads markdown checklist lines: ```md - [ ] pending item - [x] completed item - [X] also completed ``` Other markdown is ignored. Empty checklist items are ignored. Seed items can start as `pending` or `completed`; runtime updates can also use `in_progress`. If `plan.md` exists but is empty, the route still opts into planning and starts with an empty todo list. B4.run skips seed loading for files larger than 64 KiB. The capability still exists, but the initial todo list is empty. ## The `writeTodos` tool Planning adds this capability tool: ```ts writeTodos({ todos: [ { content: "Understand the customer request", status: "completed" }, { content: "Check account context", status: "in_progress" }, { content: "Write the final response", status: "pending" }, ], }) ``` Each todo has: - `content`: non-empty string - `status`: `"pending"`, `"in_progress"`, or `"completed"` `writeTodos` is full-replace. The agent must pass the entire list every time, not just the changed item. The tool updates runtime state only. It does not write changes back to `plan.md`; that file is the seed, not a persistence target. The tool returns the new todos and updates the route state: ```ts { result: { todos }, state: { todos }, } ``` The LangChain bridge turns that into a state update so later model turns see the new list. ## State and prompts Planning contributes a `todos` state field with a `replace` reducer. Do not also declare a `todos` field in `state.ts`. B4.run treats that as a capability conflict and fails route preparation with a message telling you to remove either the state field or the planning marker file. Do not create a route-local tool named `writeTodos`. B4.run treats user tools and capability tools with the same name as a conflict. The planning prompt fragment is re-rendered with the current state. If there are todos, the model sees: ```text # Planning For tasks with multiple steps, maintain a plan using `writeTodos({ todos: [...] })`. ... Current plan: - [completed] Understand the customer request - [in_progress] Check account context - [pending] Write the final response ``` That prompt is what keeps the plan visible between tool calls. ## Streaming When `runs/stream` sees a `writeTodos` tool result, B4.run emits: ```json { "type": "plan_update", "data": { "todos": [ { "content": "Check account context", "status": "in_progress" } ], "tool_call_id": "call_writeTodos_0_1" } } ``` `tool_call_id` is the model's tool-call id for the `writeTodos` call that produced the update — the same id that call's tool events carry — and it is omitted when the model supplied no tool-call id. If a subagent emits a planning event, the parent stream forwards it with a `subagent.` prefix, such as `subagent.plan_update`. Use `/threads/:id/runs/wait` when you only need the final output. Use `/threads/:id/runs/stream` when a UI should show progress. ## Generated types `b4 typegen` includes `writeTodos` in the generated route tool types when the route directory contains `plan.md`. That makes the tool visible to route-aware TypeScript surfaces, but planning is still only applied automatically to `agent()` routes at runtime. The contributed `todos` state field is runtime state; generated route state types still come from `state.ts`. ## Under the hood Planning is implemented by `createPlanningMarker()`. During agent route preparation, B4.run detects `plan.md`, reads seed todos with the checklist parser, contributes the `writeTodos` tool, contributes the `todos` state field, and contributes a stream transformer that watches tool results. The stream transformer accepts both direct `{ todos }` outputs and LangGraph Command-shaped outputs where the update is stored at `update.todos`. That keeps planning events stable across the current LangChain bridge path. ## Related --- ### Skills # Skills Skills let a route expose longer instructions without putting all of them in the system prompt. The model sees a short list of available skills and can call `readSkill({ name })` when one is relevant. That keeps the baseline prompt smaller while still making detailed procedures available on demand. Use skills for route-local instructions that are too long or too conditional for the main `systemPrompt`: refund rules, triage procedures, coding standards for a particular workflow, or escalation playbooks. ## Quick start Create a skill under a route's `skills/` directory: ```text src/app/support/[tenant]/ index.ts skills/ refund-policy/ SKILL.md ``` Every skill file must include frontmatter with a `description`: ```md title="src/app/support/[tenant]/skills/refund-policy/SKILL.md" --- description: Instructions for refund, exchange, and exception questions. --- Use this skill when the customer asks about refunds, exchanges, credits, or exceptions. ## Process 1. Check the order date. 2. Check whether the item is final sale. 3. Offer the standard path before requesting an exception. ``` When this route runs, B4.run adds: - a `# Skills` prompt section listing `refund-policy` and its description - a `readSkill({ name })` tool that returns the body of `SKILL.md` The body is not injected until the model asks for it. ## Skill names By default, the skill name is the directory name. ```text skills/refund-policy/SKILL.md ``` creates a skill named `refund-policy`. You can override the model-facing name with frontmatter: ```md --- name: refunds description: Instructions for refund, exchange, and exception questions. --- ``` Skill names must be unique within the route. If two skills resolve to the same name, route preparation fails with a duplicate-name error. Directory names must start with a letter or number and can contain letters, numbers, underscores, and hyphens. Dotfiles, spaces, and punctuation-heavy names are ignored during discovery. ## What the model sees B4.run renders a compact prompt fragment: ```text # Skills The following skills are available. To use one, call `readSkill({ name: "" })` to load its full instructions before acting. - **refund-policy** - Instructions for refund, exchange, and exception questions. ``` Skill entries are sorted by name in the prompt. The `readSkill` tool accepts: ```ts { name: string } ``` For a known skill, it returns the markdown body after frontmatter. For an unknown skill, it returns a helpful string listing the available names. If the input shape is invalid, the tool schema validation rejects it. The full skill bodies are read during route preparation, not from disk on every `readSkill` call. ## Frontmatter requirements `description` is required because it is the only information the model sees before choosing whether to load the skill. This is valid: ```md --- description: Use for billing dispute triage. --- ... ``` This fails route preparation: ```md Use for billing dispute triage. ``` So does this: ```md --- name: billing-disputes --- ... ``` The description should be short and action-oriented. It is not the full skill. B4.run's frontmatter parser is intentionally small. Use simple `key: value` fields; do not rely on full YAML features. ## Generated types `b4 typegen` includes `readSkill` in generated route tool types when a route has at least one valid `skills//SKILL.md` file. The generated input type is: ```ts { name: string } ``` The output type is: ```ts string ``` ## Under the hood Skills are implemented by `createSkillsMarker()`. During agent route preparation, B4.run discovers skill directories, reads frontmatter and body content, contributes the `readSkill` tool, and contributes the prompt fragment. Skills do not add state fields or stream transformers. They are prompt plus tool only. The list of skills is loaded during route preparation. The full skill body is held by the tool and returned when `readSkill` runs. ## Related --- ### Subagents # Subagents Subagents let one agent route delegate a bounded task to another agent route. When at least one child is dispatchable, the parent gets an internal `task({ subagent, input })` mechanism and a `# Subagents` prompt section. The child remains a real B4.run route with its own prompt, tools, state, memory, planning, skills, and subagents. Use subagents when a specialist should own a piece of work instead of becoming another helper function in the parent prompt. Omitting `delegation` is equivalent to `delegation: { default: "allow", rules: {} }`. Both convention-discovered and explicitly registered children are dispatchable unless the parent declares a stricter policy. Use `default: "deny"` with explicit allow rules to create an allowlist. ## Quick start Put child agent routes under the parent's `subagents/` directory: ```text src/app/support/[tenant]/ index.ts subagents/ research/ index.ts tools/ searchDocs.ts ``` The child route should export an `agent()` descriptor. Give it a `description`; the parent uses that description to decide when to delegate. ```ts title="src/app/support/[tenant]/subagents/research/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", description: "Find relevant policy and product documentation before a support reply.", systemPrompt: "You research internal documentation and return concise findings.", }) ``` When the parent route runs, B4.run exposes a `task` tool. The model can call: ```ts task({ subagent: "research", input: "Find the current refund policy for annual plans.", }) ``` The parent receives the child's final text as the tool result. A child gets shared authored `src/tools/*` and its own route-local `tools/*` by default, but does not inherit the parent route's local tools. Active capability tools such as `writeFile` and `runBash` are withheld unless granted with `tools.allow`. Explicitly deny a sensitive shared authored tool, for example `tools: { deny: ["deployProd"] }`, when a child must not receive it. A child with a dispatchable child of its own receives the internal `task` mechanism independently; keep `task` outside tool policy. See [Scoping a route's tools](/docs/tools#scoping-a-routes-tools). A subagent can also declare its own `tools: { approve: [...] }` to require human approval per call on any of its tools. The resulting `kind: "tool"` interrupt surfaces on the **parent's** stream, alongside the other `subagent.*` events, not on a separate child stream. See [Per-tool approval](/docs/permissions#per-tool-approval). ## Convention discovery B4.run discovers immediate child routes at: ```text /subagents//index.ts ``` The model-facing `subagent` value is the child folder name, such as `"research"`. Only immediate children count for the parent prompt. A child can have its own `subagents/` directory, but those are available to that child, not directly to the original parent. If a child route has no `description`, B4.run lists it as `No description provided.` The route still works, but selection quality will be worse. Convention-only children receive the parent's `delegation.default` rule. They cannot have named exceptions because named rules are typed from explicit registration keys. Import and register a convention child when it needs its own rule. ## Keyed registration Register descriptors in a keyed object when you need a parent-local name or a named policy rule: ```ts title="src/app/support/[tenant]/index.ts" import { agent } from "@b4run/sdk" import researcher from "./shared-researcher/index.js" import writer from "./subagents/writer/index.js" export default agent({ model: "gpt-5-mini", systemPrompt: "You coordinate customer support work.", subagents: { policyResearch: researcher, draftWriter: writer, }, delegation: { default: "deny", rules: { policyResearch: { action: "allow" }, draftWriter: { action: "approve", reason: "Draft generation requires review.", }, }, }, }) ``` The object key is the name shown to the parent model and the identity used by delegation policy and permission persistence. It is local to that parent, so different parents can expose the same child descriptor under different names. Named `delegation.rules` keys are restricted by TypeScript to the keys in `subagents`. Registration names must match `^[A-Za-z0-9][A-Za-z0-9_-]*$`; B4.run uses the exact spelling without trimming or case folding. If an explicit descriptor points to a convention-discovered child, its explicit key replaces the folder-name identity for that parent. The convention name does not remain as an alias. This lets an explicit alias carry the named rule without leaving an ungoverned path to the same child. Array-form registration is removed. Use a keyed registry such as `subagents: { researcher, writer }`; B4.run has no array compatibility path. ## Delegation policy Each parent owns the policy for its direct outbound dispatches: ```ts delegation: { default: "deny", rules: { researcher: { action: "allow" }, writer: { action: "deny", reason: "Drafting is disabled." }, reviewer: { action: "approve", reason: "Review external input." }, }, } ``` `default` can be `"allow"`, `"deny"`, or `"approve"` and defaults to `"allow"`. A named rule can use one of four actions: | Action | Result | |---|---| | `allow` | Dispatch immediately | | `deny` | Return `[B4_E3002]` with the configured or default reason | | `approve` | Pause for a `kind: "subagent"` permission decision | | `constrain` | Evaluate the current input and return allow, deny, or approval | A constraint receives `{ input }` plus the live parent route, child name and route, thread, route parameters, and cancellation signal: ```ts import { agent, type DelegationConstraintPredicate } from "@b4run/sdk" import researcher from "./shared-researcher/index.js" const restrictResearch: DelegationConstraintPredicate = ({ input }, context) => { if (context.params?.tenant === "blocked") return "Tenant cannot delegate research." if (input.includes("external")) { return { approve: true, reason: "External research requires review." } } return true } export default agent({ model: "gpt-5-mini", systemPrompt: "Coordinate support work.", subagents: { researcher }, delegation: { rules: { researcher: { action: "constrain", predicate: restrictResearch }, }, }, }) ``` `true` allows the dispatch, a string denies it with that reason, and `{ approve: true, reason? }` enters the approval gate. A predicate that throws or returns any other value fails closed, and the child does not start. Constraints inspect the input but cannot rewrite it. Policy is evaluated again at every level. A parent rule governs only that parent's direct children; it does not authorize a child to dispatch a grandchild. The child's own `delegation` policy controls that next edge. Approval decisions also follow the exact edge. `always` persists the tuple of parent route id and parent-local subagent name. Approving `researcher` under `/support` does not approve a child with the same name under `/finance`, another child of `/support`, or a deeper dispatch. See [Subagent approval](/docs/permissions#subagent-approval). ## What the model sees B4.run renders a prompt fragment like: ```text # Subagents The following subagents are available. Call `task({ subagent, input })` to dispatch a sub-task. Use the description to choose the right subagent for each piece of work. - **research** - Find relevant policy and product documentation before a support reply. ``` The runtime `task` schema uses an enum of available parent-local names, so the model is constrained to known subagents. Statically denied children are omitted from both the prompt and schema. Allowed, approval-gated, and constrained children stay visible because a valid call may dispatch them. ## Runtime behavior At runtime, B4.run resolves the canonical registry and applies the parent's policy at the final dispatch boundary. The child receives the task as a user message: ```ts { messages: [{ role: "user", content: input }] } ``` Each dispatch runs as a per-invocation LangGraph subgraph that inherits the root thread's checkpointer. Nested and parallel children keep independent checkpoint namespaces while remaining resumable through the root thread. B4.run's dispatcher preserves depth metadata across nested calls and enforces its maximum depth of 3. Explicit registration cycles resolve lazily and are stopped by this runtime guard. ## Dispatch failures Delegation failures return coded results to the parent model: - `B4_E3002` means policy, a constraint, an approval decision, or non-interactive mode denied the dispatch. - `B4_E5003` means the requested identity is unavailable, stale, or could not be started. Invalid registration or policy configuration fails route checking or preparation with `B4_E1004`; it never falls back to allow. Underlying constraint exceptions are hidden from the model. Set `B4_DEBUG_CONSTRAINTS=1` locally to log those details with the parent and child identities. ## Streaming When the parent is run through `runs/stream`, child activity is forwarded with `subagent.*` events: - `subagent.start` - `subagent.tool_call` - `subagent.tool_result` - `subagent.message` - capability events such as `subagent.plan_update` - `subagent.end` Each forwarded event includes a generated `call_id`. `subagent.start` also includes the subagent name, route id, and depth. `subagent.end` includes either `final_message` or `error`. B4.run suppresses duplicate parent token events while a child run is active, so child tokens should appear as `subagent.message` rather than also leaking into the parent stream as ordinary message chunks. If a child reaches a tool, path, command, memory, or delegation approval, B4.run surfaces a top-level `interrupt` on the root parent's stream. Resume the root thread with the complete ID-addressed pending interrupt set; do not start or resume the child separately. See [Resuming an interrupted run](/docs/permissions#resuming-an-interrupted-run). ## Names and reserved policy fields Convention and explicit subagents share one parent-local namespace. Route preparation reports `B4_E1004` for invalid names, unresolved or ambiguous descriptors, duplicate explicit registrations of one route, and unresolved name collisions. `task` is B4.run's internal model-facing dispatch mechanism, not a public tool-policy resource. It is invalid in `tools.allow`, `tools.deny`, `tools.approve`, and `tools.constrain`; the parent's `delegation` policy is the only dispatch authority. A subagent registration key may itself be `task` because registration values use a separate namespace. Do not create a route tool named `task`. The scalar interrupt resume body is also removed; use the complete multi-entry resume envelope documented in [Permissions](/docs/permissions#resuming-an-interrupted-run). ## Related --- ### Context Management # Context Management Long agent runs accumulate messages: tool results, model replies, and subagent exchanges. Left unmanaged, that history can exceed the model's context window and cause errors or truncation. B4.run manages the context window two ways — tool-output offloading (on by default) and conversation summarization (opt-in) — so agents keep working even when a thread grows long. ## Tool-output offloading When a tool returns a large result, B4.run writes the full output to `workspace/tool-outputs/` and replaces the in-context payload with a short stub. The stub contains a configurable number of preview lines plus a file handle. When the agent needs the full content it calls `readFile` with that handle — the content is retrieved from disk and fed into the next model turn. Offloading is active as soon as a `workspace/` directory exists at the app root. No additional configuration is required to enable it. ### What the model sees The in-context stub looks like this: ```text [Tool output offloaded — 12,345 chars exceeded the 1,500-char limit. Full output saved to: tool-outputs/abc123.txt Preview (first 10 lines): line 1 … line 2 … … Read the full output with the readFile tool at the path above.] ``` The model can proceed with the preview or call `readFile` to fetch more. Because the full content lives on disk, it survives across model turns without consuming context tokens. ### Configuration ```ts title="b4.config.ts" export default { toolOutput: { offloadThresholdChars: 40000, // default previewLines: 10, // default maxBytes: 268435456, // default (256 MB) ttlMs: 10800000, // default (3 h) gcThrottleMs: 10000, // default (10 s) noOffloadTools: [], // merged with built-in exempt set }, } ``` | Key | Type | Default | Description | |---|---|---|---| | `offloadThresholdChars` | `number` | `40000` | Serialized character length above which a result is offloaded. | | `previewLines` | `number` | `10` | Number of leading lines kept in the in-context stub. | | `maxBytes` | `number` | `268435456` | Maximum total bytes stored under `workspace/tool-outputs/`. Oldest files are evicted first when the budget is exceeded. | | `ttlMs` | `number` | `10800000` | Offloaded files older than this many milliseconds are deleted. Default is 3 hours. | | `gcThrottleMs` | `number` | `10000` | Minimum milliseconds between GC scans. Default is 10 seconds. | | `noOffloadTools` | `string[]` | `[]` | Additional tool names whose output is never offloaded. Merged with the built-in exempt set (`readFile`, `listDir`). | Offloading needs a filesystem to spill to, so `toolOutput` is [gated off the `hono` edge target](/docs/deployment/edge#what-the-edge-cannot-serve) at build time, and a runtime with no filesystem raises `B4_E1005` per request rather than reading the settings and ignoring them. Summarization below has no such dependency and runs on every target. ### Exemptions `readFile` and `listDir` are always exempt from offloading. Exempting retrieval tools is required so the agent can read back offloaded content without the result being re-offloaded into a second pointer. Use `noOffloadTools` to add more tool names to the exempt set — for example, if you have a tool that already returns a compact summary. ### Garbage collection B4.run runs a GC pass (at most once per `gcThrottleMs`) that removes files older than `ttlMs` and, if the total size still exceeds `maxBytes`, deletes oldest files first until the budget is satisfied. The GC runs in the background and does not block agent turns. ## Conversation summarization Summarization compresses older message history once a thread's token count exceeds a threshold. The most recent turns stay verbatim; everything older is folded into a rolling summary that is prepended to the conversation on the next model call. Summarization is opt-in. Enable it in `b4.config.ts`: ```ts title="b4.config.ts" export default { summarization: { enabled: true, }, } ``` ### Configuration | Key | Type | Default | Description | |---|---|---|---| | `enabled` | `boolean` | `false` | Enable conversation summarization. Off by default. | | `maxTokens` | `number` | `12000` | Token count above which older history is summarized. | | `keepRecentTurns` | `number` | `6` | Most-recent turns (each starting at a `HumanMessage`) kept verbatim, never summarized. | | `model` | `string` | Route's model | Model used for the summary LLM call. Defaults to the same model the route uses. | | `tokenCounter` | `(text: string) => number \| Promise` | Lazy `gpt-tokenizer` (o200k_base) | Custom token-counting function. | | `summarize` | `(args) => Promise` | Built-in single-LLM-call summarizer | Custom summary generator. Receives `messages`, `model`, `previousSummary`, and `signal`. | The `tokenCounter` hook lets you plug in a different tokenizer. The `summarize` hook replaces the entire summary generation step — useful if you want to use a cheaper model, apply domain-specific compression, or route through a different provider. ## How they compose Offloading and summarization address different axes of context growth: - **Tool-output offloading** acts per tool result — it prevents a single large result from blowing up the context. - **Conversation summarization** acts per conversation — it compresses history that has accumulated over many turns. Both can be active at the same time. A typical configuration for a long-running research agent: ```ts title="b4.config.ts" export default { toolOutput: { offloadThresholdChars: 1500, // tight threshold for a research agent previewLines: 10, }, summarization: { enabled: true, maxTokens: 12000, keepRecentTurns: 6, }, } ``` ## Related --- ### Reasoning Effort # Reasoning Effort Reasoning effort is route-level model tuning for OpenAI-backed `agent()` routes. Use it when one agent route needs a different reasoning budget than the rest of the app. A support coordinator might stay cheap and fast, while a tool-heavy planning route might ask for deeper reasoning. ## Quick start Set `reasoning.effort` on an `agent()` descriptor: ```ts title="src/app/support/[tenant]/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", reasoning: { effort: "high" }, systemPrompt: "You are a careful support assistant.", }) ``` `ReasoningConfig` is exported from `@b4run/sdk`. ## Supported values B4.run's type surface accepts: ```ts type ReasoningEffort = | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" ``` The model provider decides which values are meaningful for a given model. Non-reasoning models ignore the setting in B4.run's current OpenAI-backed materialization path. If you omit `reasoning`, B4.run does not pass a `reasoningEffort` value. The model/provider default applies. ## Where it applies Reasoning effort applies to B4.run's `agent()` descriptor path, where `@b4run/langchain` materializes the descriptor with `ChatOpenAI`. It does not automatically affect: - `workflow` routes - raw `graph` routes - `chain` routes - models you instantiate manually inside your own graph or chain For those paths, configure reasoning on the model instance you create. ## What B4.run passes through B4.run maps: ```ts reasoning: { effort: "high" } ``` to the `ChatOpenAI` option: ```ts { reasoningEffort: "high" } ``` No prompt fragment, tool, state field, or stream event is added. Reasoning effort is descriptor configuration, not a file-based marker. ## Choosing a value Start without a value unless you have evidence that a route needs one. Use lower values for routes where latency and cost matter more than deliberation: ```ts reasoning: { effort: "minimal" } ``` Use higher values when the route is tool-heavy, planning-heavy, or asked to synthesize several pieces of evidence: ```ts reasoning: { effort: "high" } ``` Avoid using reasoning effort as a substitute for better tools or clearer route design. If the agent lacks the right context, increasing reasoning will not create it. ## Subagents Reasoning effort is per descriptor. If a parent and child route need different budgets, set them separately: ```ts // parent export default agent({ model: "gpt-5-mini", reasoning: { effort: "low" }, systemPrompt: "Coordinate the work.", }) // child export default agent({ model: "gpt-5-mini", reasoning: { effort: "high" }, systemPrompt: "Analyze the evidence carefully.", }) ``` The parent setting does not automatically override the child setting. ## Related --- ### Dev Server # Dev Server `b4 dev` is the local application loop: it starts B4.run's HTTP runtime, watches your app, regenerates types, and replaces the child runtime when source files change. Use this page to start and operate the server; use the linked protocol guides when you are building a client. ## Starting the server ```bash b4 dev ``` The server binds an ephemeral localhost port and prints a URL such as `B4.run dev ready at http://127.0.0.1:43127`. Select a stable port when another process needs a predictable address: ```bash b4 dev --port 3001 ``` The bind address is always `127.0.0.1`. ## Invoking a route With the server running, invoke a route from another terminal: ```bash echo '{"messages":[{"role":"user","content":"Hello"}]}' | b4 run '/research' --url http://127.0.0.1:3001 ``` `b4 run` resolves the route to its `#` assistant id, creates a thread id for the call, sends the input through Agent Protocol, and prints the final state. ## Restart cycle When a meaningful app file changes, B4.run regenerates types before it launches the replacement runtime. Ignored paths such as `.b4/`, `workspace/`, and lockfiles do not trigger a restart; the restart log names the reason for changes that do. The parent owns the app root, watcher, session, selected port, and stable URL across restarts; the child owns the HTTP listener and route graph. Each restart stops that child and its listener, then starts a fresh child on the same port. This is a child-process restart, not in-process HMR and not a parent-owned bound listener. The replacement child reloads the app and `b4.config.ts`, including store and runtime configuration. Configuration edits take effect on that restart; the parent does not keep the previously loaded configuration alive. The default SQLite threads store and checkpointer preserve persisted threads and checkpoints across child restarts. Configured durable stores preserve data when the replacement child reconnects; an in-memory store does not survive the child-process restart. In-flight work gets a short shutdown grace window before the old child can be force-killed. ## Logging The ready line identifies the current URL, and each restart line identifies the changed file category. Set `B4_DEV_SHUTDOWN_TIMEOUT_MS` to change the grace window used while stopping a child runtime. ## Agent Protocol endpoints The HTTP contract moved to [Agent Protocol](/docs/dev-server/agent-protocol). #### SSE event types Event table: [Agent Protocol](/docs/dev-server/agent-protocol). ### Thread lifecycle with curl [Agent Protocol](/docs/dev-server/agent-protocol) has the copyable create, run, and state sequence. ### One run at a time per thread See [Agent Protocol](/docs/dev-server/agent-protocol) for run serialization and explicit cancellation. ### Client disconnect See [Agent Protocol](/docs/dev-server/agent-protocol) for the durable viewer-disconnect policy. ## AG-UI endpoint Browser clients use the separate [AG-UI endpoint and lifecycle](/docs/ag-ui). ## Tracing See [Observability](/docs/observability) for environment variables, trace setup, and interrupt-related trace behavior. ## Middleware See [Middleware](/docs/middleware) for execution-route behavior and [Security Architecture](/docs/security-architecture) for the management routes that require service-wide outer authentication. Middleware is not the only in-runtime gate. [Thread access](/docs/thread-access) is a second one on a different axis — the thread rather than the route — and it covers every thread endpoint, including the ones middleware never sees. Where both apply they compose as AND. ## Related --- ### Agent Protocol # Agent Protocol Agent Protocol is B4.run's durable HTTP surface for threads, checkpointed runs, streaming, human-in-the-loop resume, cancellation, and memory-candidate review. Both `b4 dev` and a Node runtime started with `b4 start` expose it. ## Local quickstart Start a server on a known local port: ```bash b4 dev --port 3001 ``` Requests identify authored routes with the generated `#` key, such as `/research#agent`. B4.run execution middleware is not blanket server authentication. It covers Agent Protocol `runs/wait`, `runs/stream`, and `resume`, plus the `pending_interrupts` read. AG-UI route execution passes through the same middleware. Thread management, state, cancellation, memory-candidate management, and health routes bypass it. Put outer authentication and network restrictions around the entire service for any non-local exposure; exempt only the health probe you intend to expose. An app that adds a [thread-access policy](/docs/thread-access) authorizes every thread endpoint in the table below on a second, independent axis — the thread rather than the route — and the two gates compose as AND. That still leaves the memory-candidate and health routes covered by neither. ## Agent Protocol endpoints | Method and path | Request | Success | |---|---|---| | `POST /threads` | Optional `{ "metadata": { ... } }` body | `200` thread object | | `GET /threads/:thread_id` | — | `200` thread object; `404` when absent | | `DELETE /threads/:thread_id` | — | `204`; deletes thread metadata, supported checkpoints, and its sandbox sequentially | | `GET /threads/:thread_id/state` | — | `200 { config, created_at, metadata, next, parent_config, values }`; `404` without a checkpoint | | `GET /threads/:thread_id/pending_interrupts` | — | `200 { "interrupts": [...] }`; `404` `thread_not_found`; `409` `thread_route_unknown` without a usable route identity | | `POST /threads/:thread_id/runs/wait` | `{ "route": "#", "input": { ... } }` | `200` final state JSON | | `POST /threads/:thread_id/runs/stream` | Same run body | `200 text/event-stream` | | `GET /threads/:thread_id/runs/stream` | — | `200 text/event-stream` (reattach); `404` `thread_not_found`; `409` `thread_route_unknown` without a usable route identity | | `POST /threads/:thread_id/resume` | Exact `{ "resume": [...], "route": "#" }` body | `200 text/event-stream` continuation | | `POST /threads/:thread_id/cancel` | No body | `200 { "thread_id", "status": "interrupted" }`; `404` unknown thread; `409` no active run | | `GET /memory/candidates` | — | `200 { "candidates": [...] }` | | `POST /memory/candidates/:id/approve` | No body | `200 { "record", "action", "superseded" }` | | `POST /memory/candidates/:id/reject` | No body | `200 { "ok": true }` | A run body requires `route`; `input` is optional and defaults to `{}`. A bare route id without `#` is not a registered assistant id and returns `404`. The `{ route, input }` envelope is shared by B4.run's dev, Node, and Hono HTTP runtimes. It is not the LangSmith request envelope, which uses `assistant_id`. ## Thread lifecycle with curl This copyable sequence creates a thread, waits for a route, then reads its latest checkpoint. It uses `jq` only to extract the returned thread id. ```bash BASE_URL=http://127.0.0.1:3001 THREAD_ID=$(curl -sS -X POST "$BASE_URL/threads" \ -H 'content-type: application/json' \ -d '{}' | jq -r '.thread_id') curl -sS -X POST "$BASE_URL/threads/$THREAD_ID/runs/wait" \ -H 'content-type: application/json' \ -d '{ "route": "/research#agent", "input": { "messages": [{ "role": "user", "content": "Explain checkpoints briefly." }] } }' curl -sS "$BASE_URL/threads/$THREAD_ID/state" ``` Runs create the named thread if it does not exist, but creating it explicitly is useful when you need metadata or want to distinguish setup from execution. ## Streaming over SSE `runs/stream` and `resume` return Server-Sent Events. The `event:` line is the runtime chunk type. The `data:` line is the chunk payload serialized directly as JSON—not a wrapper containing the full chunk. ```text event: chunk data: "partial text" event: tool_call data: {"id":"call-1","name":"search","input":{"query":"B4.run"}} event: done data: {"output":{"messages":[]}} ``` While a stream is quiet, B4.run sends the SSE comment below every 15 seconds by default. SSE clients ignore comment frames; intermediaries see activity. ```text : ping ``` ## Interrupt and resume A permission pause arrives as an `interrupt` event whose raw JSON data includes the public `interruptId` and permission details: ```text event: interrupt data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","detail":{"command":"ls","suggestedPattern":"ls"}} ``` Resume every pending interrupt on the root thread in one request: ```bash curl -N -X POST "$BASE_URL/threads/$THREAD_ID/resume" \ -H 'content-type: application/json' \ -d '{ "resume": [ { "interruptId": "perm-abc123", "status": "resolved", "payload": "once" }, { "interruptId": "perm-def456", "status": "cancelled" } ], "route": "/research#agent" }' ``` The body accepts exactly `resume` and `route`. A resolved entry accepts exactly `interruptId`, `status`, and a `payload` of `"once"`, `"always"`, or `"deny"`. A cancelled entry accepts only `interruptId` and `status` and maps to denial. The array must contain every pending public interrupt id exactly once: stale, partial, duplicate, or extra sets return `409`. Nested subagent interrupts are still addressed through the root thread. The removed scalar `{ interrupt_id, decision }` form returns `400`. Only one Agent Protocol or AG-UI resume can consume a thread's pending snapshot at a time. The resume claim is acquired before the run registry, so a concurrent resume returns `409` with `error.details.code` set to `resume_in_progress`. Other attempts to start work while the thread's run slot is occupied return `run_in_flight` instead. Although `route` is required in the request body, it does not normally select a new route for a parked thread. B4.run resolves the route from the in-process thread-route map first, then persisted thread metadata. The body route is the last fallback. Changing it does not redirect a parked thread while either recorded route exists. ### Recovering prompts without a live stream A client that reloaded has no stream left to read the `interrupt` event from. `GET /threads/:thread_id/pending_interrupts` returns the prompts still parked on a thread, which is enough to put the permission UI back on screen: ```bash curl -sS "$BASE_URL/threads/$THREAD_ID/pending_interrupts" # {"interrupts":[{"interruptId":"perm-abc123","resumeKey":"...","value":{...}}]} ``` `value` is the interrupt payload as stored in the checkpoint—for a permission prompt, `{ interruptId, type, kind, detail }`. It is the same payload the `interrupt` event carries, minus what the stream projection adds on the way out: a prompt raised inside a subagent picks up a `callId` on the wire naming the parent's subagent tool call, and the stored copy has no such field. `resumeKey` is the checkpoint write's own key, or `null` when the write carries no usable one; clients address prompts by `interruptId`. A thread with nothing parked answers `200` with an empty array. Responses are sent `cache-control: no-store`, because checkpoint state moves under the client. Answer the prompts with the same `POST /threads/:thread_id/resume` body shown above: exactly `{ resume, route }`, with `route` required even though the server prefers its own recorded route. A reloaded client that no longer knows the route reads it from `GET /threads/:thread_id`, whose `metadata.route` is the route key recorded at the start of every run on the thread. Unlike every other gated endpoint, this one is gated on an identity the caller cannot repoint: the route that *parked* the interrupts, recorded as `metadata.parked_route` when a turn parks and retired once the last prompt is answered. Gating on the last-run route alone would let a caller allowed to run some cheaper route start a run, move the thread's route identity, and read a prompt that route never raised. Only when no parking route is recorded does resolution fall back to the last-run chain—the in-process map, then `metadata.route`. A thread carrying none of those has no identity to gate on, so it is refused rather than served: this endpoint hands back the same interrupt payloads a run stream does. That is the ordinary state of a thread created but never run, and it returns `409` with `error.details.code` set to `thread_route_unknown`. The same code answers a thread whose recorded route is no longer registered, and the response deliberately does not name that route. An unknown thread returns `404` with `thread_not_found`. ## One run at a time per thread B4.run admits one active run per thread. An ordinary run-slot collision—such as a competing `runs/wait` or `runs/stream`, or a resume colliding with a non-resume run—returns `409` with `error.details.code` set to `run_in_flight`. A second concurrent resume is stopped by the earlier resume claim and returns `resume_in_progress`. The run registry is in-memory and process-local; persisted thread status does not provide distributed serialization. Cancel the active run explicitly: ```bash curl -sS -X POST "$BASE_URL/threads/$THREAD_ID/cancel" # {"status":"interrupted","thread_id":"..."} ``` The cancel endpoint returns `404` with `thread_not_found` for an unknown thread and `409` with `no_run_in_flight` when the thread exists on this process but no run is active. Cancellation keeps checkpointed state; it does not roll back. Cancellation is reported differently after execution has begun. A cancelled SSE run or resume ends in band with: ```text event: done data: {"output":{"cancelled":true}} ``` A cancelled blocking `runs/wait` has not committed a response, so it returns `409` with `error.details.code` set to `run_cancelled`. A route failure instead ends a stream with a `done` payload containing `output.error`. ### `interrupted` covers cancelled and parked `GET /threads/:thread_id` reports `status: "interrupted"` for a run stopped by `POST /threads/:thread_id/cancel` and for a turn parked on a human-in-the-loop interrupt alike. `pending_interrupts` is the discriminator: a non-empty `interrupts` array means the thread is waiting on a human, and an empty one means it is not—the run was cancelled, or the prompts have already been answered. Parked turns previously reported `"idle"`, which a reloaded client could not tell from a finished run. The streaming endpoints `runs/stream` and `resume` report the parked status. `runs/wait` is a blocking JSON call and still returns its thread to `"idle"` when its turn parks, so check `pending_interrupts` there rather than the thread status. ## Client disconnect Disconnecting an Agent Protocol `runs/stream`, `runs/wait`, or `resume` client only detaches that viewer; the checkpointed run continues. To stop the intent, call `POST /threads/:thread_id/cancel`. Server shutdown also aborts active work. A reconnecting client rejoins the run with `GET /threads/:thread_id/runs/stream` (below) rather than polling `GET /threads/:thread_id/state`. Because run admission and cancel routing are process-local, a multi-replica service needs guaranteed thread-keyed routing to one process or distributed per-thread serialization and cancel routing. Shared Postgres stores add durability, not that coordination. ## Reattaching to a running turn `GET /threads/:thread_id/runs/stream` is the read-only mirror of the `POST` stream: a disconnected client — browser reload, network blip, laptop sleep — rejoins without cancelling anything and without a request body, so it is the one Agent Protocol stream a stock `EventSource` can consume. It requires thread-access `read` and middleware approval for every recorded route whose content it discloses: the selected live producer, its anchor, and the parked/last-run routes. Selection stays fixed while middleware runs. Checkpoint owners come from runtime-written metadata bound to the exact checkpoint ID, including verified ancestor routes whose state a later turn may inherit. Client-written thread metadata cannot establish checkpoint ownership. A checkpoint without verified provenance, including a legacy checkpoint, fails closed with `409 thread_route_unknown`. Running another turn does not clear unknown ancestry. Start a fresh thread to establish verified provenance; no checkpoint or conversation history is deleted automatically. Reattachment is resumable **state**, not a resumable event log: there are no cursors, no `Last-Event-ID`, and no retention window. Every reconnect re-snapshots, and every failure heals by reconnecting. The stream always opens with a single `event: state` frame — a B4 extension to the Agent Protocol wire — carrying `status`, `values` (channel values), `interrupts`, and, when a turn is streaming in this process, the turn's frames so far: ```text event: state data: {"status":"busy","live":true,"anchor":"","run_started_at":"", "resume":false,"values":{...},"input":{...},"turn":[...],"interrupts":[]} ``` - **Live turn present (`live: true`):** `values` is the checkpoint at `anchor` (the instant the run claimed its slot), `turn` is the turn's coalesced frames so far, and the frame is followed by the live tail — the same `chunk` / `tool_call` / `tool_result` / `interrupt` / capability frames the `POST` stream emits — terminated by the turn's own `done`. `interrupts` is always `[]` here: a live turn is not parked, and during a `resume` turn `resume` is `true` and the client must not apply `input` to the transcript. If the in-memory digest overflowed, `turn` is `null` and `turn_truncated: true` is present — reconnect for a fresh snapshot. - **No live turn (`live: false`):** the durable path. `values` is the latest checkpoint, `interrupts` carries the parked prompts with their `value`, and the stream then emits a `retry:` hint followed by `event: done` `{"output":null}` before closing. This path works across restarts, replicas, and serverless, because it is checkpoint-backed. Live tail is best-effort and only available on the process holding the run. Consumers **must** treat `done` and `detached` as end-of-stream. Canceling an attach body or aborting its request releases the viewer without stopping the producer. A slow viewer is detached with `{"reason":"overflow"}` when its bounded queue fills. Heartbeats respect response backpressure. Two bounds protect the in-memory hub: the per-thread digest is capped at 2 MiB (on overflow the turn digest is dropped whole and the snapshot degrades to values-plus-live-tail), and each thread serves at most 16 concurrent viewers (a viewer beyond the cap receives `event: detached` `{"reason":"capacity"}` and closes). Both are fixed defaults today; neither the durable snapshot nor cancellation is affected by reaching them. ## Review memory candidates `GET /memory/candidates` lists candidates across every memory namespace. Approval uses identity-aware reconciliation and reports an `action` of `activated`, `superseded`, or `deduped`; it returns `404` for a missing record and `409` when the record is not a candidate. Rejection deletes the record. Candidate listing spans namespaces, while approve and reject are destructive mutations. All three management routes bypass B4.run execution middleware. Apply outer authentication, tenant authorization, and audit controls before exposing them beyond a trusted local environment. ## Production topology The Node runtime exposes the same B4.run request envelope, but production needs more than replacing `b4 dev` with `b4 start`: configure durable stores, outer authentication, network policy, health behavior, and replica coordination. See [Production Topology](/docs/production-topology), [Persistence and Tenancy](/docs/persistence), and [Security Architecture](/docs/security-architecture). ## AG-UI is a different client surface AG-UI uses `POST /agui/{routeId}` with an encoded assistant id and an AG-UI `RunAgentInput`, then translates B4.run chunks into AG-UI events. It also has the opposite disconnect policy: the ephemeral run aborts when its viewer disconnects and there is no event replay. See [AG-UI and Web Clients](/docs/ag-ui) for that endpoint and lifecycle. ## Related --- ### Middleware # Middleware B4.run supports a single global request middleware for authentication, request shaping, and per-request context. The middleware runs once per route-execution request, before the route executes, and its decision (allow or reject) gates execution. ## File location Define middleware as a default-exported function in `src/middleware.ts` (or `middleware.ts` at the app root): ```ts title="src/middleware.ts" import { allow, defineMiddleware, reject } from "@b4run/sdk" export default defineMiddleware(async (req) => { if (!req.headers["x-api-key"]) { return reject(401, { error: "Missing x-api-key" }) } return allow() }) ``` If no middleware file is present, every request is allowed. B4.run probes four paths, in this order, and the first one that **exists** is the one it loads: `src/middleware.ts`, `src/middleware.js`, `middleware.ts`, `middleware.js`. A later candidate is never a fallback for an earlier one that fails to load — see [When middleware fails to load](#when-middleware-fails-to-load). ## API ### `defineMiddleware(fn)` Identity helper that types `fn` as `B4Middleware`. Use it for editor inference: ```ts type B4Middleware = ( req: MiddlewareRequest, ) => Promise | MiddlewareResult ``` ### `MiddlewareRequest` The argument every middleware receives. All values are pre-parsed: | Field | Shape | Notes | |---|---|---| | `headers` | `Readonly>` | Lowercase keys (Node convention). Multi-value headers joined with `, `. | | `params` | `Readonly>` | Dynamic-segment values extracted from the request input, e.g. `{ tenant: "acme" }` for `/hello/[tenant]`. Always `{}` on `/resume` and `/pending_interrupts` — see [Where middleware runs](#where-middleware-runs). | | `routeId` | `string` | E.g. `"/hello/[tenant]"`. | | `assistantId` | `string` | E.g. `"/hello/[tenant]#agent"`. | | `method` | `string` | HTTP method. `"POST"` on every gated endpoint except `/pending_interrupts`, which is a `"GET"`. | | `url` | `string` | Path + query, e.g. `"/threads/t-1/runs/wait"`. | ### `reject(status, body?)` Stops execution and responds with the given HTTP status. Optional `body` is JSON-encoded into the response. ```ts return reject(401, { error: "Unauthorized" }) return reject(403) // body omitted ``` ### `allow(context?)` Lets the request proceed. Optional `context` is a record of arbitrary values that flows into every tool invocation as `ctx.middleware`. See [Context flow to tools](#context-flow-to-tools) below. ```ts return allow() return allow({ userId, plan: "pro" }) ``` ## Context flow to tools Whatever you pass to `allow({ ... })` is delivered to every tool call for that request via the second argument: ```ts title="src/middleware.ts" export default defineMiddleware(async (req) => { const userId = await verifyJwt(req.headers.authorization) return allow({ userId }) }) ``` ```ts title="src/app/(public)/hello/[tenant]/tools/lookup.ts" export default async ( input: { readonly query: string }, ctx: { readonly middleware?: Readonly>; readonly signal: AbortSignal }, ) => { const userId = ctx.middleware?.userId as string | undefined return await db.search(userId, input.query) } ``` Context is per-request — there is no shared state between requests. The `middleware` field is `undefined` if no middleware is defined, or if the middleware called `allow()` without arguments. ## Single-function model B4.run middleware is intentionally a single global function, not an array of layered middlewares. If you need branching by route, do it with normal control flow inside the function: ```ts export default defineMiddleware(async (req) => { if (req.routeId.startsWith("/admin/")) { return await requireAdmin(req) } return await requireApiKey(req) }) ``` This keeps the request lifecycle predictable and avoids ordering questions about per-route middleware. ## Where middleware runs Middleware runs in every B4.run HTTP runtime: `b4 dev`, the Node runtime served by `b4 start` or the generated `server.mjs`, and Hono builds. These endpoints invoke it before any route executes: | Endpoint | `req.method` | `req.params` | |---|---|---| | `/threads/:id/runs/wait` | `"POST"` | Dynamic segments read from the request input. | | `/threads/:id/runs/stream` | `"POST"` | Dynamic segments read from the request input. | | `/threads/:id/resume` | `"POST"` | Always `{}` — a resume body carries decisions, not route input. | | `/threads/:id/pending_interrupts` | `"GET"` | Always `{}` — a `GET` has no body to read them from. | | `/agui/:routeId` | `"POST"` | Dynamic segments read from the AG-UI run input. | Middleware is not the only gate on those five. An app that has a [thread-access policy](/docs/thread-access) also authorizes each of them against the thread they name, and the two compose as AND: middleware answers "may this caller run this route", the policy answers "may this caller touch this thread", and a request needs both to pass. An app with no policy file is gated by middleware alone, exactly as before. The endpoints middleware does **not** see are `/healthz` (a liveness probe), thread create, read and delete, `GET /threads/:id/state`, `POST /threads/:id/cancel`, and the memory-candidate endpoints. Of those, every thread endpoint is on the thread-access policy — so "no middleware" no longer means "ungated". `/healthz` and the memory-candidate endpoints have no gate of either kind; put an outer boundary around them. Two of the five resolve their route identity from the thread itself, so their thread-access check runs **before** middleware and answers first: `/resume` and `/pending_interrupts`. On those, a caller middleware would have refused with a `401` receives the policy's deny instead. See [the run endpoints and middleware](/docs/thread-access#the-run-endpoints-and-middleware). `GET /threads/:id/pending_interrupts` hands back the human-in-the-loop prompts parked on a thread, so it is gated like a run — and it is the first gated endpoint whose `req.method` is not `"POST"`. Middleware that branches on `req.method === "POST"`, or that reads `req.params` to decide access, falls through on it. See [the endpoint reference](/docs/dev-server/agent-protocol#recovering-prompts-without-a-live-stream) for how its route identity is resolved. It also runs on Windows. Earlier versions handed the raw filesystem path to Node's ESM loader, which rejects a drive letter as an unknown URL protocol, and the resulting failure was swallowed — so middleware never ran there at all. If you develop on Windows, expect a middleware file that was previously inert to start gating requests. The `langsmith` build target is different: its generated graph entries do not include B4.run HTTP middleware. Put equivalent authentication at the LangSmith/platform boundary when you deploy those entries. Do not use this hook as blanket service authentication. Create/read/delete, state, cancellation, health, and memory-candidate routes bypass it. Thread authorization is a [separate file on a separate axis](/docs/thread-access) and covers the thread endpoints; health and memory-candidate routes are covered by neither. Put an outer boundary around the complete runtime surface as described in [Security Architecture](/docs/security-architecture). ## When middleware fails to load Middleware is an authorization gate, so B4.run refuses to start rather than start without one it was supposed to have. A middleware file that is present but cannot be loaded fails the boot with `B4_E3004`, naming the file and the underlying cause: ``` Middleware at /app/src/middleware.ts failed to import, so every endpoint it gates would run ungated. Fix the file, or delete it if this app has no middleware. Error: JWT_SECRET is not set ``` This covers a middleware file that throws while it is being imported — a missing environment variable, an ESM/CJS interop break, a syntax error, an unresolved dependency — and a file B4.run cannot even probe, such as one inside a directory it lacks permission to read. Existence is decided before the import, so a permission error is never mistaken for "this app has no middleware". Three consequences worth knowing: - **An app with no middleware file is unaffected.** All four candidates are definitively absent, so the boot proceeds with every request allowed, exactly as before. - **The first existing candidate is the only candidate.** If `src/middleware.ts` exists but fails to import, B4.run does not quietly fall back to a `middleware.ts` at the app root. - **A file that exports no middleware function is not fatal.** It is ignored, with a warning on stderr naming the file, because the built manifest binds the same way and dev must not diverge from it. In `b4 dev` the failure is printed and the watcher restarts the child once you fix the file. In `b4 start`, a container, or a built `server.mjs`, the process exits non-zero — so the failure surfaces to your deploy's health check before traffic reaches an ungated server. ## Errors thrown from middleware This is the separate case of a middleware function that loads fine and then throws *while handling a request*. That request is rejected with HTTP `500`; the server keeps running. Prefer explicit `reject(status, body?)` over throwing — it gives users a meaningful response. ## Related --- ### AG-UI and Web Clients # AG-UI and Web Clients B4.run exposes [AG-UI](https://github.com/ag-ui-protocol/ag-ui) for browser clients alongside Agent Protocol. `@b4run/ag-ui` is the pure translation library: it maps B4.run stream chunks to AG-UI events and maps `RunAgentInput` back to a B4.run-shaped run input. The package owns no server, transport, or agent runtime. `b4 dev` and the production runtime started by `b4 start` use that adapter to serve an SSE endpoint. This page is the canonical request, resume, threading, and lifecycle guide. See [Middleware](/docs/middleware) for execution gating and [Security Architecture](/docs/security-architecture) for the service routes that require outer authentication. ## The endpoint ```http POST /agui/{routeId} content-type: application/json accept: text/event-stream ``` The URL segment is the URL-encoded `#` assistant id. For example, `/chat#agent` becomes `/agui/%2Fchat%23agent`. The request body is an AG-UI `RunAgentInput`. B4.run creates an unknown thread, invokes the same route runtime used by Agent Protocol, and streams translated events back as SSE. ## Consuming it from a web UI `examples/chat/web` is the canonical reference client. Its CopilotKit runtime registers an `HttpAgent` pointed at B4.run's encoded route URL. The browser talks to the Next.js runtime route, while only the B4.run server holds model credentials. ```text browser -> CopilotKit runtime -> HttpAgent -> POST /agui/%2Fchat%23agent -> B4.run route runtime -> AG-UI event stream ``` CopilotKit's sidebar uses the literal agent id `default` when none is supplied, so the example registers the B4.run route under that key. See `examples/chat/web/README.md` for the complete setup and smoke checklist. That client renders plan and researcher cards by passing the adapter's own renderer array to CopilotKit: ```tsx import "@b4run/ag-ui/react/styles.css" import { CopilotKit } from "@copilotkit/react-core/v2" import { b4ActivityRenderers } from "@b4run/ag-ui/react" ``` The stylesheet import gives the cards B4.run's default appearance in light and dark; without it, the cards still render but with structured, unstyled markup. Override its `--b4-activity-*` CSS custom properties to restyle, or pass `classNames`/`components` to the card components for deeper customization — see the [package README](https://github.com/cacheplane/b4run/blob/main/packages/ag-ui/README.md#customizing-the-activity-cards) for the full ladder. React and `@copilotkit/react-core` are optional peer dependencies, so a server-only consumer of the root or `/sse` entry installs nothing extra. The subpath also exports the two renderers individually, the card components, and the content schemas, for a client presenting the activities its own way. For the full application wiring — a thread rail, suggestions, generic tool cards, and permissions — see the [research web UI recipe](/docs/recipes/research-web-ui) and its [`examples/research/web`](https://github.com/cacheplane/b4run/tree/main/examples/research/web) implementation. ## Adapter API The root package exports the transport-independent mapping surface: ```ts import { fromRunAgentInput, toAguiEvents } from "@b4run/ag-ui" const b4Input = fromRunAgentInput(runAgentInput) for await (const event of toAguiEvents(b4Chunks, { threadId, runId })) { // Send `event` through the transport chosen by the application. } ``` Transport helpers are isolated behind subpaths. An SSE server can encode an event without expanding the root API: ```ts import { encodeAgUiSse } from "@b4run/ag-ui/sse" response.write(encodeAgUiSse(event, request.headers.accept)) ``` ### Outbound events `toAguiEvents(chunks, context)` is a stateful async generator. It frames B4.run's implicit assistant text and preserves upstream tool-call ids for result correlation. | B4.run chunk | AG-UI event(s) | |---|---| | stream start | `RUN_STARTED` | | `token` | `TEXT_MESSAGE_START` once, then `TEXT_MESSAGE_CONTENT` per delta | | `tool_call` | close open text, then `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END` | | `tool_result` | close open text, then `TOOL_CALL_RESULT` | | `tool_call` and `tool_result` for a `writeTodos` or `task` call whose activity was emitted | no `TOOL_CALL_*` events; the activity is the whole presentation | | root `plan_update` | replacement `ACTIVITY_SNAPSHOT` with activity type `b4.plan` | | `subagent.start` and matching child plan/tool/result/end chunks | replacement `ACTIVITY_SNAPSHOT` with activity type `b4.subagent` | | `interrupt` | terminal `RUN_FINISHED` with `outcome.type: "interrupt"` | | `done` | terminal `RUN_FINISHED` with `outcome.type: "success"` | | upstream error | terminal `RUN_ERROR` | ### Activity snapshots Activities use standard AG-UI framing and complete replacement snapshots. A root plan has stable message id `b4:plan:${runId}`, activity type `b4.plan`, and content containing only the complete todo list. Todo status is exactly `pending`, `in_progress`, or `completed`. B4.run emits no seeded-plan activity at run start; the first snapshot follows a valid root `plan_update`. Each subagent has stable message id `b4:subagent:${call_id}`, activity type `b4.subagent`, and complete content with its name, positive integer depth, `running`/`completed`/`failed` status, an optional current todo list, up to five recent child-tool name/status summaries, and the total observed tool count. Tool-summary status is exactly `running`, `completed`, or `incomplete`. A failed activity can also contain a human-readable error capped at 400 characters. Because every event has `replace: true`, later snapshots replace the same stable activity message. The adapter validates the full internal identity `{ call_id, subagent, route_id, depth }` on every recognized child event. Only an exact match with the original `subagent.start` can update its activity; `call_id` is used only to form the stable standard message id, while `route_id` and child tool ids remain internal. None is duplicated in public content. `subagent.message` is consumed without emission. This boundary excludes child reasoning and prose, prompts, tool inputs, tool outputs, final child answers, route ids, and raw runtime ids. It is an explicit allowlist rather than a generic capability mapping: unknown capability chunks retain the existing behavior of closing any open text message and then being ignored. B4.run emits neither activity deltas nor a raw advanced child stream. Activities are informational; standard interrupt UI remains the only place to resolve or cancel permission requests. No queued, waiting, cancelled, or parent-task correlation state is inferred. ### Canonical orchestration presentation B4.run's two built-in orchestration actions are presented once. When a `writeTodos` call produced the `b4.plan` activity, or a `task` call produced the `b4.subagent` activity because delegation actually started, the adapter emits no `TOOL_CALL_START`, `TOOL_CALL_ARGS`, `TOOL_CALL_END`, or `TOOL_CALL_RESULT` for that call. The chunk behind the activity carries the same tool-call id as the model's call (`plan_update.tool_call_id`, and the subagent's `call_id`); that correlation is internal and decides the suppression. It is not republished in activity content, which still excludes raw runtime ids. Only `b4.subagent` exposes the id at all, inside its stable `messageId`. Every other tool is unchanged. `recall`, `searchCorpus`, `readDoc`, `writeFile`, `runBash`, and any application tool still produce ordinary tool events. The rule fails open: whenever suppression cannot be proven, the generic frames are preserved and a client sees the ordinary tool card exactly as before. That includes a call with no tool-call id, a colliding tool-call id, a malformed activity payload, a resolver failure that means `subagent.start` never arrives, a result that arrives with no correlation, buffer limits, and a stream that ends early. At a permission interrupt belonging to the held call, the held frames are dropped rather than flushed, because the resumed run re-presents the same call under the same tool-call id and the client converges the two into one card. Unrelated held calls still flush before the interrupt. A client that registers no activity renderer sees less for `writeTodos` and `task` than a generic tool renderer used to show. That is the contract: activities are canonical for built-in orchestration, so render them. For a React client, that is one line — pass `b4ActivityRenderers` from `@b4run/ag-ui/react` to CopilotKit's `renderActivityMessages`. ### Inbound input `fromRunAgentInput(input)` translates every AG-UI message and preserves the original request as `raw`, where consumers can inspect tools, state, and context. The CLI endpoint forwards only the newest user message because B4.run owns the checkpointed conversation for the thread. The standard top-level `RunAgentInput.resume` array is preserved as vocabulary-neutral B4.run resume requests: ```ts resume?: Array<{ interruptId: string status: "resolved" | "cancelled" payload?: unknown }> ``` For a permission prompt, a resolved payload can carry a B4.run decision such as `"once"` or `"always"`; cancelling the interrupt maps to denial at the runtime boundary. Every answer must address one currently pending interrupt. ## Threading AG-UI's `threadId` is the B4.run thread id; there is no separate mapping table. AG-UI and Agent Protocol requests execute the same route code and use the same thread store and checkpointer. A caller can inspect state through Agent Protocol while a UI drives later turns through AG-UI. A turn that parks on a permission prompt leaves the thread `interrupted` rather than `idle`, on both surfaces. The park is written to the checkpointer, so it outlives the response that carried it: a client reading `GET /threads/:thread_id` after a reload sees that a human is still being waited on instead of being told the agent finished. They also share the same process-local one-active-run-per-thread gate. An ordinary run-slot collision—an AG-UI turn or Agent Protocol run that reaches an occupied slot—returns `409` with `error.details.code` set to `run_in_flight`. A second concurrent resume is stopped by the shared resume claim before it reaches the run registry and returns `resume_in_progress`. Shared durable stores do not distribute that coordination across replicas. ## Middleware and service authentication B4.run execution middleware gates AG-UI route execution just as it gates Agent Protocol execution. It can reject the request or pass context into authored tools. It is not service-wide authentication: thread management, state, cancellation, memory-candidate management, and health routes bypass it. See [Middleware](/docs/middleware) for the request contract and [Security Architecture](/docs/security-architecture) before exposing the runtime outside a trusted local environment. ## Disconnect and reconnect Each POST owns one SSE response. B4.run does not buffer emitted AG-UI events or support last-event-id replay. If the connection drops, B4.run aborts that ephemeral AG-UI run; the client cannot reattach to its event stream. A later request against the same thread starts a new run from the latest completed checkpoint. Aborting the run does not discard a park it already reached. A permission prompt raised before the disconnect is durable, so the thread stays `interrupted` and the next request can resume it. The two surfaces differ here. Agent Protocol viewer disconnects continue the run; AG-UI viewer disconnects abort the run. For Agent Protocol, closing a `runs/stream`, `runs/wait`, or `resume` connection only detaches the viewer, so stop the run explicitly with `POST /threads/:thread_id/cancel`. Server shutdown also aborts in-flight work. ## Related --- ### Embed the Runtime # Embed the Runtime B4.run can own a standalone Node listener or provide a web-standard fetch handler inside a host you already operate. Choose the highest-level stable export that gives the host the lifecycle control it needs. ## Choose standalone or embedded Use `b4 start` or the generated Node server when B4.run can own the listener. Use `serveRuntime` when application code should construct and close that listener. Use `createRuntimeFetchHandler` when an existing server, worker, test harness, or Hono application owns transport and dependency lifetimes. Embedding does not change the rooted B4.run endpoint paths or widen middleware coverage. The host still owns blanket authentication, tenant authorization, proxy behavior, readiness, and process shutdown. ## Own a Node server with serveRuntime `serveRuntime` and `loadStaticModules` are stable exports from the package root: ```ts title="server.ts" import { loadStaticModules, serveRuntime } from "@b4run/cli" const modules = await loadStaticModules( new URL("./.b4/build/modules.mjs", import.meta.url), ) const runtime = await serveRuntime({ appRoot: process.cwd(), modules, host: "0.0.0.0", port: 8000, // Set this only when the host delegates SIGINT/SIGTERM handling to B4.run. installSignalHandlers: true, }) console.log(`B4.run listening on ${runtime.url}`) ``` `serveRuntime` defaults `installSignalHandlers` to `false`. A larger host that already coordinates several servers should keep signal ownership at that layer and call `await runtime.close()` in its own ordered shutdown path. `b4 start` opts in; the currently generated Node `server.mjs` does not. ## Compose the fetch runtime The edge-safe entry is `@b4run/cli/fetch`. It performs no route discovery and has no filesystem or SQLite fallback, so supply the generated edge manifest, serializable config, and every store your exposed endpoints require. The example below is an **advanced lifecycle/store skeleton**, not a complete production edge/model host. It intentionally omits generated-equivalent `seedModelImporter` wiring, literal provider imports, `seedRuntimeEnv` binding seeding, and the complete serialized configuration. A model route can bundle incorrectly or read the wrong environment if those responsibilities are absent. For production edge deployment, use the generated `.b4/build/app.mjs`; if a host must replace it, inspect that artifact and reproduce all of its generated responsibilities in addition to this lifecycle pattern. ```ts title="runtime.ts" import { createRuntimeFetchHandler } from "@b4run/cli/fetch" import modules from "./.b4/build/modules.edge.mjs" import { createApplicationRequestStores, permissionPolicy, } from "./request-stores.js" type Env = { readonly DATABASE_URL?: string } const APP_ROOT = "/my-app" const envByRequest = new WeakMap() let handlerPromise: ReturnType | undefined const requestStores = (request: Request) => { const env = envByRequest.get(request) if (!env) throw new Error("No environment is bound to this request") return createApplicationRequestStores(env, permissionPolicy) } export default { async fetch(request: Request, env: Env) { // Bind this invocation before B4.run calls requestStores during dispatch. envByRequest.set(request, env) handlerPromise ??= createRuntimeFetchHandler({ appRoot: APP_ROOT, modules, config: { build: { targets: ["hono"] } }, requestStores, }).catch((error) => { // Do not cache a failed construction for the isolate's lifetime. handlerPromise = undefined throw error }) const handler = await handlerPromise return handler.fetch(request) }, } ``` Construct the handler lazily inside the first request. Handler construction creates an `AbortController`; workerd does not permit that I/O-associated object to be created in module/global scope. Keep only the promise and plain binding map at module scope. Binding environment by the incoming `Request` also prevents later requests from accidentally reusing the first request's database environment. Reset the memoized promise when construction rejects so a later invocation can retry. `"/my-app"` is a readable placeholder, not an arbitrary namespace choice. `APP_ROOT` must exactly match the rooted namespace baked into `modules.edge.mjs`, currently `/`. A mismatch splits the manifest's route/cache identity from the handler identity. `requestStores` may return a checkpointer, threads store, permissions store, memory store, and `dispose`. A matching boot-supplied instance is the alternative when that resource can safely live for the handler's lifetime. A store supplied by `requestStores` is used as-is; B4.run does not call `load()` on it and does not reapply sibling `permissions.mode`, `permissions.allow`, or `permissions.deny` from `b4.config.ts`. Reaching an omitted required store fails loudly rather than opening local SQLite. ## Compose with Hono The `hono` build target emits `.b4/build/app.mjs`, a Hono app with rooted B4.run routes and request-scoped store wiring. Compose it with Hono's router so the original `Request` object and its environment binding are preserved: ```ts title="host.ts" import { Hono } from "hono" import b4App from "./.b4/build/app.mjs" const app = new Hono() app.use("*", authenticateAndAuthorize) app.route("/", b4App) export default app ``` The generated app has no B4.run base-path option. Do not place it under a `/b4` prefix or use Hono's request-rebuilding mount helper: B4.run's routes are rooted, and the emitted per-request environment lookup is keyed by the original request. Keep `app.route("/", b4App)` exact. ## Lower-level tooling surface `@b4run/cli/runtime` is a lower-level tooling surface used by B4.run's testing and internal runtime integrations. It exposes dynamic Node-oriented machinery and is not the application embedding entry point. Application hosts should import `serveRuntime` and `loadStaticModules` from the package root or import `createRuntimeFetchHandler` from `@b4run/cli/fetch`. ## Dependency precedence For the Node assembly, explicitly supplied modules, middleware, and store instances win. When a store is absent, the Node fallback reads the corresponding `b4.config.ts` field, then uses the local SQLite/file default where the contract has one. A `requestStores` result overrides matching boot instances for that request. The edge fetch entry has no filesystem fallback: it cannot discover routes, load config, read permission files, or open default SQLite. Supply those dependencies explicitly. Some optional features remain absent when not configured; a configured feature the edge cannot serve fails with a capability error instead of silently degrading. ## Endpoint paths Embedding changes who owns the listener, not the API layout. The handler dispatches rooted paths: - `/healthz` for liveness; - `/threads` and `/threads/:thread_id/...` for Agent Protocol management and execution; - `/agui/:routeId` for AG-UI; - `/memory/candidates...` for memory candidate management. Route keys and thread ids still need URL encoding where the protocol requires it. If a product needs a public prefix, rewrite it at an outer proxy and test every request/response path; do not treat the prefix as runtime configuration. ## Resource ownership and shutdown The handler tracks response lifetime and run lifetime separately. For server-sent events, keep the response body connected to the host rather than buffering it. An Agent Protocol run may continue after its viewer disconnects, while AG-UI aborts on disconnect. Await `close()` before ending application-owned pools. It stops acceptance, aborts shutdown-aware work, drains boundedly, waits for request-store disposal, and releases sandboxes. It does not close injected boot stores or pools. A request-store factory owns partial allocation if it throws before returning: ```ts title="request-stores.ts" import { Pool } from "@neondatabase/serverless" import { createPostgresPermissionsStore, createPostgresThreadsStore, type PostgresPermissionsStoreOptions, postgresCheckpointer, } from "@b4run/postgres-storage" type Env = { readonly DATABASE_URL?: string } type PermissionPolicy = Required< Pick > // These are the application's effective production values. Keep policy // resolution in application code; requestStores does not inherit B4.run config. export const permissionPolicy = { mode: "non-interactive", config: { version: 1, allow: { bash: ["ls", "cat"] }, deny: { bash: ["rm -rf", "sudo"] }, }, } satisfies PermissionPolicy export async function createApplicationRequestStores( env: Env, policy: PermissionPolicy, ) { if (!env.DATABASE_URL) throw new Error("DATABASE_URL is required") const pool = new Pool({ connectionString: env.DATABASE_URL }) pool.on("error", (error) => { console.warn("Postgres pool client error:", error) }) try { const checkpointer = postgresCheckpointer({ pool }) const threadsStore = createPostgresThreadsStore({ pool }) const permissionsStore = createPostgresPermissionsStore({ pool, mode: policy.mode, config: policy.config, }) await Promise.all([checkpointer.ready(), threadsStore.ready()]) await permissionsStore.load() return { checkpointer, threadsStore, permissionsStore, dispose: () => pool.end(), } } catch (error) { await pool.end().catch(() => undefined) throw error } } ``` The explicit `await permissionsStore.load()` fulfills the store's load contract before it is returned. In `interactive` mode that call hydrates persisted runtime grants into the synchronous match cache; other modes intentionally skip those grants. B4.run invokes neither `ready()` nor `load()` for a `requestStores` override, and it does not layer the sibling B4.run permission config or `B4_PERMISSIONS_MODE` override onto a custom store. The application owns the effective mode, static allow/deny policy, hydration, refresh, and disposal. `dispose` is called only after a returned store set's response and any run it started have settled. If construction or hydration throws, the runtime received nothing to dispose, so the catch must close the partially allocated pool itself. The currently generated Hono `stores.mjs` creates a request-scoped Postgres permissions store that is made ready and migrated, but it is not hydrated with `load()`. It also omits the resolved mode and config-seeded allow/deny. Do not assume static policy, effective-mode, or persisted interactive grants have parity on that generated path. When any of those permission controls matter, compose app-owned request-store wiring that supplies and loads the complete policy as above, and define the refresh behavior your replicas require. For a caller-owned boot pool, preserve shutdown ordering: ```ts await handler.close() await pool.end() ``` Close the handler before the pool so active runs and response bodies cannot keep writing through an ended resource. ## Authentication Place blanket authentication and tenant authorization in the owning server or proxy. B4.run middleware runs for Agent Protocol execution/resume and AG-UI, but health, thread management, state, cancellation, and memory candidate routes bypass it. See [Security Architecture](/docs/security-architecture) before exposing an embedded handler. ## Test the embedded host Test the assembled host, not only the bare handler: 1. Verify unauthenticated requests fail for `/healthz`, `/threads`, `/agui`, and `/memory` surfaces according to your outer policy. 2. Create a thread, run a route, read state, and confirm cross-tenant access is rejected. 3. Exercise an Agent Protocol viewer disconnect and explicit cancellation through the real proxy. 4. Exercise AG-UI disconnect behavior and unbuffered SSE delivery. 5. Make request-store construction fail after opening a resource and verify the factory closes it. 6. Invoke shutdown with an active run, await `close()`, then close caller-owned pools. 7. For Hono, drive the composed `app.route("/", b4App)` path with the deployment's real environment binding. See [Edge and Hono](/docs/deployment/edge) for generated edge artifacts and target constraints, or [Node and Docker](/docs/deployment/node) for the standalone production server. --- ### Blueprints # Blueprints A blueprint is a guide for adding an integration to your B4.run app. `b4 add ` fetches the guide and prints it; you hand it to your coding agent (Claude Code, Cursor, …), which applies it to your project — installing dependencies, creating files, and wiring them in. Blueprints are Markdown, served from `b4.run`, and applied by an agent — not npm packages or runtime abstractions. For framework *patterns* (how to write a tool, type state, etc.), see [Recipes](/docs/recipes); blueprints are for wiring in *external systems*. ## Using `b4 add` ```bash b4 add # list available blueprints, grouped by category b4 add pgvector # print the pgvector blueprint for your agent to apply b4 add # apply a third-party blueprint from any URL ``` Pipe it straight to your coding agent, or run it and paste the output. `b4 add` only prints the guide — your agent makes the changes, and you review them. Set `B4_BLUEPRINTS_URL` to point at a self-hosted catalog (or a local dev server) instead of `b4.run`. ## Authoring a blueprint Blueprints live under `apps/web/content/blueprints//.md` in the B4.run repo. The **filename is the blueprint's name** (`b4 add `), and the **directory is its category** (`observability`, `retrieval`, `deploy`) — used for listing only, never in the command. Frontmatter (only `description` is required): ```yaml --- description: Add OpenTelemetry tracing to a B4.run app. # required, written for an LLM website: https://opentelemetry.io # optional version: 1 # optional (default 1) tags: [tracing, otel] # optional source: official # optional: official | maintainer | community --- ``` The body is an agent-facing guide. Lead with the intent and scope ("you are adding X; it does Y, not Z"), then: prerequisites, inspect the project, install dependencies, create the file(s), wire them in, configure environment, and verify. The primary generated file's first line carries a marker so a future `b4 update` can find it: ```ts // b4-blueprint: opentelemetry@1 ``` Keep guides adaptive: have the agent detect the package manager, read `b4.config.ts` and `AGENTS.md`, reuse existing dependencies, and follow the project's env conventions rather than assuming. --- ### Scenario Testing # Scenario Testing Scenario tests are colocated with routes as `run.test.ts` files. Each file default-exports a route-scoped suite built with `scenarios("/route")`, and `b4 test` runs every scenario through the route runtime. Plain default-exported scenario arrays are not supported. ## A minimal test A scenario file has no `describe()` or `test()` wrapper. Pass the route ID to `scenarios()`, add cases with `.scenario()`, then set the input and expected status explicitly: ```ts title="src/app/(public)/support/[tenant]/run.test.ts" import { scenarios } from "@b4run/sdk/testing" export default scenarios("/support/[tenant]").scenario("greets a tenant", (s) => s .input({ tenant: "acme" }) .expectPassed() .expectOutput({ tenant: "acme", greeting: "Hello, acme!" }), ) ``` Run all scenarios: ``` b4 test ``` B4.run discovers every `run.test.ts` under `src/app/`, verifies that the declared route matches the file's directory, invokes the route, and evaluates the expectations. `b4 typegen` writes the route and application-tool types used by the builder, so route names, `.mockTool()`, and `.expectTool()` are discoverable in IntelliSense. ## Builder API Every `.scenario(name, configure)` callback must return a builder that has called `.input()` exactly once and selected either `.expectPassed()` or `.expectFailed()`. The remaining methods add expectations or choose where the scenario runs: - `.input(value)` sets the route input. - `.expectPassed()` and `.expectFailed()` select the required result status. - `.expectOutput(value)` matches a passed route's returned state. - `.expectError(value)` matches a failed route's modeled error. - `.expectMeta(value)` matches `{ mode, routeId, routePath, executionSource }`. - `.assert(callback)` runs a synchronous or asynchronous custom assertion after declarative expectations. - `.server(url)` sends the scenario through a running B4.run-compatible server instead of invoking in-process. - `.mockTool(name, implementation)` replaces one application tool for this in-process invocation. - `.expectTool(name, configure)` asserts calls made to a tool mocked earlier in the same scenario. Passing scenarios can use `.expectOutput()`; failing scenarios can use `.expectError()`. The builder's type states hide combinations that cannot succeed and the loader validates the same rules at runtime. For programmatic assertions, import the existing helpers from `@b4run/sdk/testing`: ```ts title="src/app/(public)/support/[tenant]/run.test.ts" import { expectMeta, expectOutput, scenarios } from "@b4run/sdk/testing" export default scenarios("/support/[tenant]").scenario("custom assert", (s) => s .input({ tenant: "acme" }) .expectPassed() .assert((result) => { expectOutput(result, { greeting: "Hello, acme!" }) expectMeta(result, { mode: "agent", routeId: "/support/[tenant]" }) }), ) ``` `expectOutput`, `expectMeta`, and `expectError` use the same result model as declarative expectations and produce focused mismatch messages. ## Mocking application tools Use `.mockTool()` when an in-process scenario should replace an external or nondeterministic application tool while keeping the rest of the route real. Tool names, parameters, and awaited return values come from the generated route types: ```ts title="src/app/research/run.test.ts" import { scenarios } from "@b4run/sdk/testing" export default scenarios("/research").scenario("uses a controlled search result", (s) => s .input({ messages: [{ role: "user", content: "Research B4.run" }] }) .mockTool("searchWeb", async ({ query }) => ({ results: [{ title: "B4.run", url: "https://example.test/b4", query }], })) .expectPassed() .expectTool("searchWeb", (call) => call.calledOnce().withArgs({ query: "B4.run" }), ), ) ``` Mocks are partial: application tools not named by `.mockTool()` keep their real implementations. B4.run resolves shared and route-local tool precedence first, then replaces only the selected definition's implementation while preserving its schema, description, scope, and source metadata. `.expectTool()` is available only for tools mocked earlier in the same scenario. Its call builder supports: - `.called()` for one or more calls. - `.calledOnce()` for exactly one call. - `.calledTimes(n)` for exactly `n` calls. - `.notCalled()` for zero calls. - `.withArgs(partial)` for at least one call containing the supplied deep-partial object. Primitive values and arrays match exactly. Count and argument assertions are independent when combined. Multiple `.withArgs()` assertions each need a matching invocation, and one compatible invocation may satisfy more than one matcher. Call ordering and return-value assertions are not part of this API. Mocks apply only to the root route's generated application-tool set: route-local tools and shared tools. They cannot shadow built-in planning, workspace, memory, skill, or subagent capability tools, and a parent route's mocks never propagate into a child subagent route. Each scenario invocation receives a fresh override set and call journal. B4.run never mutates cached route modules, so mocks cannot leak into another scenario, a later `b4 run`, a server request, or a concurrently executing invocation. ## Against a live dev server Use `.server(url)` to exercise a scenario through a running `b4 dev`, built B4.run server, or staging deployment. There is no command-level `--url` flag on `b4 test`. ```ts title="src/app/(public)/support/[tenant]/run.test.ts" import { scenarios } from "@b4run/sdk/testing" export default scenarios("/support/[tenant]").scenario( "greets a tenant via dev server", (s) => s .input({ tenant: "acme" }) .server("http://127.0.0.1:3001") .expectPassed() .expectOutput({ tenant: "acme", greeting: "Hello, acme!" }), ) ``` Start the dev server first, then run `b4 test`: ``` b4 dev --port 3001 & b4 test ``` Keep fast, isolated behavior checks in-process. Add selected server-backed scenarios for behavior that only exists across the transport or runtime boundary. ### When to use a server-backed scenario A server-backed scenario is useful when the claim depends on: - JSON request and response serialization; - request middleware and request-scoped context; - runtime boot and route lookup wiring; - packaged static modules in a built runtime; or - real staging infrastructure and deployment configuration. Server-backed scenarios execute in another process and exchange JSON. JavaScript mock functions cannot cross that boundary, so `.server()` and `.mockTool()` are mutually exclusive in the builder and in runtime validation. B4.run does not install a test backdoor or a serializable mock interpreter in the server. Control server dependencies at their real boundary instead. Start a local fake HTTP service, point the server at a model proxy, or use a dedicated staging dependency before running `b4 test`. ## Agents, retries, and middleware Two cross-cutting features shape what scenarios assert: - **Agent retries** — `agent({ retry: { maxAttempts, baseDelay } })` retries on transient errors. To assert the exhausted-retry path, call `.expectFailed().expectError(...)` or use `.assert()` with `expectError`. See [Retry](/docs/retry). - **Middleware** — `src/middleware.ts` runs before local Agent Protocol run and stream requests. **Server-backed scenarios targeting a B4.run runtime exercise middleware; in-process scenarios bypass it.** A scenario whose middleware calls `reject(...)` should use `.expectFailed().expectError(...)`. See [Middleware](/docs/middleware). ## Rules `run.test.ts` must live in the route's directory, not a sibling or nested folder. B4.run matches tests to routes by directory. Default-export `scenarios("/route").scenario(...)`. The declared route must match the file location, and plain scenario arrays are rejected. Call `.input()` once and select `.expectPassed()` or `.expectFailed()`. Add declarative expectations or `.assert()` for the behavior the scenario owns. Mock only the external or nondeterministic application tools needed for the claim. Leave deterministic tools real, and use a server-backed scenario when the transport boundary itself is under test. One scenario = one claim about the route's behavior. If you're adding branches, add more scenarios — don't inflate a single one. ## CI Use `b4 verify` as the integrity gate (it covers app, routes, typegen, and deps in one call), then run `b4 test`: ```yaml - run: pnpm exec b4 verify - run: pnpm exec b4 test ``` `b4 verify` runs typegen and check internally, plus the deps check (missing packages, missing env vars) that bare `b4 check && b4 typegen` does not. Typegen also refreshes `.b4/scenarios.generated.d.ts`, which powers route and application-tool completion in `run.test.ts`. `b4 test` exits non-zero on any failure and outputs a diff per mismatched scenario. ## Unit-testing tools and middleware The scenario harness drives a whole route through the runtime. Sometimes you want to test one unit in isolation — a single route tool, a `FilesystemMiddleware`, or `ctx.fs`-using code — without standing up an agent. `@b4run/testing` ships three harnesses for this. They run against the **real** `WorkspaceFs` and filesystem backend over a temp directory, so real permission gating, realpath resolution, and parent-directory creation all apply. All three are async `create*Harness` factories that return a handle with `.close()` and `[Symbol.asyncDispose]` — the same convention as `createAgentHarness`. Across `@b4run/testing`, every harness/handle is created with a `create*` factory and torn down with `close()` (or `await using`). ### A tool that uses `ctx.fs` `createToolHarness(tool)` builds the `B4ToolContext` and gives you a reusable `invoke()`. Assert both the return value and what landed on disk via `h.workspace.read(...)`: ```ts import { afterEach } from "vitest" import { createToolHarness } from "@b4run/testing" import type { B4ToolContext } from "@b4run/sdk" const saveNote = async (input: { name: string; body: string }, ctx: B4ToolContext) => { const { bytesWritten } = await ctx.fs.writeFile(`notes/${input.name}.md`, input.body) return { bytesWritten } } let h: Awaited> afterEach(() => h.close()) test("saveNote writes into the workspace", async () => { h = await createToolHarness(saveNote) const result = await h.invoke({ name: "todo", body: "ship it" }) expect(result.bytesWritten).toBe(7) expect(await h.workspace.read("notes/todo.md")).toBe("ship it") }) ``` `invoke()` is reusable and shares one workspace across calls, so you can assert cumulative state across several invocations. Pass `{ workspace }` to share a fixture you already own (the harness won't close it), or `{ permissions }` to exercise allow/deny gating instead of the permissive default. ### Testing `ctx.fs` code directly `createWorkspaceHarness()` is the shared fixture the tool harness builds on. Use it directly when the code under test takes a `WorkspaceFs`. Seed the workspace with `write`, run your code against `h.fs`, and assert with `read`: ```ts import { createWorkspaceHarness } from "@b4run/testing" import type { WorkspaceFs } from "@b4run/sdk" const appendLine = async (fs: WorkspaceFs, path: string, line: string) => { const prev = await fs.readFile(path) await fs.writeFile(path, `${prev}\n${line}`) } test("appendLine round-trips through the real WorkspaceFs", async () => { await using h = await createWorkspaceHarness() await h.write("log.txt", "first") await appendLine(h.fs, "log.txt", "second") expect(await h.read("log.txt")).toBe("first\nsecond") }) ``` The `await using` form auto-disposes the harness (cleaning up the temp dir) at the end of the block — the modern alternative to `afterEach(() => h.close())`. It works on all three harnesses. ### A filesystem middleware `createMiddlewareHarness(mw)` composes a `FilesystemMiddleware` over a temp `localFilesystem` and exposes the wrapped `backend` plus a `ctx` to call it with. `assertForwardsAll()` catches the most common middleware bug — silently dropping a backend method (required or optional) that the middleware doesn't intercept: ```ts import { createMiddlewareHarness } from "@b4run/testing" import type { FilesystemMiddleware } from "@b4run/workspace" const uppercaseReads: FilesystemMiddleware = (next) => ({ ...next, readFile: async (path, ctx) => (await next.readFile(path, ctx)).toUpperCase(), }) test("uppercaseReads forwards every other backend method", async () => { await using h = await createMiddlewareHarness(uppercaseReads) await h.backend.writeFile("a.txt", "hello", h.ctx) expect(await h.backend.readFile("a.txt", h.ctx)).toBe("HELLO") // Fails loudly if the middleware forgot to spread a method like realPath. h.assertForwardsAll() }) ``` ## Related --- ### Agent Test Harness # Agent Test Harness Agent tests in CI should be deterministic and replay committed fixtures. `@b4run/testing` achieves this by intercepting the model's HTTP endpoint with **aimock**, a local mock that replays pre-written fixtures. Your tools, prompts, capabilities, and state all run normally; only the LLM is replaced. Optional, explicitly gated local smoke tests may instead use a live model. For CI, the fixture is the source of truth. Use live mode locally to probe model behaviour, with looser assertions that account for nondeterminism. ## Install ```bash pnpm add -D @b4run/testing vitest ``` ## First test This is the full shape you'll use for most agent tests. ```ts title="test/agent.test.ts" import { fileURLToPath } from "node:url" import { afterAll, it } from "vitest" import { createAgentHarness, expectFinalMessage, expectToolCalled, script } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const h = await createAgentHarness({ appRoot, route: "/chat#agent" }) afterAll(async () => { await h.close() }) it("filters open items", async () => { const run = await h.run({ input: "Filter open items", fixtures: script() .user("Filter open items") .callsTool("applyFilter", { status: "open" }) .replies("Found 2 open items."), }) expectToolCalled(run, "applyFilter").withArgs({ status: "open" }) expectFinalMessage(run).toContain("Found 2") }, 60_000) ``` `createAgentHarness` boots aimock on a random port, runs typegen, and resolves your route — all in the same process. The `route` string is the Agent Protocol key: `"/chat#agent"` means the `agent` export from `src/app/chat/index.ts`. Await `h.close()` in `afterAll` to stop aimock and restore env vars. Keep only one agent harness alive per process. The aimock endpoint and environment variables, plus B4.run's materialized-model state, are process-global. Run harness suites sequentially or isolate them in separate subprocesses. ## Fixture script `script()` compiles one or more fixture groups for fresh-thread scenarios. Each `.user()` starts a group whose `turnIndex` begins at zero. `.callsTool()` tells aimock to respond with a tool call; `.replies()` tells it to respond with a text message. For a same-thread follow-up, use explicit fixtures with cumulative turn indexes as shown below. ```ts script() .user("Summarize my project") // aimock matches when user message contains this .callsTool("readFile", { path: "README.md" }) // model responds: call readFile .replies("Here's a summary…") // model responds after tool result .build() // returns AimockFixture[]; or pass builder directly to h.run() ``` aimock matches fixtures by substring on the latest user message plus `turnIndex` (count of assistant messages already in the thread). You do not need to call `.build()` — pass the builder directly to `h.run({ fixtures })` and B4.run unwraps it for you. ## Recipes ### Assert a tool was called with specific args ```ts expectToolCalled(run, "applyFilter").withArgs({ status: "open" }) ``` `withArgs` does a partial (subset) match — extra args are ignored. Chain `.times(n)` to assert exact call count, or `.never()` to assert the tool was not called. ### Assert the final message ```ts expectFinalMessage(run).toContain("Found 2") expectFinalMessage(run).toMatch(/found \d+ items/i) expectFinalMessage(run).toEqual("Found 2 open items.") ``` ### Assert streamed tokens arrived ```ts expectStreamedTokens(run) // throws if zero tokens were streamed ``` ### Multi-turn: run the agent twice ```ts const first = await h.run({ input: "List tasks", fixtures: [ { match: { userMessage: "List tasks", turnIndex: 0, hasToolResult: false }, response: { content: "You have 3 tasks." }, }, ], }) const followUp = await h.run({ input: "Mark first done", fixtures: [ { // The same thread already contains the first assistant message. match: { userMessage: "Mark first done", turnIndex: 1, hasToolResult: false }, response: { content: "Done. You have 2 tasks left." }, }, ], }) expectFinalMessage(first).toContain("3 tasks") expectFinalMessage(followUp).toContain("2 tasks left") ``` Two `h.run()` calls without a reset are two turns on the same thread, so the follow-up can exercise conversation state. ### Fresh-thread test isolation ```ts const original = await h.run({ input: "Remember project alpha", fixtures: script().user("Remember project alpha").replies("Remembered."), }) h.reset() // new thread and a clean fixture set; aimock keeps the same port const isolated = await h.run({ input: "What project did I mention?", fixtures: script().user("What project did I mention?").replies("No project yet."), }) expectFinalMessage(original).toContain("Remembered") expectFinalMessage(isolated).toContain("No project") ``` Use `h.reset()` between scenarios or tests that require a fresh thread, not between turns in one conversation. ### Tool output offloading When large tool outputs are offloaded to a stub file, assert the offload marker: ```ts expectOffloaded(run, "generateReport") ``` ### Assert tools were called in order ```ts expectToolSequence(run, ["searchCorpus", "readDoc", "writeFile"]) ``` Asserts that the named tools were called in that order (subsequence match by default — other tools may appear between them). Pass `{ strict: true }` to require contiguity: the tools must appear consecutively with nothing else in between. ```ts expectToolSequence(run, ["validate", "save"], { strict: true }) ``` ### Assert no tool returned an error ```ts expectNoToolErrors(run) ``` Asserts that no tool returned an error result. HITL permission interrupts are not counted as errors. To inspect tool results manually, use `run.toolResults` — a `ReadonlyArray` derived from the final conversation messages by `deriveToolResults` (also exported). Each entry has the shape `{ name: string, status?: "error" | "success", content: unknown, isError: boolean }`. This is useful for asserting on specific error messages or content when `expectNoToolErrors` is too broad. ```ts import { deriveToolResults } from "@b4run/testing" const failing = run.toolResults.filter((r) => r.isError) expect(failing).toHaveLength(0) ``` ### State assertions ```ts expectState(run).messages.toHaveLength(3) expectState(run).field("runningSummary").toBeTruthy() expectState(run).field("todos").toEqual([{ content: "ship it", status: "pending" }]) ``` `run.state` is the full agent state after the run — the same object a checkpointer would persist. ## Choose the execution boundary `@b4run/testing` exposes a separate factory for each execution boundary: - **`createAgentHarness()`** runs tools, prompts, capabilities, and state directly through B4.run's runtime in the test process. It is the default choice for fast agent-behavior tests and does not bind an application port. - **`createAgentProtocolInjector()`** drives Agent Protocol requests through the runtime's fetch handler without binding a port. Use it when the HTTP request, response, or SSE contract is the behavior under test. - **`createSubprocessApp()`** starts a real `b4 dev` child process. Use it for process-boundary, restart, and persistence tests. These factories deliberately have separate option and result types. Choose the factory for the boundary the test needs; `createAgentHarness()` does not accept a `mode` option. ## CI setup No extra setup needed. `@b4run/testing` is CI-safe by default: aimock runs on a random port and stops when the harness closes. The only thing to guard is that fixture files are committed: ```yaml title=".github/workflows/ci.yml" - run: pnpm exec vitest --run - run: test -z "$(git status --porcelain -- test/fixtures/)" # tracked and untracked drift ``` ## Fixture files: author, commit, replay [Fixtures and Recording](/docs/testing-agents/fixtures) is the canonical workflow. ### Author inline and snapshot to a file See [Fixtures and Recording](/docs/testing-agents/fixtures). ### Record from a real model (local only) See [Fixtures and Recording](/docs/testing-agents/fixtures). ### Replay a fixture file in tests See [Fixtures and Recording](/docs/testing-agents/fixtures). ## Live mode (real model) See [Fixtures and Recording](/docs/testing-agents/fixtures). ## Your scaffolded app already has a test `create-b4-app` generates `server/test/research.test.ts` that imports `@b4run/testing` and covers the default `/research#agent` route. Run it immediately after scaffolding, from the workspace root: ```bash npm test ``` The generated suite starts by verifying the corpus search and citation path: ```ts title="server/test/research.test.ts" import { fileURLToPath } from "node:url" import { afterAll, it } from "vitest" import type { FixtureSet } from "@b4run/testing" import { createAgentHarness, expectFinalMessage, expectInterrupt, expectOffloaded, expectSubagent, expectToolCalled, script, } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const h = await createAgentHarness({ appRoot, route: "/research#agent" }) afterAll(async () => { await h.close() }) it("searches the corpus and writes a cited answer", async () => { h.reset() const run = await h.run({ input: "What are common agent architectures?", fixtures: script() .user("What are common agent architectures?") .callsTool("searchCorpus", { query: "agent architectures" }) .callsTool("readDoc", { path: "corpus/agent-architectures.md" }) .replies("ReAct and plan-and-execute are common. [corpus/agent-architectures.md]"), }) expectToolCalled(run, "searchCorpus") expectToolCalled(run, "readDoc") expectFinalMessage(run).toContain("[corpus/") }, 60_000) ``` The suite also exercises: - Recall of seeded durable research preferences. - Candidate writes through `remember`, plus CLI approval and recall from a fresh thread. - Researcher subagent dispatch with access to the shared corpus tools. - Offloading of a large `readDoc` result. - The HITL permission gate for `runBash`, followed by resume. These scenarios share one harness and call `h.reset()` per test — `reset()` starts a fresh thread and clears the previous scenario's fixtures, so the tests stay isolated. The separately gated `server/test/sandbox-docker.test.ts` verifies that shared tools operate inside an isolated Docker workspace without leaking files to the host or a fresh thread sandbox. Grow it by: 1. **Adding more `script()` scenarios** — one `it` block per behaviour you want to pin. 2. **Committing fixtures for complex flows** — follow [Fixtures and Recording](/docs/testing-agents/fixtures) for multi-turn or multi-tool scenarios that are tedious to hand-write. ## Related --- ### Fixtures and Recording # Fixtures and Recording Use an inline fixture when a scenario is short enough to understand beside the test. Use a committed fixture file when several tests share an exchange, a tool chain is long, or reviewing the exact model traffic matters. Both replay deterministically; recording and live mode are explicit local opt-ins that call a real provider. ## Choose inline fixtures or a committed fixture file Start inline with `script()`. A short intent → tool → answer flow is easier to maintain where it is asserted: ```ts const fixtures = script() .user("Filter open items") .callsTool("applyFilter", { status: "open" }) .replies("Found 2 open items.") ``` Move the exchange to a committed fixture file when the script obscures the test or must be reused. Fixture replay replaces model HTTP traffic only: authored tools, capabilities, state, and any external I/O they perform still run. ## How script matching works aimock selects each response using three match fields: - `userMessage` compares against the latest user message that contains text. A string from `.user()` matches when that latest text contains the fixture value; a trailing attachment-only user message is skipped in favor of the nearest text-bearing one. - `turnIndex` is the number of assistant messages already in the thread before that model call. It is cumulative in a continuing thread. - `hasToolResult` examines only messages after the latest user message. It is `false` for the current turn's initial model call and `true` after that turn produces a tool-role result. Earlier turns' tool results do not make the next turn's initial model call match `hasToolResult: true`. Within one `.user()` group, `script()` emits `turnIndex: 0` for the first response, then increments it for each tool call or reply; post-tool responses also set `hasToolResult: true`. Each new `.user()` group starts again at zero, so groups are suited to fresh-thread scenarios. For a same-thread follow-up, supply explicit fixtures with the cumulative `turnIndex` from the existing conversation. ## Fixture files: author, commit, replay Resolve a fixture beside the test once, then pass that absolute path to the file helpers: ```ts title="test/agent.test.ts" import { fileURLToPath } from "node:url" import { loadFixtures, script, writeFixtures } from "@b4run/testing" const fixturesPath = fileURLToPath( new URL("fixtures/filter-open.fixture.json", import.meta.url), ) ``` `new URL(..., import.meta.url)` is relative to the test file. `loadFixtures(path)` and `writeFixtures(path, fixtures)` use the path exactly as supplied; a relative string passed directly to either helper is therefore relative to `process.cwd()`. ### Author inline and snapshot to a file `writeFixtures` accepts a `script()` builder or a fixture array, creates parent directories, and writes formatted `{ "fixtures": [...] }` JSON. ```ts title="test/agent.test.ts" writeFixtures( fixturesPath, script() .user("Filter open items") .callsTool("applyFilter", { status: "open" }) .replies("Found 2 open items."), ) ``` Run this authoring step intentionally, inspect the JSON, and commit it. Do not leave snapshot generation in the normal test path. ### Replay a fixture file in tests `loadFixtures` checks only the supported top-level container — a bare array or an object whose `fixtures` property is an array — and returns that array. It does not validate every fixture entry. Replay does not fall back to a provider: if no fixture matches, the model request fails. Choose one registration scope. Put a shared file in `createAgentHarness({ fixtures })`, or supply it before a run as below. Do not register the same fixture file at both scopes: ```ts title="test/agent.test.ts" import { fileURLToPath } from "node:url" import { afterAll, it } from "vitest" import { createAgentHarness, expectFinalMessage, loadFixtures } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const fixturesPath = fileURLToPath( new URL("fixtures/filter-open.fixture.json", import.meta.url), ) const h = await createAgentHarness({ appRoot, route: "/chat#agent" }) afterAll(async () => { await h.close() }) it("replays a committed exchange", async () => { const run = await h.run({ input: "Filter open items", fixtures: loadFixtures(fixturesPath), }) expectFinalMessage(run).toContain("Found 2") }) ``` Fixtures supplied to `h.run()` are appended before that run, but they are not one-run overrides: they persist across later `h.run()` calls until `h.reset()` clears them. Because aimock selects the first registered matching fixture, overlapping additions can leave an older fixture shadowing a newer one. Use `h.reset()` between independent scenarios; omit it between turns that intentionally share one thread. A `turnIndex` mismatch is nonfatal by default: aimock can select a content-matching fixture at a different assistant-message count. Set `AIMOCK_STRICT_TURN_INDEX=1` in the test process when an exact `turnIndex` mismatch must reject the fixture. This strict-turn setting changes selection; replay still does not fall back to a real provider in either mode. ### Record from a real model (local only) Integrated harness recording exercises one B4.run route and converts the most recent run's captured model traffic into replay keys. Use it only for one fresh-thread first run: construct a new harness, run one representative input immediately, inspect the result, then write it. Set `OPENAI_API_KEY` for the default upstream: ```ts title="test/record-filter.ts" import { fileURLToPath } from "node:url" import { createAgentHarness, writeFixtures } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const fixturesPath = fileURLToPath( new URL("fixtures/filter-open.fixture.json", import.meta.url), ) const h = await createAgentHarness({ appRoot, route: "/chat#agent", record: true, }) try { await h.run({ input: "Filter open items" }) const fixtures = h.getRecordedFixtures() writeFixtures(fixturesPath, fixtures) } finally { await h.close() } ``` `getRecordedFixtures()` returns only traffic captured for the most recent `run()`. Its current conversion uses the first user message in the captured request, any tool-role message in the captured request for `hasToolResult`, and a zero-based index within only that latest run's captured calls for `turnIndex`. That is not a safe way to mint a later-turn fixture for an already-active thread: inspect and correct explicit match fields instead, or record from a new harness's first run. The standalone `record({ out, provider? })` helper is a distinct API. It launches the aimock recorder in a separate process; it does not create a B4.run harness, drive a route, or return fixtures: ```ts title="scripts/record-fixture.ts" import { record } from "@b4run/testing" record({ out: "test/fixtures/filter-open.fixture.json" }) // Optional upstream override: record({ out: "test/fixtures/filter-open.fixture.json", provider: "https://api.openai.com", }) ``` A relative `out` path is interpreted by that child process relative to the inherited `process.cwd()`. Use an absolute path when the command may run from different directories. The call is synchronous and throws if the recorder exits unsuccessfully. ## Live mode (real model) Use `live: true` only for local prompt validation. It proxies model calls to the real OpenAI endpoint, requires `OPENAI_API_KEY`, and registers no fixtures. The harness still captures the system prompt, but model output is nondeterministic: ```ts title="test/agent.live.test.ts" import { fileURLToPath } from "node:url" import { it } from "vitest" import { createAgentHarness, expectFinalMessage, expectToolCalled } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) it.skipIf(process.env.CI || !process.env.OPENAI_API_KEY)( "checks the prompt against a real model", async () => { const h = await createAgentHarness({ appRoot, route: "/chat#agent", live: true }) try { const run = await h.run({ input: "Filter open items" }) expectToolCalled(run, "applyFilter") expectFinalMessage(run).toMatch(/open/i) } finally { await h.close() } }, 120_000, ) ``` Construct and close the live harness inside the skipped test so importing the module cannot start a real-model harness. Keep assertions loose: check that a tool was called, an answer has the expected shape, or a prompt contains a stable instruction. Avoid exact arguments and exact response text. Never run live mode in CI, even when a provider key happens to be present there. ## CI rules and fixture drift CI should run fixture replay only, never record or live mode. Commit every fixture a test loads, keep recording scripts out of the test command, and fail when a fixture update is tracked or untracked: ```yaml title=".github/workflows/ci.yml" - run: pnpm exec vitest --run - run: test -z "$(git status --porcelain -- test/fixtures/)" ``` Unlike `git diff`, the status-based fixture drift check catches both tracked edits and untracked fixture files. Code review still decides whether a changed exchange is correct. Keep record mode and live mode behind explicit local commands, not environment-dependent fallbacks inside ordinary tests. ## Process-global constraints and cleanup One harness changes process-global aimock and environment state: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and materialized-model caches. Keep only one harness alive per process, run harness suites sequentially, or isolate them in subprocesses. Always `await h.close()` in `afterAll` or `finally`; cleanup stops aimock, releases sandboxes, restores the prior environment, and clears runtime caches. Record and live runs can send prompts, tool schemas, and conversation content to the configured provider. Treat captured files as reviewable source: remove secrets and irrelevant turns before committing them. ## Update fixtures and troubleshoot When prompts, tools, or model behavior intentionally change: 1. Re-run the authoring script or one integrated recording locally. 2. Inspect the fixture diff for sensitive data and unexpected calls. 3. Replay the focused test without provider credentials. 4. Commit the fixture and test changes together. If replay reports no matching fixture, compare the actual user text, cumulative `turnIndex`, and `hasToolResult` with the JSON. If a later scenario matches an earlier wildcard, call `h.reset()` between them. If a following suite points at a stopped aimock port or inherits a temporary key, confirm every harness cleanup is awaited and that harnesses do not overlap. ## Related --- ### Evals # Evals Scenario tests and `@b4run/testing` pin down behaviour: given this input and these fixtures, the agent calls this tool and says this. They answer "is it correct?" for a handful of cases. Evals answer a different question — "how good is it?" — by running your agent over a whole **dataset** and scoring each output, then aggregating the scores into a single verdict you can **gate** on. Use evals when you want a quality bar that survives prompt edits, model swaps, and refactors: a number that goes up or down across a dataset, with an optional threshold that fails CI when quality regresses. Evals make the agent run deterministic by default. Like `@b4run/testing`, each case replays pre-written **aimock** fixtures, so the agent's model call is replaced while your tools, prompts, capabilities, and state run normally. Scorers still execute after the replayed run; deterministic scorers are CI-safe, and model-graded scorers such as `llmJudge` are CI-safe when their chat-completions requests are fixture-backed or mocked. A separate `b4 eval --live` mode runs the real model locally when you want to measure against actual model output. ## Install ```bash pnpm add -D @b4run/evals @b4run/testing ``` `@b4run/evals` provides `defineEval` and the scorers; `@b4run/testing` provides `script()` and the harness the `b4 eval` command drives. ## The `*.eval.ts` convention Evals live next to the route they exercise, under an `evals/` directory: ``` src/app/chat/ index.ts evals/ quality.eval.ts ``` `b4 eval` discovers `src/app//evals/*.eval.ts` the same way `b4 test` discovers `run.test.ts`. An eval co-located under a route directory binds to that route automatically; the `route` field is only needed when the file lives elsewhere. ## `defineEval` ```ts title="src/app/chat/evals/quality.eval.ts" import { contains, defineEval } from "@b4run/evals" import { script } from "@b4run/testing" export default defineEval({ name: "chat quality", // route: "/chat#agent", // optional — inferred from the file location dataset: [ { name: "greets the user", input: "hello", fixtures: script().user("hello").replies("Hi! How can I help?"), }, ], scorers: [contains("help", { threshold: 1 })], threshold: 1, }) ``` The fields: - **`name`** — required label for the eval, shown in the report. - **`route`** — the Agent Protocol route key (`"/chat#agent"`). Optional when the file is co-located under a route; required otherwise. - **`dataset`** — the cases to run (see below). - **`scorers`** — one or more scorers; each runs against every case. - **`threshold`** — sugar for `gate.mean(threshold)` (see Gating). - **`gate`** — a composable gate policy; takes precedence over `threshold`. A `dataset` case is `{ input, expected?, name?, fixtures?, metadata? }`. `input` is the user message for an agent route. `fixtures` is the per-case aimock script used in replay mode (ignored under `--live`). ## Datasets `dataset` accepts three shapes: ```ts // 1. Inline array of cases dataset: [{ input: "hello", expected: "Hi!" }] // 2. A path to a committed file (.json or .jsonl), relative to the eval file dataset: "./cases.json" dataset: "./cases.jsonl" // 3. A sync or async function returning cases dataset: async () => loadCasesFromSomewhere() ``` A `.json` file must contain a JSON array of cases; a `.jsonl` file holds one case object per line. Relative paths resolve against the eval file's directory. ## Scorers A scorer reads the run result and the case and returns a **score**: a number in `0..1`, a boolean (`true` = `1`, `false` = `0`), or a rich verdict `{ score, label?, reason? }`. Built-in scorers: - **`exactMatch()`** — final message equals `case.expected`. - **`contains(substring)`** — final message includes the substring. - **`regex(re)`** — the regex matches the final message. - **`jsonEquals()`** — `case.expected` deep-equals the parsed final message (or a value you `select`). - **`toolCalled(name, { withArgs? })`** — a tool was called (optionally with matching args). - **`tokensUnder(budget)`** — fewer than `budget` tokens were streamed. - **`memoryRecalled(expectedIds)`** — every expected memory id appears in a `recall` tool result. - **`memoryFresh(expectedValue)`** — the final message contains the expected newer value. - **`memoryIsolated(forbidden)`** — the forbidden value appears in neither `recall` results nor the final message. Each built-in takes an optional `{ threshold }` — its own pass bar, used by `gate.perScorer` and per-case pass logic. For anything custom, write your own: ```ts import { custom, llmJudge } from "@b4run/evals" // Arbitrary scoring logic custom((run, testCase) => (run.finalMessage.length < 200 ? 1 : 0), { name: "concise" }) // Grade output quality with a model llmJudge({ criteria: "Does the answer fully address {{input}}? Output: {{output}}", model: "gpt-5-mini", // optional threshold: 0.7, // optional }) ``` `custom((run, testCase) => Score | Promise)` gives you the full run result and case. `llmJudge` asks a model to grade the output against `criteria` (which interpolates `{{input}}`, `{{expected}}`, `{{output}}`) and returns `{ score, reason }`. The scorer sends a chat-completions request whenever it runs. In B4.run CLI replay, that request goes through aimock and can be satisfied by fixtures; in live, record, or unmocked programmatic runs, it needs model credentials unless you inject a mocked `fetchImpl`. ## Gating After every case is scored, a **gate** turns the aggregated scores into pass/fail. Gate policies compose via the `gate` helper: ```ts import { gate } from "@b4run/evals" gate: gate.mean(0.8) // dataset-wide mean ≥ 0.8 gate: gate.passRate(0.9) // ≥ 90% of cases pass (every scorer met its bar) gate: gate.everyCase(0.6) // every case's mean ≥ 0.6 gate: gate.perScorer() // each scorer's mean ≥ that scorer's own threshold // Combine them: gate: gate.all(gate.mean(0.8), gate.perScorer()) // all must pass gate: gate.any(gate.passRate(0.9), gate.mean(0.95)) // any one passing is enough ``` There are two shorthands: - **`threshold:`** on the eval is sugar for `gate.mean(threshold)`. If `gate` is set, `threshold` is ignored. - A per-scorer **`threshold`** sets that scorer's own bar — used by `gate.perScorer()` and to decide whether a case "passed" (every scorer must meet its bar; default bar `0.5`). If you set neither `gate` nor `threshold`, the eval is **informational**: it always passes and just reports scores. This lets you land an eval and watch the numbers before you commit to a bar. ## Execution: replay vs live Evals use the same aimock fixture system as agent tests — see [Fixtures and Recording](/docs/testing-agents/fixtures) for how fixtures, `script()`, and replay-vs-live mode work, and why live runs must never touch CI. By default `b4 eval` runs in **replay** mode: each case replays its `fixtures` (a `script()` builder inline on the case, or a committed fixture file) through aimock, so the agent's model call is replaced. This is the mode to run in CI. If the eval includes `llmJudge`, the scorer still runs during replay, so include a fixture for its judge request or inject a mocked `fetchImpl`. ```bash b4 eval ``` Pass `--live` to run the real model locally instead: ```bash b4 eval --live ``` Live mode ignores per-case fixtures and calls the actual provider. It requires `OPENAI_API_KEY` and is meant for local runs while you tune prompts — never wire it into CI. ### Recording fixtures with `--record` `b4 eval --record` runs the suite against the real model (requires `OPENAI_API_KEY`; never run in CI) and writes agent-run request fixtures you can replay later. For each case that has no inline `script()` fixtures, it writes a sibling file `..fixtures.json` next to the `.eval.ts`. A plain `b4 eval` then auto-loads those files, so the agent run stays deterministic in CI without any code changes. Recording snapshots only the agent run; it does not add scorer model calls to that sibling file. If the eval uses `llmJudge`, separately add and commit a fixture that matches the judge request, or inject a mocked `fetchImpl` into the scorer before replaying in CI. Cases that already have inline `script()` fixtures are left alone — `--record` skips them (logging `skipped record (inline fixtures)`) and never overwrites them. Inline fixtures stay authoritative. The gate still applies during `--record`: scores are aggregated and checked against the threshold after all cases run, but fixture files are written per-case before the verdict, so a gate failure never discards captured responses. `--record` and `--live` are mutually exclusive. ```bash # Record real-model responses into sibling fixture files b4 eval --record # Replay the committed agent fixtures; supply scorer fixtures separately b4 eval ``` ### Command reference ``` b4 eval [path] [--live] [--record] [--json [file]] [--cwd ] ``` - **`[path]`** — narrow discovery to a subdirectory. - **`--live`** — run the real model (requires `OPENAI_API_KEY`); never use in CI. - **`--record`** — record real-model responses into sibling fixture files (requires `OPENAI_API_KEY`); never use in CI. Mutually exclusive with `--live`. - **`--json [file]`** — also write a JSON report. Defaults to `.b4/eval-report.json`. - **`--cwd `** — operate on a different app root. A gated eval that fails causes a non-zero exit, so CI fails when quality drops below the bar. Informational evals never affect the exit code. See the [CLI reference](/docs/cli) for the full command surface. ## A full example ```ts title="src/app/chat/evals/quality.eval.ts" import { contains, defineEval, gate, toolCalled } from "@b4run/evals" import { script } from "@b4run/testing" export default defineEval({ name: "chat quality", dataset: [ { name: "filters open items", input: "Filter open items", fixtures: script() .user("Filter open items") .callsTool("applyFilter", { status: "open" }) .replies("Found 2 open items — let me know if I can help further."), }, ], scorers: [ contains("help", { threshold: 0.5 }), toolCalled("applyFilter", { threshold: 0.5 }), ], gate: gate.perScorer(), }) ``` Run it: ```bash b4 eval ``` The single case calls `applyFilter` and its reply contains "help", so both scorers score `1.00`. `gate.perScorer()` checks each scorer's mean against its own bar — both are `1.00 ≥ 0.5`, so the eval passes: ``` PASS chat quality › filters open items mean=1.00 [contains(help)=1.00 toolCalled(applyFilter)=1.00] PASS chat quality mean=1.00 ``` Commit the eval and any fixture files alongside your route. In CI, run `b4 eval` in replay mode with fixtures for every model call, including any `llmJudge` scorer calls. Locally, run `b4 eval --live` when you want to measure the agent against the real model before updating fixtures. ## Your scaffolded app already has an eval `create-b4-app` generates `server/src/app/research/evals/research-quality.eval.ts` next to the default research route, adds `@b4run/evals` as a dev dependency of the `server` package, and wires an `eval` npm script at the workspace root. Run it right after scaffolding: ```bash npm run eval ``` This wraps `b4 eval` and runs the agent cases in **replay mode** by default. The generated eval (`server/src/app/research/evals/research-quality.eval.ts`) has two dataset cases and four scorers: - `toolCalled("searchCorpus", { threshold: 1 })` — asserts the agent called `searchCorpus`. - `contains("[corpus/", { threshold: 1 })` — asserts the reply cites a corpus source. - A custom `cites-source` scorer: `custom((run) => run.finalMessage.includes("corpus/") ? 1 : 0, { name: "cites-source", threshold: 1 })`. - `llmJudge({ criteria: "The report answers the question and cites at least one source document.", model: "gpt-5-mini", threshold: 0.7 })`. The gate is `gate.all(gate.passRate(1), gate.perScorer())` — every case must pass and every scorer must meet its threshold. The generated fixtures include the extra judge turns used by `llmJudge`, so the default `npm run eval` stays offline and no-key. To run the agent cases against the real model locally: ```bash npm run eval -- --live ``` Grow it by adding cases to the dataset and more scorers. ## Programmatic API `runEval` from `@b4run/evals` lets you drive an eval definition from your own script — useful for custom CI tooling, reporting pipelines, or running evals as part of a larger orchestration. ```ts title="scripts/run-chat-eval.ts" import { fileURLToPath } from "node:url" import { contains, defineEval, runEval } from "@b4run/evals" import { createAgentHarness, script } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const evalDir = fileURLToPath(new URL("../src/app/chat/evals", import.meta.url)) const myEval = defineEval({ name: "chat quality", dataset: [{ input: "hello", fixtures: script().user("hello").replies("Hi!") }], scorers: [contains("Hi", { threshold: 1 })], threshold: 1, }) const harness = await createAgentHarness({ appRoot, route: "/chat#agent" }) try { const report = await runEval(myEval, { runCase: async (testCase) => { harness.reset() if (typeof testCase.input !== "string") { throw new TypeError("Agent eval input must be a string") } return harness.run({ input: testCase.input, ...(testCase.fixtures !== undefined ? { fixtures: testCase.fixtures } : {}), }) }, baseDir: evalDir, // for resolving dataset paths }) console.log(report.passed, report.mean) } finally { await harness.close() } ``` The reset starts every dataset case on a fresh thread with a clean fixture set. `runEval(def, options)` accepts a `RunEvalOptions` with two fields: `runCase` (required — executes one case and returns an `AgentRunResult`) and `baseDir` (optional — base directory for resolving a string dataset path). It returns a `Promise` containing `{ name, cases, byScorer, mean, gated, passed, reason? }`. ## Related --- ### Persistence and Tenancy # Persistence and Tenancy B4.run's Node runtime uses local SQLite stores by default. That is a good one-process default, but it is not a tenancy model: production tenant ownership is an application boundary and is never inferred from a thread id. ## What B4.run persists The runtime writes several independent kinds of state. They do not share one lifecycle merely because they belong to the same application. | Data | Default location | Shared option | Namespace or tenant key | Deletion behavior | Owner | |---|---|---|---|---|---| | LangGraph checkpoints and pending writes | `.b4/checkpoints.sqlite` | `postgresCheckpointer` through [`checkpointer`](/docs/configuration#checkpointer) | `thread_id`, checkpoint namespace, and checkpoint id; no authenticated tenant key is added | After metadata deletion, the runtime awaits `deleteThread` when the saver supports it; a failure leaves metadata gone | The app selects the saver; the runtime writes route state | | Agent Protocol thread metadata | `.b4/threads.sqlite` | `createPostgresThreadsStore` through [`threadsStore`](/docs/configuration#threadsstore) | `thread_id`; metadata can record application fields, but the id itself proves no owner | The thread row is removed first | The app owns thread authorization; the runtime maintains status and route metadata | | Runtime permission decisions | `.b4/permissions.json` | `createPostgresPermissionsStore` through [`permissions.store`](/docs/configuration#permissionsstore) | Tool/gate key and pattern within the configured store; not thread-scoped or tenant-scoped by default | Thread deletion does not remove permission decisions | The application owns policy and store boundaries | | Typed long-term memory | `.b4/memory.sqlite` | A separate `MemoryStore`, such as `pgvectorMemoryStore`, through [`memory.store`](/docs/configuration#memory) | The route-declared memory scope: `workspace`, `route`, and any application-supplied `tenant`, `user`, or `agent` dimensions | Managed by memory APIs and retention policy, not by thread deletion | The application owns namespace derivation and lifecycle | | Local workspace files | `/workspace/` through the local filesystem backend | An application-supplied [`backends.filesystem`](/docs/configuration#backends) | App/workspace path; there is no automatic thread or tenant partition | Thread deletion does not remove arbitrary workspace files | The application and its tools | | Per-thread sandbox volumes | None until [`sandbox`](/docs/configuration#sandbox) is configured; location is provider-specific | A durable provider volume, such as a Docker named volume or Kubernetes PVC | `threadId` is the provider lookup key | Sandbox destruction on thread deletion removes the thread volume; idle reap and shutdown release compute but keep it | The configured sandbox provider | A caller can supply a route value, thread id, or memory scope value. Persisting that value does not verify that the caller owns it. Authenticate at the service edge and authorize every read, write, run, cancellation, and deletion against application-owned tenant data. ## Choose local or shared stores Local SQLite and file stores keep setup small and make sense for development or one long-lived Node process with durable local disk. Move the three durable runtime stores independently when compute becomes ephemeral or several processes must see the same records: - [`checkpointer`](/docs/configuration#checkpointer) for checkpoints; - [`threadsStore`](/docs/configuration#threadsstore) for thread metadata; - [`permissions.store`](/docs/configuration#permissionsstore) for runtime permission grants. `@b4run/postgres-storage` implements those three stores over Postgres. Typed long-term memory remains a separate [`memory.store`](/docs/configuration#memory) with its own schema, retrieval behavior, and retention needs; `@b4run/memory-pgvector` is one shared option. ### Generated Hono database boundary The generated Hono request stores select the default `public` schema and default `b4` table prefix for checkpoints, threads, and permissions. Their `public.b4_*` tables contain no application namespace, so each generated Hono app requires an app-dedicated database. A tenant field in application input does not partition those tables. Several applications may share one database only through hand-composed store wiring that passes a unique `schema` or `tablePrefix` consistently to all three Postgres stores. Preserve the generated edge lifecycle and bundling constraints when doing so: per-request pools and disposal, migration coordination, original-`Request` environment binding, static provider imports, serialized config, and explicit permission policy/hydration. The generated `stores.mjs` is replaced on rebuild and does not expose an app naming option; see [Edge and Hono](/docs/deployment/edge#why-the-stores-are-per-request). A running process caches loaded permission decisions because `PermissionsStore.match()` is synchronous. On the configuration-resolved Node path, `serveRuntime` resolves and loads its configured permissions store once at Node boot. The Postgres implementation hydrates its in-memory map with `load()`, but that cache does not auto-refresh across replicas. A shared permissions table therefore does not automatically provide instant invalidation: the application owns the refresh or reload strategy when externally added grants or revocations must propagate, whether that means an explicit reload schedule, rebuilding the handler, or another application-controlled mechanism. ## Tenant ownership Derive tenant and user scope from identity that your service has already verified. Keep a server-side ownership record that relates that identity to each thread and other namespace-bearing resource, then check it before accepting a caller-supplied id. Do not treat route parameters, request bodies, thread ids, or memory scope strings as authentication. They are addressing inputs. A useful design keeps the verified principal, tenant ownership record, storage namespace, and authorization decision distinct. `memory.resolveScope` receives only `{ routePath, appRoot }`. It does not receive a verified request identity or middleware context automatically. If a memory namespace needs per-request identity, provide application-owned wiring that genuinely has access to that identity and test the isolation boundary; do not assume B4.run inferred it. ## What deleting a thread removes `DELETE /threads/:thread_id` is ordered and not transactional across stores: 1. The runtime deletes thread metadata first. 2. If the configured checkpoint saver exposes `deleteThread`, the runtime awaits that call. 3. Only after the checkpoint step succeeds—or is unsupported—does it destroy the thread's sandbox state and volume when a sandbox manager is configured. Optional checkpoint deletion support is the limited best-effort boundary: a saver without `deleteThread` is skipped. Once a supported saver is called, its errors are not swallowed. A saver error propagates after metadata is already gone and can prevent sandbox cleanup. An HTTP `204` means every step that was attempted completed; it does not mean a cross-store transaction committed. That operation does **not** remove global permission decisions, typed long-term memory, arbitrary workspace files, application database rows, or data held by another service. Account deletion therefore requires an application-level inventory and workflow across every relevant store. Operators need reconciliation for partial deletions, an idempotent retry path for checkpoint and sandbox cleanup, and audit records that distinguish a missing metadata row from fully completed cleanup. You must quiesce the thread before deletion: `DELETE /threads/:thread_id` does not cancel an active run and does not wait for it. A live route can produce later checkpoint writes after cleanup, and sandbox destruction can race tools that are still executing. At the application boundary, use owning-process thread-aware routing or distributed coordination to stop new work, route `/threads/:thread_id/cancel` to the owning process, and confirm the run is settled before sending `DELETE`. A successful cancel response or delivered abort signal does not prove route completion: route work may ignore cancellation or still be unwinding. A 204 confirms only the sequential cleanup calls completed; it does not prove there were no concurrent or later writes. See [Production Topology](/docs/production-topology#add-replicas-safely). ## Backup, restore, encryption, and retention Choose backup and restore procedures for each backend, and rehearse restoring a consistent set of thread metadata and checkpoints. A restored thread row without its corresponding checkpoint history may still exist but cannot reproduce the prior route state. Workspace files, sandbox volumes, and long-term memory need their own backup decisions. B4.run does not add application-level encryption to stored values and does not provide an account-erasure transaction across these stores. Postgres rows are plaintext application data unless the application or infrastructure adds encryption. Protect database credentials, storage volumes, backup copies, and access logs accordingly. Define retention by data class: checkpoint history, thread records, permission grants, long-term memory, workspace output, sandbox volumes, and backups rarely need identical windows. Record which component performs expiry or deletion and how failed cleanup is retried and audited. ## Migration checklist 1. Inventory every row in the matrix, including local workspace and sandbox data. 2. Establish tenant ownership and namespace rules before copying data. 3. Provision the destination stores and exercise their native schema initialization and backup path. 4. Quiesce or otherwise account for writes while copying local data with backend-native tooling or an application-specific migration. B4.run does not ship a local-to-Postgres data migration command. 5. Configure [`checkpointer`](/docs/configuration#checkpointer), [`threadsStore`](/docs/configuration#threadsstore), [`permissions.store`](/docs/configuration#permissionsstore), and [`memory.store`](/docs/configuration#memory) explicitly, then verify representative reads and writes. 6. Decide how workspace files and sandbox volumes survive replacement compute. 7. Rehearse rollback and restoration before removing the local copies. 8. Add routing and coordination separately: shared durable stores do not distribute the active-run gate or cancellation registry. ## Related --- ### Production Topology # Production Topology Start with one B4.run process and add infrastructure only for a measured requirement. Shared persistence, replica coordination, streaming behavior, readiness, and shutdown are separate design decisions. ## Start with one process The smallest production shape is the Node runtime with a durable local disk. One process owns the HTTP listener and its in-memory run registry; default SQLite files hold checkpoints and thread metadata, while the default permission store uses a local file. Bind the service to a private interface or put it behind an authenticated edge, persist the app's `.b4/` and `workspace/` data, and arrange signal-driven shutdown. This topology avoids cross-replica races because every run for the service reaches the same process. ## Where state lives | State | Placement | Replica implication | |---|---|---| | Active-run gate, abort controllers, cancellation registry, resume claims, and the in-session thread-to-route map | process-local memory in one runtime handler | Another process cannot observe, serialize, or cancel this process's active run | | Checkpoints, thread metadata, and runtime permission grants | Local SQLite/file stores by default; configurable shared durable stores | Shared records survive replacement compute, but they do not coordinate active execution | | Typed long-term memory | Separate local SQLite store or configured shared memory backend | Namespace and retention are application-owned; it is not part of the three runtime Postgres stores | | `workspace/` files | Local filesystem backend by default | Ephemeral or replica-local disks diverge unless the application supplies durable shared behavior | | Sandbox workspace | Provider-managed per-thread volume | Release and destroy semantics depend on the provider; route requests must reach the intended volume | | Middleware context and per-request stores | One request/run lifetime | Never use them as cross-request or cross-replica state | See [Persistence and Tenancy](/docs/persistence) for the data lifecycle matrix. ## Move to ephemeral compute Before replacing local disk with ephemeral instances, move checkpoints, thread metadata, and permission decisions to Postgres with the three `@b4run/postgres-storage` adapters. Choose a separate backend for long-term memory. Decide explicitly whether workspace files and sandbox volumes must survive process or pod replacement. Use one application-owned pool where the host permits it, handle idle pool errors, and close the runtime handler before ending the pool. An edge runtime may instead require request-scoped pools; follow the generated Hono store lifetime rather than reusing request-bound sockets across invocations. ## Add replicas safely Replicas need both shared persistence **and** guaranteed thread-aware routing or serialization. B4.run has no distributed run coordinator: each handler's one-run-per-thread gate and cancellation registry are process-local. For Agent Protocol routes, the thread id is present in paths such as `/threads/:thread_id/runs/stream` and `/threads/:thread_id/cancel`. Route all operations for an active thread—including cancellation—to the instance that owns its run, or place a distributed coordinator in front of execution. A persisted `busy` status is metadata, not a safe lock. AG-UI sends its `threadId` in the request body to `/agui/:routeId`. An ordinary path-only load balancer cannot derive thread affinity from that body automatically. Use a gateway that understands the request, a trusted affinity token established before dispatch, or another serialization design you can test under concurrent requests. ## Streaming and proxy behavior Agent Protocol execution is durable with respect to its viewer: if the client disconnects from `/runs/stream` or abandons `/runs/wait`, the run continues until it completes, is explicitly cancelled, or the runtime shuts down. Route cancellation to the owning process. AG-UI is ephemeral. Disconnecting or cancelling its response aborts the request's run. Configure proxies to pass server-sent events without buffering, preserve heartbeat traffic, and allow timeouts long enough for expected turns. Test disconnects at the browser, proxy, and runtime—not only with a direct local request. ## Health and readiness `GET /healthz` returns a successful liveness response from the route table. That does not prove that Postgres, model providers, sandbox infrastructure, or every configured backend can serve a turn. The fetch runtime calls `requestStores` before route dispatch, so a fetch/Hono host may initialize request-scoped stores even for `/healthz`. Treat that as an implementation detail, not a comprehensive dependency probe. Add an application-owned readiness check for the dependencies and migrations your rollout requires, and keep it behind the same outer access controls as the rest of the service. ## Shutdown and rollouts When invoked, the runtime handler's `close()` stops accepting requests, aborts shutdown-aware work, and waits up to 30 seconds for response bodies, active runs, and request-store disposal before proceeding. It then releases active sandboxes while keeping provider volumes according to provider semantics. Work that ignores cancellation can outlive the bounded drain. Injected stores and pools remain application-owned. Await `close()` before closing those resources so in-flight routes do not write through a pool that has already ended. `b4 start` opts into SIGINT/SIGTERM handling. `serveRuntime` defaults signal installation off, and the generated Node `server.mjs` calls it without that option, so the generated server does not currently install signal handlers. Use `b4 start` or provide a host entry that receives platform signals, invokes and awaits `close()`, and exits according to your supervisor's contract. ## Kubernetes implications An HPA adds or removes replicas; a PodDisruptionBudget limits simultaneous voluntary disruption. Both can improve availability, but neither serializes a thread, routes cancellation, drains a B4.run handler, or makes local disk shared. For Kubernetes, combine an application-level readiness probe, a termination grace period longer than the chosen drain budget, a signal-aware entry point, and verified thread-affinity or coordination. Exercise rolling updates with a live Agent Protocol stream, an AG-UI disconnect, a cancel request, and a pod replacement while shared stores remain available. ## Related --- ### Security Architecture # Security Architecture B4.run's route middleware is an execution hook, not a service-wide security boundary. Put outer authentication and tenant authorization in front of every non-local runtime endpoint, then layer B4.run's inner agent controls behind it. ## Start at the service edge Authenticate the caller before the request reaches the B4.run service and restrict network reachability where possible. The outer layer should reject unauthenticated access consistently, apply rate and body-size limits, terminate TLS, and attach verified identity for authorization and audit. This boundary must cover the entire service. Do not expose management or health routes on the assumption that `src/middleware.ts` will see them. ## Endpoint coverage B4.run middleware runs only when a request is about to execute a route. Management surfaces bypass it. | Surface | Examples | B4.run middleware | Outer auth required | |---|---|---|---| | Health | `GET /healthz` | No | Yes on a non-local service; a platform may expose a separately constrained probe path | | Thread management | `POST /threads`, `GET /threads/:thread_id`, `DELETE /threads/:thread_id` | No | Yes, with per-thread ownership checks | | State | `GET /threads/:thread_id/state` | No | Yes, with per-thread ownership checks | | Cancellation | `POST /threads/:thread_id/cancel` | No | Yes; it controls process-local execution | | Agent Protocol execution | `/threads/:thread_id/runs/wait`, `/runs/stream`, and `/resume` | Yes | Yes; middleware is an additional execution decision | | AG-UI execution | `POST /agui/:routeId` | Yes | Yes | | Memory candidate management | `GET /memory/candidates` and approve/reject routes under `/memory/candidates/:id` | No | Yes; listing can span namespaces | Apply authorization before dispatch so create, read, delete, state, cancel, memory candidates, and health cannot reach their narrower B4.run handlers unauthenticated. The "B4.run middleware" column is about route execution only. Every thread row above — management, state, cancellation, Agent Protocol execution and AG-UI execution — is additionally covered in-runtime by a [thread-access policy](/docs/thread-access) when the app has one, on the per-thread ownership axis this table asks the outer boundary to supply. It is a second gate, not a replacement for the outer one, and health and memory-candidate routes are outside it. ## Authorize the tenant, not the identifier Verified claims answer *who the caller is*. Route parameters, `thread_id`, AG-UI `threadId`, tenant strings in route input, and memory namespaces answer *which resource was requested*. Compare the two using application-owned records. A robust request path resolves the verified principal, determines its tenant and roles, loads or checks thread ownership, and only then forwards the request. A thread identifier is not proof of ownership, even if it is difficult to guess. Use the same rule for state, cancellation, deletion, resume, and any custom list endpoint. Memory deserves the same care. Candidate management can operate across namespaces, and `memory.resolveScope` receives route/app-root context—not verified request identity automatically. Do not let model-selected or caller-selected scope strings establish ownership. ## Pass verified identity to tools For execution routes, outer authentication can forward a signed/internal identity header or trusted request context to B4.run middleware. The middleware verifies or consumes only that trusted value and returns canonical identity through `allow({ ... })`. Tools read it from `ctx.middleware`. ```ts title="src/middleware.ts" import { allow, defineMiddleware, reject } from "@b4run/sdk" export default defineMiddleware(async (req) => { const identity = await verifyInternalIdentity(req.headers.authorization) if (!identity) return reject(401, { error: "Unauthorized" }) return allow({ userId: identity.userId, tenantId: identity.tenantId }) }) ``` The model and request body must not choose those canonical fields. Keep authorization-sensitive tool arguments separate from verified identity, and have the tool derive tenant filters and credentials from `ctx.middleware`. ## Inner agent controls After service authentication and tenant authorization, use [Access Control](/docs/access-control) to compose narrower controls: - [tool scope](/docs/tools#scoping-a-routes-tools) limits which tools are offered; - [Permissions](/docs/permissions) gates commands, paths, approved tools, delegations, and selected memory writes; - [Execution Sandbox](/docs/sandbox) constrains B4.run's workspace filesystem and shell backends; - [Subagents](/docs/subagents#delegation-policy) can guard the input sent to a child. These controls do not isolate arbitrary application code. Authored tools execute in the app process unless they isolate themselves; give their database clients, cloud credentials, network access, and filesystem behavior least privilege of their own. ## Secrets and stored data Do not put secrets in `b4.config.ts` values that cross a build boundary. The Hono target serializes JSON-compatible configuration into the generated `app.mjs` build artifact. Use platform bindings or environment variables for secrets, and keep emitted artifacts out of places where their contents are exposed. Protect `.env`, provider keys, database URLs, internal identity-signing keys, and sandbox credentials with the host's secret system. Scrub verified tokens and sensitive tool arguments from logs and traces. Postgres rows are plaintext application data unless the app or platform adds encryption. Apply transport encryption, storage encryption, backup protection, database roles, row or schema isolation where appropriate, and an audited deletion workflow. ## Target differences | Target | Service boundary | Inner behavior | |---|---|---| | Node (`b4 start`, `serveRuntime`, generated server) | Your reverse proxy or Node host owns blanket auth | B4.run middleware covers execution/AG-UI only; Node can use configured sandboxes | | Hono | Hono/platform middleware must protect the rooted B4.run app | B4.run execution middleware is included in the static manifest; filesystem workspace and B4.run sandbox features are gated off this target | | LangSmith | The LangSmith/platform boundary owns authentication | Generated graph entries do not include B4.run HTTP middleware; route tool scope and applicable agent controls remain part of graph materialization | Review the exact capabilities of the selected [Deployment Options](/docs/deployment) instead of assuming a control transfers unchanged between targets. ## Production checklist - Restrict network access and require authentication on every endpoint group in the matrix. - Map verified principals to application-owned tenant and thread records. - Reject cross-tenant reads, state access, runs, resume, cancellation, and deletion. - Treat memory-candidate routes as privileged administrative operations. - Pass canonical identity to tools through trusted middleware context, never model input. - Run with least-privilege database roles, provider keys, authored tools, and sandbox credentials. - Keep secrets out of generated artifacts and redact them from logs and traces. - Test middleware-bypassing routes and cross-tenant attempts in the deployed topology. - Re-review boundaries whenever the build target or embedding host changes. ## Related --- ### Access Control # Access Control B4.run has four independent decision planes. They compose — none substitutes for another, and an app may use the planes relevant to each route. These planes do not authenticate the whole HTTP service or establish tenant ownership. Protect health, thread management, state, cancellation, AG-UI, execution, and memory-candidate routes at the service edge; see [Security Architecture](/docs/security-architecture). B4.run middleware is a narrower execution hook. | Layer | Question it answers | Docs | |---|---|---| | Tool scoping | *Which* tools can the model call? | [Tools](/docs/tools#scoping-a-routes-tools) | | Permissions / HITL | *Whether* a given call runs | [Permissions](/docs/permissions) | | Execution sandbox | *What* an allowed, approved call can actually touch | [Sandbox](/docs/sandbox) | | Guarded delegation | *Whether* a child receives the delegated input before any of its tools execute | [Subagents](/docs/subagents#delegation-policy) | ## Tool scoping — which tools `agent({ tools: { allow, deny, approve, constrain } })` controls the surface offered to the model. `deny` revokes a tool so it's never wired into the generated entry; `allow` grants back a capability tool withheld from a subagent, such as `readFile` or `runBash`; `deny` wins when a name appears in both. This is enforced at composition time — a denied tool is never wired in, so the model has no way to call it, valid or not. ```ts title="src/app/ops/index.ts" export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } }) ``` Scoping controls the *surface*, not what a granted tool does once invoked — a granted `writeFile` can still write anywhere its implementation permits. See [Tools](/docs/tools#scoping-a-routes-tools). ## Permissions — whether a call runs Two gates are on by default for the built-in workspace tools: `runBash` commands are matched against allow/deny patterns, and filesystem paths outside `workspace/` are permission-gated. An unmatched ("unknown") call pauses the run and asks a human, unless `permissions.mode` is set to `non-interactive` (fail-closed) or `bypass` (dev/test only). Two more gates ride the same interrupt machinery: `tools: { approve: [...] }` requires human approval before a named tool call, and `memory: { writes: "ask" }` gates belief-contradiction memory writes. Use `tools.approve` for a separately authored tool such as `deployProd`; workspace command and path tools already use their pattern-aware gates. See [Permissions](/docs/permissions) for the interrupt payloads and resume flow. ## Execution sandbox — what a call can touch Even a tool that's allowed and approved still runs somewhere. By default that's the local `workspace/` directory on the host. Adding a `sandbox` key to `b4.config.ts` routes every `readFile`, `writeFile`, `listDir`, and `runBash` call for a thread into an isolated environment instead — filesystem, shell, and (optionally) network. B4.run ships a Docker reference provider and a [Kubernetes provider](/docs/sandbox/kubernetes) behind the same `SandboxProvider` contract. See [Execution Sandbox](/docs/sandbox) for the provider-neutral boundary. ## Guarded delegation — whether a child receives input A parent's `delegation` policy runs at the final dispatch boundary, before the child starts. Its allow, deny, approve, or constraint decision determines whether the child receives the delegated input at all; only after that decision allows dispatch can the child's own tool policies and permission gates run. See [Subagents](/docs/subagents#delegation-policy). ## How they compose A route that uses pattern-gated shell commands in an isolated environment, separately approves an authored deployment tool, and reviews delegated input stacks all four planes: ```ts title="b4.config.ts" import { config } from "@b4run/cli" import { dockerSandbox } from "@b4run/sandbox" export default config({ permissions: { allow: { bash: ["ls", "cat"] }, deny: { bash: ["rm -rf", "sudo"] }, }, sandbox: { provider: dockerSandbox({ image: "node:24-slim" }) }, // what runBash can touch }) ``` ```ts title="src/app/ops/index.ts" import { agent } from "@b4run/sdk" import researcher from "./subagents/researcher/index.js" export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["writeFile"], approve: ["deployProd"] }, subagents: { researcher }, delegation: { rules: { researcher: { action: "approve", reason: "Review delegated customer context." }, }, }, }) ``` Tool scoping without a sandbox still lets an allowed `runBash` touch the whole host filesystem. A sandbox redirects only B4.run's built-in workspace tools — `readFile`, `writeFile`, `listDir`, and `runBash` — into the isolated environment. Authored tools such as `deployProd` still execute in the app process and need their own least-privilege and isolation design. Permissions without a sandbox still pause for approval, but an approved workspace call runs on the host. Guarded delegation controls whether a child receives input, not what that child can call or access after it starts. ## Related --- ### Thread Access # Thread Access `src/thread-access.ts` answers one question: **may this caller create, read, mutate, or destroy this thread?** That is a different question from the one [middleware](/docs/middleware) answers ("may this caller run this route"), and it is keyed on a different thing. A thread has no owning route: every endpoint that starts a turn overwrites the thread's `route` metadata, so any caller allowed to run any route on a thread can move that identity onto a route of their choosing. Thread authorization is therefore keyed on the thread object, and lives in its own file with its own failure policy. Without a policy file, every thread endpoint is open to anyone who can name a thread id — and ids are neither secret nor collision-proof (`t-` plus four random bytes). This page is how you close that. ## The shape ```ts title="src/thread-access.ts" import { defineThreadAccess, deny, permit, type ThreadAccessRequest } from "@b4run/sdk" import { principalOf } from "./auth.js" // shared with src/middleware.ts const owned = async (req: ThreadAccessRequest) => { const user = await principalOf(req.headers) if (!user) return deny() // `thread: undefined` reaches `delete` too. Denying it FIRST, ahead of the // admin branch, is what keeps "not yours" and "does not exist" the same // answer; an admin allowed to delete a row that never existed reopens the // existence oracle this default closes. if (req.thread === undefined) return deny() const owner = req.thread.access?.ownerId if (owner === undefined) return user.isAdmin ? permit() : deny() // legacy thread if (owner === user.id) return permit() if (req.action === "read" && user.isAdmin) return permit() return deny() } export default defineThreadAccess({ create: async (req) => { const user = await principalOf(req.headers) return user ? permit({ ownerId: user.id, org: user.org }) : deny() }, // Also handles the post-create `update` recheck: the row just stamped has // `ownerId === user.id`, so `owned` permits it; a row the store handed back on // an id collision carries someone else's, so `owned` denies and the caller // never receives a thread they do not own. fallback: owned, }) ``` B4.run probes four paths, in order: `src/thread-access.ts`, `src/thread-access.js`, `thread-access.ts`, `thread-access.js`. The `default` export wins; a named `threadAccess` export is the fallback. `create-b4-app` scaffolds this file, and the `src/auth.ts` it imports, as `src/thread-access.ts.example` and `src/auth.ts.example` inside the B4.run app — that is the generated root in the `basic` template, and the `server/` package in the research workspace. Drop both `.example` suffixes to activate them — B4.run probes for the exact names above, so an unrenamed scaffold changes nothing. They ship inert because a deny-by-default policy denies every request from a caller the app cannot yet authenticate, and a generated app has no identity provider on its first run. `fallback` is required. "I forgot to handle delete" is a compile error rather than a silent allow — or a silent deny — on every request of that action. ## What the policy receives | Field | Notes | |---|---| | `action` | `"create"`, `"read"`, `"update"` or `"delete"` — which handler was selected. | | `operation` | The specific endpoint, e.g. `"thread.state"`, for policies that need finer grain than `action`. | | `threadId` | `undefined` only on `POST /threads`, whose id is server-generated. | | `thread` | The stored row, or `undefined` when no row exists. | | `headers` | Lowercase keys, repeated headers joined with `", "`. | | `method`, `url` | The originating request's method, and its path plus query. | | `requestedMetadata` | Client-supplied metadata on a create, already stripped of B4.run's reserved key. `undefined` everywhere else. | | `resuming` | `true` when this request carries a resume credential and will continue a parked turn. Always a boolean. See below. | ### `resuming`: which requests continue a parked turn `resuming` is `true` when the request answers a parked human-approval prompt — it carries an `interruptId`/`resumeKey` credential and will continue an already-interrupted run rather than start a fresh one. It is `false` on every other request, and it is never absent, so a policy writes `if (req.resuming)` and never `?? false`. **Both doors resume.** Two different endpoints continue a parked turn, and only one of them says so in its `operation`: | Request | `operation` | `resuming` | |---|---|---| | `POST /threads/:thread_id/resume` | `run.resume` | always `true` | | `POST /agui/{routeId}` carrying a `resume` array | `run.agui` | `true` | | `POST /agui/{routeId}` with no `resume` | `run.agui` | `false` | | everything else | — | `false` | That middle row is the reason this field exists. An AG-UI resume reports `run.agui`, exactly as an ordinary AG-UI turn does — the only thing that distinguishes them is the request body, which a policy never sees. So **a policy that wants resumes held to a higher bar — step-up auth, a second approver, extra logging — must check `req.resuming`, not `req.operation`.** Keying that rule on `operation === "run.resume"` leaves every CopilotKit-driven resume ungoverned. `operation` and `resuming` answer different questions and neither replaces the other: `operation` is endpoint identity ("which door did this come through"), `resuming` is request shape ("what is in the body"). An endpoint that gates more than once for a single request — the gate before its side effects, the mid-flight recheck, the implicit create's recheck — reports the **same** `resuming` at every one of them. One request, one value. ```ts import { defineThreadAccess, deny, permit } from "@b4run/sdk" export default defineThreadAccess({ // ... update: (req) => { if (!owns(req)) return deny() // Resuming a parked approval is the moment the agent gets to act, so it // costs a fresh step-up — on BOTH doors, which `operation` alone cannot see. if (req.resuming && !hasStepUp(req.headers)) return deny({ status: 403 }) return permit() }, fallback: (req) => (owns(req) ? permit() : deny()), }) ``` `thread.metadata` is client-supplied and untrusted — anyone who can create a thread can write anything into it. **Authorize against `thread.access`**, which is the stamp your own `create` decision returned. B4.run stores it under a reserved key (`b4:access`) that it strips from client input on every create path, so a client cannot forge one, and it is lifted out of `metadata` before your policy sees the row. `thread.access` is `undefined` for a thread created before you adopted a policy. B4.run does not guess what that should mean — decide it explicitly. The two sane answers are admin-only (the `owned` example above) or a one-time backfill (below). ### Comparing headers Repeated headers arrive joined. `X-User-Id: victim` plus `X-User-Id: attacker` is the single string `"victim, attacker"`. That is safe under `===` and unsafe under `includes`, `startsWith` or `split(",")` — which is exactly what a hand-rolled parser reaches for. Compare with strict equality, and prefer a signed token over a trusted header wherever the deployment allows it. ## Denials `deny()` produces **404 for a `read`, 403 for everything else**. | Endpoint | Default deny | |---|---| | `POST /threads` | 403 `thread_access_denied` | | `GET /threads/:thread_id` | 404, the same body a genuine miss returns | | `GET /threads/:thread_id/state` | 404, the same body a missing checkpoint returns | | `DELETE /threads/:thread_id` | 403 `thread_access_denied` | | `POST /threads/:thread_id/cancel` | 403 `thread_access_denied` | | `GET /threads/:thread_id/pending_interrupts` | 404 `thread_not_found`, the same body a genuine miss returns | | `POST /threads/:thread_id/runs/stream` | 403 `thread_access_denied` | | `POST /threads/:thread_id/runs/wait` | 403 `thread_access_denied` | | `POST /threads/:thread_id/resume` | 403 `thread_access_denied` | | `POST /agui/:routeId` | 403 `thread_access_denied` | The read default is 404 so a denial cannot be told apart from a miss, which is what stops anyone enumerating thread ids. The 403s sit on endpoints where the caller has already named a specific thread and asked to change it. `deny({ status, body })` overrides both. `status` accepts only `403` or `404` — a policy cannot mint a 200, a 500 or a redirect — and anything else falls back to the per-action default. **A `read` handler that returns 403 tells the caller the thread exists.** `deny({ status: 403 })` on a read is legal, and it reopens the enumeration channel the 404 default closes: `GET /threads/` becomes an existence oracle over a 32-bit id space. B4.run does not forbid it — an app that authenticates every caller and wants honest diagnostics is entitled to it — but make it a choice, not something you reach by copying the `update` branch. **Your `delete` handler must deny when `thread` is `undefined`.** `DELETE /threads/:thread_id` returns 204 today even for a thread that never existed, so a 403 would ordinarily mean "this exists and is not yours" — a bit DELETE does not leak. B4.run keeps it that way by invoking the policy with `thread: undefined` instead of short-circuiting, so an ownership policy denies both cases identically. A policy that allows deleting unknown threads puts the oracle back. The policy runs on **every** gated request, including when the row is missing. On `/state` that matters for a second reason: the checkpointer is a separate store from the threads store, so a transcript can exist for a thread whose row is gone, and skipping the gate would serve it ungated. ## Failure modes A policy that **throws** becomes a 500 and the endpoint's real work never runs. That is fail-closed and honest — a 403 would hide a broken policy behind what looks like a working one. A policy that returns something that is neither a well-formed allow nor a well-formed deny (a missing `return` on one branch, a copy-pasted `{ action: "continue" }`) **denies at the per-action default** and logs a warning naming the operation, the thread id and the value. It is deliberately not pinned to 403: forcing 403 on a read would make a broken policy answer differently from a working one and hand back the enumeration oracle. A policy that **hangs** is not defended. B4.run imposes no timeout on a policy call, so a slow identity provider degrades into stuck requests. Put your own timeout around any network call in a policy and fail closed on it. ## Load failures Route middleware that fails to import degrades to "no middleware". An authorization policy must not: a syntax error, a missing dependency or a thrown environment assertion would boot the app with every thread world-writable and no log line. So the loader decides existence with a filesystem check **before** the import, and an import failure can then only mean "the policy is broken". - No policy file on disk — no gate, exactly today's behavior. - A policy file that fails to import — the boot fails with `B4_E3003`. - A policy file that imports but binds no usable policy — the boot fails with `B4_E3003`. Four cases are distinguishable at a glance in the message: no `default` or `threadAccess` export; the bound value is not an object; `fallback` is missing or is not a function; a per-action key is present but is not a function. There is no path on which a policy you wrote resolves to "allow everything". See [Error codes](/docs/errors). Every boot logs which layer the policy came from, or that there is none — it is the one signal that says a policy vanished: ``` B4.run: thread access policy bound from src/thread-access.ts B4.run: no thread access policy (all thread endpoints are open) ``` ## Build targets `b4 build --target langsmith` **fails** with `B4_E1005` while a policy file exists, and always will: that target materializes per-route graphs and no B4.run HTTP layer, so there is nowhere for the hook to run, and a build that silently dropped it would deploy every thread endpoint ungated. Put equivalent authorization at the LangSmith platform boundary instead. `hono` and `vercel` carry the policy. They share one emitter, and the built manifest has a slot for it: the policy rides in as a static import, and the generated entry point records that the build saw one. If a manifest generated before the app grew a policy is later deployed beside a newer entry point, the boot **fails** rather than coming up silently ungated — there is no disk to probe on a bundled runtime, so nothing else would notice. The `node` target needs nothing special; its emitted server reaches the same disk probe `b4 dev` does. An app with no policy file builds for every target exactly as before. See [Deployment](/docs/deployment) and [Edge and Hono](/docs/deployment/edge). ## Adopting a policy on an existing app Threads created before the policy have `access === undefined`. Either handle that branch (admin-only is the usual answer) or backfill. Backfill is an **operator script** that constructs the threads store directly. Every in-runtime path to the reserved key is deliberately shut: there is no HTTP endpoint for metadata updates, and the runtime asserts that none of its own metadata patches carry the key. ```ts title="scripts/backfill-thread-access.ts" import { THREAD_ACCESS_METADATA_KEY } from "@b4run/sdk" import { createThreadsStore } from "@b4run/sqlite-storage" const store = createThreadsStore({ path: ".b4/threads.sqlite" }) for (const thread of await store.listThreads()) { if (thread.metadata[THREAD_ACCESS_METADATA_KEY] !== undefined) continue await store.updateMetadata(thread.thread_id, { [THREAD_ACCESS_METADATA_KEY]: { ownerId: "operator-assigned-owner" }, }) } ``` This is in the same class as `b4 inspect` and `b4 memory`: a local operator with filesystem or database access, documented rather than defended. ## Identifiers, never secrets `GET /threads/:thread_id` returns the raw thread, reserved key included. That is deliberate — hiding it would break round-tripping and make the stamp undebuggable, and that endpoint is gated by the very policy the stamp feeds. Put identifiers in a stamp. Do not put anything in it whose disclosure to a caller your `read` policy admits would matter. ## The run endpoints and middleware The run endpoints — `POST /threads/:thread_id/runs/stream`, `/runs/wait`, `/resume` and `POST /agui/{routeId}` — plus `GET /threads/:thread_id/pending_interrupts` are on this policy **as well as** [route middleware](/docs/middleware). The two compose as AND: middleware answers "may this caller run this route", the policy answers "may this caller touch this thread", and a request needs both. Neither replaces the other, so keep middleware doing the per-caller work it does today. A `run.*` operation on a thread that exists arrives under `action: "update"` — starting a turn mutates the thread. Three of these endpoints (`/runs/stream`, `/runs/wait`, `POST /agui/{routeId}`) also **create** the thread when the id names no row, and B4.run asks about that under `action: "create"`, then again as the `update` recheck that follows every create — the same two-step `POST /threads` uses. `/resume` is the exception: it needs an already-parked thread, creates nothing, and is only ever an `update`. The stamp your `create` handler returns is written into the row, exactly as it is on `POST /threads`. So a thread born on a run endpoint has an owner from its first turn, and `access === undefined` keeps its one meaning: **created before you adopted a policy**. `POST /threads` mints the id itself. The run endpoints take the one the caller sent — `POST /agui/{routeId}` especially, because CopilotKit picks its `threadId` in the browser and never calls `POST /threads`. Whoever names an unused id gets stamped as its owner, which also means anyone your `create` handler admits can claim an id **before** the user who meant to use it, and hold it against them. If that matters, mint ids with `POST /threads` and hand the returned `thread_id` to the client. Those ids are server-generated, so nobody can call them first. The recheck is what makes the race safe rather than merely unlikely: two callers can both find the row absent, and a store that upserts hands the loser the winner's row. B4.run re-authorizes the row that actually came back, under `update`, before the run proceeds — it never compares stamps, because a `permit()` with no stamp leaves both sides `undefined` and the comparison would pass. `GET /threads/:thread_id/pending_interrupts` composes both checks as AND — the route that parked the interrupts must admit the caller *and* this policy must permit the read — and which one refused is deliberately not visible in the response: a thread-access deny returns the handler's own `404 thread_not_found`, the same bytes a genuine miss returns, while a route-identity refusal returns whatever your middleware returns. B4.run logs neither, so log the denial inside your own policy if you need to tell them apart. `POST /threads/:thread_id/resume` and `GET /threads/:thread_id/pending_interrupts` are the two endpoints whose thread-access gate runs **before** middleware, so on them a caller middleware would have refused with a `401` receives this policy's deny instead — a `403` on `/resume`, a `404` on `/pending_interrupts`. That ordering is forced, not preferred. Middleware needs the route key, and on both endpoints the route key is read from the thread's own metadata — so resolving the identity middleware would authorize against means reading a thread the caller is not yet authorized to read. On `/resume`, gating later also let a denied caller take the thread's resume claim (a denial of service against a parked turn, needing no credential at all) and read the `400`/`409` codes as an oracle on a guessed `interruptId`/`resumeKey`. Both checks still apply on both endpoints; only which one answers first is decided here. --- ### Permissions # Permissions Permissions are B4.run's human-in-the-loop gate. The runtime gates two workspace operations by default: - **`runBash` commands** (`kind: "command"`) — shell commands are matched against allow and deny lists before executing. - **Filesystem paths outside `workspace/`** (`kind: "path"`) — file reads, writes, and directory listings that would escape the workspace root are permission-gated. Three further gates build on the same interrupt machinery and are covered below: opt-in **per-tool approval** (`kind: "tool"`), parent-owned **subagent approval** (`kind: "subagent"`), and **memory-write approval** (`kind: "memory"`). In an interactive agent run, unknown operations pause and ask the human. Other modes are covered below. ## Configuration Set allow and deny lists in `b4.config.ts`: ```ts title="b4.config.ts" export default { permissions: { // mode?: "interactive" | "non-interactive" | "bypass" (default "interactive") allow: { bash: ["ls", "cat"] }, deny: { bash: ["rm -rf", "sudo"] }, }, } ``` `allow` and `deny` each map a gate key (`bash`, `readFile`, `memory`, and so on) to an array of pattern strings. The research scaffold, for example, allows safe read-only commands and denies destructive ones — but leaves the network-fetch script off the allow list so the first run surfaces a prompt. Static entries remain in `b4.config.ts` as configuration; they are evaluated alongside runtime decisions and are not copied or seeded into the runtime permission store. Only an `always` decision adds an allow entry to that store. The default Node store persists runtime entries in `.b4/permissions.json`, while custom stores and the Postgres store may persist them elsewhere. ## How matching works For every `runBash` call the runtime runs this sequence: 1. **Deny first** — if any deny pattern is a prefix of the command string, the call is rejected immediately. 2. **Then allow** — if any allow pattern is a prefix of the command string, the call proceeds. 3. **No match → "unknown"** — the outcome depends on the mode. Matching depends on the gate: | Gate | Stored key and `suggestedPattern` granularity | Matching | |---|---|---| | Bash command | `bash`; the first two command tokens, such as `node scripts/fetch-source.mjs` | Prefix | | Filesystem path | The operation (`readFile`, `writeFile`, or `listDir`); canonical parent directory with a trailing slash | Prefix | | Memory write | `memory`; workspace-and-route namespace with a trailing `|` | Prefix | | Authored or capability tool approval | Reserved `tool`; tool name | Exact | | Subagent approval | Reserved `subagent`; serialized parent route id and parent-local child name | Exact | Thus `"ls"` covers `ls`, `ls -la`, and `ls workspace/corpus`. Use longer bash, path, and memory prefixes to be more specific. Reserved `tool` and `subagent` entries never prefix-match. ## Modes | Mode | Unknown command behavior | |---|---| | `interactive` (default) | Run pauses; an interrupt is sent to the client for human decision | | `non-interactive` | Unknown commands are denied immediately (fail-closed) | | `bypass` | All commands are allowed without checking — dev/test only | Override the mode for a single run without touching the config by setting the `B4_PERMISSIONS_MODE` environment variable: ```sh B4_PERMISSIONS_MODE=non-interactive b4 dev ``` The env var takes precedence over `permissions.mode` in `b4.config.ts`. ## Per-tool approval Alongside the `runBash` and path gates, a route can require human approval before *any* named tool call — authored route tool or capability tool — via the third `tools` knob, `approve`: ```ts title="src/app/ops/index.ts" export default agent({ model: "gpt-5", systemPrompt: "…", tools: { approve: ["deployProd"] }, }) ``` Every call to `deployProd` pauses the run and emits a `kind: "tool"` interrupt, unless the tool is pre-approved (see below) or a prior "Always" decision already covers it. An argument [constraint](/docs/tools#constraining-arguments) can escalate a specific call to this same prompt by returning `{ approve: true }` — e.g. allow staging deploys silently but require approval for prod. The "Always" decision is still **name-level** (it persists the tool name), so it auto-approves future escalations of that tool; use an outright `deny` in the predicate if a case should never run. ### The tool interrupt payload ``` event: interrupt data: { "interruptId": "perm-ghi789", "type": "permission-request", "kind": "tool", "detail": { "toolName": "deployProd", "argsPreview": "{\"env\":\"prod\",\"version\":\"1.4.2\"}", "suggestedPattern": "deployProd" } } ``` `detail.argsPreview` is a display-only JSON preview of the call's arguments (truncated around 500 characters) — it is shown to the human but never matched against or persisted. `detail.suggestedPattern` is always the tool name itself. ### Decisions are name-level Resuming a `kind: "tool"` interrupt uses the same `once` / `always` / `deny` decisions as the other gates, but the semantics are tool-name-level rather than pattern-level: | Decision | Effect | |---|---| | `once` | This call runs. The next call to the same tool prompts again. | | `always` | Adds the tool **name** under the reserved `tool` key in the configured permission store. Matching is **exact-name**, not prefix. | | `deny` | The call is blocked and nothing is persisted. Unlike the workspace gates (which surface a thrown error), the denial reason is **returned as the tool result** — the model sees it as a normal tool response and can adapt. | ### Pre-approval in config Pre-approve a tool so it never prompts, by adding it to `permissions.allow.tool` in `b4.config.ts`: ```ts title="b4.config.ts" export default { permissions: { allow: { tool: ["deployProd"] }, }, } ``` ### Mode behavior `approve` respects the same `permissions.mode` as the other gates: `non-interactive` denies an unapproved tool call immediately (fail-closed), and `bypass` skips the gate entirely (dev/test only). ### Coexistence with the bash and path gates `runBash`, `readFile`, `writeFile`, and `listDir` keep their own pattern-aware allow/deny gates — putting them in `approve` is redundant and would double-prompt. `b4 check` warns when a route's `approve` list: - names one of these internally-gated tools (redundant — already gated), - overlaps with `deny` (a dead entry, since deny wins), or - approves a capability tool on a subagent that the subagent hasn't also granted itself via `allow` (a no-op until allow-listed). ## Subagent approval A parent route can require approval before dispatching a direct child with `delegation`. Omitting `delegation` allows dispatch by default; `default: "deny"` creates an allowlist, while `default: "approve"` requires review for every child that does not have an explicit rule. ```ts title="src/app/support/index.ts" export default agent({ model: "gpt-5-mini", systemPrompt: "Coordinate support work.", subagents: { researcher }, delegation: { rules: { researcher: { action: "approve", reason: "Research may send customer context to a specialist.", }, }, }, }) ``` The dispatch pauses before the child starts and emits a `kind: "subagent"` interrupt on the root parent stream: ``` event: interrupt data: { "interruptId": "perm-subagent-123", "type": "permission-request", "kind": "subagent", "callId": "parent-task-call-1", "detail": { "parentRouteId": "/support", "subagentName": "researcher", "subagentRouteId": "/support/subagents/researcher", "inputPreview": "Find the applicable refund policy.", "reason": "Research may send customer context to a specialist.", "suggestedPattern": "[\"/support\",\"researcher\"]" } } ``` The input preview is bounded, display-only text. Approval persistence uses the exact parent route id and parent-local registration name serialized in `suggestedPattern`; it does not use the target route id or input. Therefore `always` approves only that exact edge. The same child name under another parent, another name under the same parent, and a nested child dispatch each require their own decision. Pre-approve that exact registration under the reserved `subagent` key: ```ts title="b4.config.ts" export default { permissions: { allow: { subagent: ['["/support","researcher"]'] }, }, } ``` The `subagent` key uses exact matching, like the reserved `tool` key. Explicit deny entries win. In `non-interactive` mode, an unapproved dispatch fails closed; `bypass` skips the approval gate but does not override static or constraint denial. `task` is internal and is invalid in every tool-policy field: `tools.allow`, `tools.deny`, `tools.approve`, and `tools.constrain`. Configure dispatch only through `delegation`; `b4 check` and route preparation report `B4_E1004` for any `tools.*.task` reference. ## Memory write approval (`writes: "ask"`) Routes with [long-term memory](/docs/memory/long-term) can gate belief *changes*: with `memory: { writes: "ask" }` in `b4.config.ts`, a `remember` call that would **supersede** an existing active memory interrupts with the old and new values. New facts and idempotent refreshes never prompt. - **Once** — this supersede proceeds. - **Always** — persists the route's namespace prefix under the `memory` key; all future overwrites in the route proceed silently. - **Deny** — the old memory stays active; the agent is told which memory was kept. Unlike bash/path/tool gates, `ask` **allows through** when no human can answer (non-interactive mode): headless, `ask` ≡ `auto`. It is a supervision affordance, not a security boundary. Explicit `deny` entries are honored in every mode except `bypass`. Hand-authored patterns should keep the trailing `|` terminator: `"workspace=app|route=/a|"` cannot collide with `route=/ab`. ## The interrupt payload When a command or path is "unknown" in interactive mode, the agent run pauses and the runtime emits an SSE event. The `kind` field tells you which gate fired. **`kind: "command"`** — a `runBash` command was not on the allow list: ``` event: interrupt data: { "interruptId": "perm-abc123", "type": "permission-request", "kind": "command", "detail": { "command": "node scripts/fetch-source.mjs https://example.com/api", "suggestedPattern": "node scripts/fetch-source.mjs" } } ``` **`kind: "path"`** — a filesystem operation targeted a path outside `workspace/`: ``` event: interrupt data: { "interruptId": "perm-def456", "type": "permission-request", "kind": "path", "detail": { "operation": "readFile", "path": "/Users/me/private/notes.md", "suggestedPattern": "/Users/me/private/" } } ``` `detail.suggestedPattern` is the prefix B4.run suggests you add to the allow list so the operation is approved automatically on future runs. The run stays paused until you resume it. ## Resuming an interrupted run Send a `POST /threads/:thread_id/resume` request with a `resume` entry for every interrupt currently pending on that root thread: | Decision | Effect | |---|---| | `once` | Allow this operation for this invocation only | | `always` | Allow and add the gate's exact `suggestedPattern` to the configured permissions store | | `deny` | Reject the operation without persisting a decision; the run continues with an error result | The ordinary HTTP endpoint accepts one strict envelope: ```json { "resume": [ { "interruptId": "perm-abc123", "status": "resolved", "payload": "once" }, { "interruptId": "perm-def456", "status": "cancelled" } ], "route": "/research#agent" } ``` `resume` may contain one or many entries, but it must address the complete current pending set exactly once. A `resolved` entry requires a `payload` of `once`, `always`, or `deny`. A `cancelled` entry omits `payload` and maps to denial. The top-level object requires exactly `resume` and `route`; entries also reject unknown or mixed fields. Nested child interrupts use the same endpoint and the root parent's `thread_id`. B4.run maps each public `interruptId` to the correct nested checkpoint; clients do not resume a child route or thread separately. The former scalar resume body is removed and is not parsed as a compatibility form. Here is the full sequence using curl (start the server with `b4 dev --port 2024`): ```sh # 1. Create a thread THREAD=$(curl -sX POST http://127.0.0.1:2024/threads \ -H 'Content-Type: application/json' \ -d '{}' | jq -r .thread_id) # 2. Start a run — stream until the interrupt fires curl -N http://127.0.0.1:2024/threads/$THREAD/runs/stream \ -H 'Content-Type: application/json' \ -d '{"input":{"messages":[{"role":"user","content":"fetch the API docs"}]},"route":"/research#agent"}' # ...SSE output... # event: interrupt # data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","detail":{"command":"node scripts/fetch-source.mjs ...","suggestedPattern":"node scripts/fetch-source.mjs"}} # 3. Resume with a decision curl -X POST http://127.0.0.1:2024/threads/$THREAD/resume \ -H 'Content-Type: application/json' \ -d '{"resume":[{"interruptId":"perm-abc123","status":"resolved","payload":"once"}],"route":"/research#agent"}' # The response streams the continuation as SSE ``` Choosing `"always"` is the only decision that persists: it adds the allow entry to the configured permissions store. `once` and `deny` are not persisted. With the default Node store, runtime entries live in `.b4/permissions.json`; a custom or Postgres store uses its configured backend. On subsequent runs the command is matched by the allow list and proceeds without prompting. ## Testing In `@b4run/testing`, use `expectInterrupt` and `harness.resume` to drive the approval flow in automated tests without a live server. See the [testing docs](/docs/testing-agents) for the full pattern. ## Related --- ### Retry # Retry Agent routes can opt into automatic retry of transient LLM failures via the `retry` field on `agent()`. Retries apply to rate limits, server errors, network timeouts, and OpenAI overload responses — but not to non-transient errors like invalid API keys or model-not-found. ## Configuring retry Set `retry` on the `agent()` descriptor: ```ts title="src/app/(public)/research/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", retry: { maxAttempts: 5, baseDelay: 500 }, systemPrompt: "You are a helpful assistant.", }) ``` | Field | Default | Notes | |---|---|---| | `maxAttempts` | `3` | Total number of attempts (including the first call). `1` disables retry. | | `baseDelay` | `1000` (ms) | Base delay before the first retry. Backoff is exponential with jitter. | If `retry` is omitted, agents use the defaults (3 attempts, 1s base delay). To disable retry entirely, set `maxAttempts: 1`. ## What's retried The retry policy retries on errors whose message indicates a transient condition: - **Rate limits:** `429`, `rate limit` - **Server errors:** `500`, `502`, `503` - **Network errors:** `ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, `timeout`, `network` - **OpenAI transient:** `overloaded`, `server_error` Anything else (invalid API key, model not found, schema validation errors, abort) fails immediately without retry. ## Backoff Delay before retry `n` (zero-indexed) is: ``` delay = min(baseDelay * 2^n + jitter, 10s) jitter = random(0, 500ms) ``` So with the defaults (1s base, 3 attempts): | Attempt | Delay before this attempt | |---|---| | 1 (initial) | 0 | | 2 | ~1000–1500 ms | | 3 | ~2000–2500 ms | The non-stream fallback honors the configured `baseDelay`. The cap at 10 seconds prevents pathological backoff for long retry chains. ## Streaming behavior For streaming routes, retry only applies if the failure happens **before any token or event is yielded to the client**. The current streaming path starts retries with a 1-second base delay regardless of the configured `baseDelay`. Once content has streamed, the partial response is committed — B4.run cannot retry a partially-emitted stream because the client has already seen content. In that case the error propagates through the stream. If you need stronger retry guarantees in streaming mode, wrap the call at a higher level in your client or use `/threads/:id/runs/wait` for operations where partial output is not useful. ## Abort signals An Agent Protocol viewer disconnect does not abort the run; the run stays available for the thread to resume. An AG-UI disconnect, explicit Agent Protocol cancellation (`POST /threads/:id/cancel`), and server shutdown do abort the run. The resulting signal reaches agent execution and tools as `ctx.signal`, but provider and tool operations must cooperate with that signal for pending I/O to stop promptly. ## Per-route, not global Retry is configured per `agent()` descriptor. Different routes can have different policies: ```ts // Critical billing-related route — fail fast on transient errors export default agent({ model: "gpt-5-mini", retry: { maxAttempts: 1 }, systemPrompt: "...", }) ``` ```ts // Best-effort summarization route — patient retry export default agent({ model: "gpt-5-mini", retry: { maxAttempts: 5, baseDelay: 2000 }, systemPrompt: "...", }) ``` There is no global retry config — each route states its own intent. ## Related --- ### Observability # Observability ## LangSmith tracing B4.run runs on LangGraph, which emits traces to LangSmith automatically when tracing is enabled. A single agent run produces a nested trace that includes every LLM call (with prompts, token counts, and latency), every tool invocation (input and output), and every subagent child run — all linked under one root run. This gives you a full picture of where time and tokens go without any instrumentation code in your routes. ## Enabling tracing Put your `LANGSMITH_API_KEY` in `.env` (or export it in your shell). When `b4 dev` loads the environment, it checks whether `LANGSMITH_API_KEY` is present and `LANGCHAIN_TRACING_V2` is not already set, then sets `LANGCHAIN_TRACING_V2=true` automatically. You do not need to add `LANGCHAIN_TRACING_V2` to your `.env` yourself. ```bash title=".env" LANGSMITH_API_KEY=lsv2_... LANGCHAIN_PROJECT=my-research-agent # optional — names the project in LangSmith ``` **To opt out**, set `LANGCHAIN_TRACING_V2=false` explicitly in your shell or `.env`. The auto-enable only fills variables that are not already set, so an explicit `false` wins. ## Reading a trace Open any run in the LangSmith UI. The hierarchy follows the LangGraph execution graph: - **Root run** — the full agent turn. Status is `success` when the turn completes normally. - **LLM runs** — one per model call, with the full prompt, sampled output, token usage, and latency. - **Tool runs** — one per tool call, with input and output. Subagent tool runs appear nested under the subagent's run. - **Subagent runs** — each `task({ subagent, input })` dispatch appears as a child of the root run. ### Human-in-the-loop interrupts in LangSmith A HITL permission interrupt appears in LangSmith as a tool run with status `interrupted` and a `GraphInterrupt` in the error field. This is a pause, not a failure — the root run remains `success` and the agent continues after the interrupt is resolved. Do not confuse this with a real error, which has status `error` and an exception string in the error field. ## Live SSE streaming (no account required) If you do not have a LangSmith account, the dev server's `runs/stream` endpoint streams every event in real time over SSE — no tracing account needed. Each event has a type (`chunk`, `tool_call`, `tool_result`, `plan_update`, `interrupt`, `subagent.*`, `done`) and a JSON payload you can pipe to any terminal or client. See [Agent Protocol](/docs/dev-server/agent-protocol) for the full event-type table and connection instructions. ## Related --- ### Inspector # Inspector The B4.run Inspector is a browser-based UI for inspecting a running B4.run app's runtime state. It ships as its own package, `@b4run/inspector`, and opens with `b4 inspect`. The first panel is **Memory** — browse, search, and govern the app's [long-term memory](/docs/memory/browse) records — and the shell is panel-based, so future panels (threads, runs, sandbox) slot in alongside it. ## Launching ```bash b4 inspect ``` The CLI starts the inspector's server on `127.0.0.1` at a free port, prints the URL, and opens your browser. Flags: - `--cwd ` — inspect a different app root (defaults to the current directory). - `--port ` — bind to a stable localhost port instead of a free one. - `--env-file ` — load a `.env` file before resolving the store (overrides `b4.config.ts` `env` and the default `./.env`) — the same precedence as `b4 dev`. Apps scaffolded with `create-b4-app` ship the inspector as a `devDependency`. In an existing app, install it once: ```bash npm i -D @b4run/inspector ``` `b4 inspect` resolves the package from the app's own `node_modules`; if it is missing, the command prints the install hint and exits. ## Which store it inspects The inspector serves the app's **live** memory store — it loads your `b4.config.ts` and uses `config.memory.store` exactly as the dev server does. The default SQLite store, the [pgvector backend](/docs/memory/retrieval#postgres-backend-pgvector), and custom `MemoryStore` implementations all work unchanged. Two caveats follow from the inspector running as a separate process: - It constructs a **second store instance** from your config. For SQLite that is a second connection to the same database file; for Postgres it is a second connection pool. Both are safe, but count it against connection limits. - The store must be **constructible from config alone**. A store that closes over live objects created elsewhere in your server process cannot be re-created by the inspector. ## The Memory panel - **Browse and filter** — every record across namespaces and statuses (not just candidates), with facet counts by status, kind, namespace, and source type. - **Search** — recall-equivalent ranked search, not a substring filter. It runs the same IDF-weighted keyword ranking the agent's `recall` tool uses, and when your config declares a [vector embedder](/docs/memory/retrieval#semantic-recall-opt-in) (and the provider key is available, e.g. via `--env-file`), the same hybrid keyword + vector fusion. - **Live refresh** — lists and facet counts poll every 2 seconds while the tab is visible, so agent writes show up as they happen. - **Detail sheet** — the full record (data, tags, confidence, source, supersession links) with the governance actions below. - **Approve** — promotes a candidate with the same supersede-aware reconciliation as `b4 memory approve`: a contradicting active record with the same identity is superseded (history preserved), an identical one dedupes the candidate, and otherwise the candidate simply activates. When approving would supersede an active memory, the sheet shows a callout and the button reads **Approve & supersede**. - **Reject / Forget** — hard-delete a candidate or any record, each behind a confirmation prompt. ### Timeline view With [episodic memory](/docs/memory/episodes#episodic-memory) enabled, the panel's list/timeline toggle switches to a **timeline** of what the agent did: records grouped by day, each row showing the time it happened, an outcome badge (`ok`/`error` for auto-recorded runs, `authored` for agent-written episodes), the episode summary, and the run duration. A window select (24h / 7d / 30d / all) narrows the timeline, and the kind filter defaults to `episodic` there. Clicking a row opens the same detail sheet as the list. Like agent recall, the panel excludes **expired** episodes — search does so intentionally (it mirrors what the agent's `recall` can see), with no escape hatch. For debugging retention, the list API accepts `includeExpired=1` to reveal rows that are past their TTL but not yet pruned. The [`b4 memory`](/docs/cli) CLI covers the same governance flow — list/search/inspect/approve/reject/forget — without a browser, for CI and remote shells. ## Security posture The inspector is a local development tool: - The server binds to `127.0.0.1` only — it is never reachable from the network. - Every API route verifies the `Host` header, and state-changing requests with a foreign `Origin` are rejected — protection against CSRF and DNS-rebinding from other pages open in your browser. - Destructive actions (reject, forget) require an explicit confirmation in the UI. ## Related --- ### Browse and Manage Memory # Browse and Manage Memory `MemoryStore.browse` is the administrative listing contract: it filters, sorts, counts, and paginates records across namespaces and statuses. `MemoryStore.search` is the agent-recall contract: it searches one exact namespace and may rank by keyword or vectors. Browse has no semantic ranking. B4.run does not expose a public memory browse HTTP endpoint. The Inspector is a local/internal development surface, and the candidate management endpoints cover only review. A production admin UI must be an application-owned, authenticated, authorized route. ## Keep the boundary server-owned Authenticate outside B4.run's model/tool boundary. Derive the tenant namespace prefix from the verified principal, then AND any narrower query with that server-owned prefix. Never accept the prefix, tenant, or user identifier as authority from request JSON. ```ts title="src/admin/memory.ts" import { BROWSE_MAX_LIMIT, BrowseQueryError, validateBrowseQuery, type BrowsePage, type BrowseQuery, } from "@b4run/memory/browse" import { serializeNamespace } from "@b4run/memory/namespace" type BrowseStore = { browse(query: BrowseQuery): Promise } type Principal = { tenantId: string; roles: readonly string[] } function badRequest(message: string): Response { return new Response(message, { status: 400 }) } export function createMemoryAdminHandler( store: BrowseStore, authenticate: (request: Request) => Promise, ) { return async (request: Request): Promise => { const principal = await authenticate(request) if (!principal.roles.includes("memory-admin")) return new Response("Forbidden", { status: 403 }) let raw: unknown try { raw = await request.json() } catch { return badRequest("Invalid JSON") } if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return badRequest("Expected a JSON object") } const input = raw as Record const tenantPrefix = `${serializeNamespace({ tenant: principal.tenantId })}|` if (typeof input.namespace === "string" && !input.namespace.startsWith(tenantPrefix)) { return new Response("Forbidden", { status: 403 }) } const query = { ...input, namespacePrefix: tenantPrefix, limit: input.limit ?? 50, } as BrowseQuery try { validateBrowseQuery(query, { maxLimit: BROWSE_MAX_LIMIT }) return Response.json(await store.browse(query)) } catch (error) { if (error instanceof BrowseQueryError) return badRequest(error.message) throw error } } } ``` The `@b4run/memory/browse` entry is pure contract code—types, validation, ordering, cursor codec, and range helpers—and does not import `node:sqlite`. Keep the store itself on the server and do not send database credentials or unrestricted browse queries to client code. Outer authentication is only the first check. Authorize the admin role, audit queries and mutations, rate-limit expensive listings, redact content where necessary, and bind every request to the server-derived tenant prefix. This handler assumes the administered collections declare a tenant-first scope with at least one following dimension (for example, `scope: ["tenant", "user"]`), making `tenant=|` a delimiter-bounded prefix. If a route includes `workspace` or `route`, B4.run's canonical dimension order places those first; define and test a different server-owned prefix strategy instead of searching for `tenant=` in the middle of a namespace string. ## Filters Top-level fields cover common cases: | Field | Meaning | |---|---| | `namespacePrefix` | Server-owned prefix restriction | | `namespace` | Exact, case-sensitive namespace, ANDed with the prefix | | `status` | `candidate`, `active`, or `superseded`; one value or a set | | `kind` | `semantic`, `episodic`, `procedural`, or `reflection`; one value or a set | | `sourceType` | `run`, `user`, `tool`, `eval`, or `human` | | `since`, `until` | Inclusive lower and exclusive upper event-time instants | | `now` | Excludes rows at or past `expiresAt` | | `limit`, `offset`, `cursor` | Page controls | Normalized `filters` are AND-combined and permit at most one predicate per field: - `status` and `kind`: `in`, `notIn`; - `content`: `contains`, `notContains`, `equals`, `notEquals`, `startsWith`, `endsWith`; - `namespace`: `equals`, `startsWith`; - `confidence`: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `between`; - `updatedAt`: `onDay`, `beforeDay`, `afterDay`, `betweenDays`, using UTC calendar days. Content filters are case-insensitive in both in-repo stores. SQLite's built-in `lower()` folds ASCII only, so non-ASCII content matching can differ from Postgres. For example, case variants containing `É` may match under the Postgres database's case-folding rules but not under stock SQLite. Namespace filters remain case-sensitive and byte-exact. Call `validateBrowseQuery` at the untrusted boundary even though the in-repo stores validate defensively. The detailed limits, constants, and error codes belong in the [memory API reference](/docs/api/memory#trust-boundaries); map a `BrowseQueryError` to a client error rather than retrying it as a store outage. Top-level `status: []` and `kind: []` are valid and match nothing: “any of no values” is false. In the normalized `filters` array, empty `in` or `notIn` filter values are invalid because a present filter with no choices is treated as a UI/query-construction error. Validate arbitrary JSON and map `BrowseQueryError` to HTTP 400 as the handler does; unexpected store or infrastructure failures remain server errors. ## Sorting `orderBy` is an ordered list of `{ field, dir }`. Sort fields come from a closed whitelist: `updatedAt`, `createdAt`, `confidence`, `namespace`, `kind`, and `status`; direction is only `asc` or `desc`. Store code resolves those names to known SQL columns and appends `id ASC`, so untrusted text never becomes a SQL identifier. The default is `updatedAt DESC, id ASC`. Do not add arbitrary column names by casting request JSON. If a UI needs a new sort, add it to the shared type, validator, resolution table, both stores, and conformance tests together. ## Pagination and a fixed clock Prefer `cursor` for a stable forward walk. A continuation is opaque and carries a query fingerprint plus the last ordered key; it is not a bearer token, authorization proof, or supported client-parsed format. Changing filters, sort order, namespace restrictions, or `now` causes rejection. Choose one fixed `now` before the first page and reuse it across the entire walk. Replacing it with `new Date().toISOString()` on every request changes the query fingerprint and can also change expiry membership. ```ts const now = new Date().toISOString() let cursor: string | undefined do { const page = await store.browse({ namespacePrefix: tenantPrefix, status: ["active", "candidate"], orderBy: [{ field: "updatedAt", dir: "desc" }], limit: 100, now, ...(cursor ? { cursor } : {}), }) await exportRecords(page.records) cursor = page.continuation ?? undefined } while (cursor) ``` The stores issue a continuation whenever a page is full rather than fetching one extra row. If the result count is an exact multiple of the limit, the final full page can have a continuation and the walk ends with one empty page. Treat that as normal completion. Offset remains useful for bounded, human-driven jumps, but inserts above the seam can displace records between requests. A cursor uses keyset continuation so inserts above the seam do not shift the next window. ## Count and snapshot semantics `BrowsePage.total` is the exact count of the entire matching set, not the current window and not the remaining rows after a cursor. Both in-repo stores read the page records and total in the same transaction snapshot. Without that boundary, concurrent writes could produce a records/total pair that never described one database state. The snapshot applies to one `browse` call, not a whole multi-request export. Use database-native snapshot/locking or an application export job if every page must represent one immutable point in time. ## Backend parity SQLite and pgvector intentionally share namespace, filter, order, and cursor behavior through conformance tests. That parity has explicit representation limits: - `kind` and `status` are closed ASCII lowercase enums, so their collation order agrees; - namespace and ID ties use byte/C ordering; - Postgres stores confidence as `float4`, so values that differ below float4 precision may tie there but remain distinct in SQLite; - timestamps use normalized ISO UTC strings. Within those constraints, the same query should continue safely across pages on either backend. Do not claim identical serialized rows or universal ordering for data outside the contract. ## Mutations and candidate review Browse returns records; it does not authorize changes. Build separate mutation handlers with record-level tenant checks before calling `update`, `delete`, approval, or rejection. Candidate review is already available through `GET /memory/candidates`, `POST /memory/candidates/:id/approve`, and `POST /memory/candidates/:id/reject`, but those management routes still require outer authentication and tenant authorization in a deployed service. Approval can reconcile semantic identities and delete a duplicate candidate. Refresh the current record after a mutation instead of assuming the row you displayed still exists unchanged. ## Test the admin surface Use the shared memory-store conformance tests for store implementations, then add application tests that prove: 1. an unauthenticated or unauthorized caller receives no records; 2. request JSON cannot override the server-derived tenant namespace prefix; 3. exact namespace, enums, source, time, confidence, content, and sort filters map correctly; 4. an invalid sort or reused cursor returns a client error; 5. one fixed `now` completes a cursor walk, including an exact-multiple empty final page; 6. concurrent writes cannot skew `records` from `total` inside one response; 7. cross-tenant mutation IDs are rejected after lookup. The contract authority is the exported types and pure browse modules plus the SQLite/pgvector implementations and their conformance suites. Do not infer production behavior from stale comments in another package. ## Related --- ### Upgrading # Upgrading B4.run is pre-1.0. The API surface is still moving, and the safest posture is to pin a version, read what changed, and upgrade deliberately rather than floating on a range. This page is the workflow, not a changelog — the actual list of what changed between any two versions lives on GitHub, not here. ## Fixed-group versioning Every publishable B4.run package — `@b4run/cli`, `@b4run/core`, `@b4run/sdk`, the other `@b4run/*` packages, and `create-b4-app` — is released together under one version number via [Changesets](https://github.com/changesets/changesets)' fixed-group mode. If `@b4run/cli` is at `0.8.12`, so is every other package in that release. There's no independent versioning between packages to reason about, and no compatibility matrix to check — install matching versions and you're done. The two Helm charts (`b4-sandbox-infra`, `b4-app`) track the same package train through their `appVersion` field, but they're published separately as OCI artifacts, not npm packages — see [Kubernetes](/docs/deployment/kubernetes) for how to pull a specific chart version. ## How to read what changed Each release is built from the changeset entries merged since the previous one — short, per-change Markdown files that describe what changed and why, written by whoever made the change. Two places to read them: - **GitHub Releases** — [github.com/cacheplane/b4run/releases](https://github.com/cacheplane/b4run/releases) lists every published version with its changeset entries rolled up into release notes. This is the fastest way to scan what changed between two versions. - **Per-package `CHANGELOG.md`** — each package under `packages/*/CHANGELOG.md` in the repository carries its own changelog, generated from the same changeset entries but scoped to that package. Before bumping `@b4run/cli` (and its siblings) to a new version, skim the GitHub release notes for every version between the one you're on and the one you're moving to — not just the latest. A changeset entry that looks unrelated to your app (a sandbox hardening default, a memory recall tuning change) can still change runtime behavior you depend on. ## Upgrade workflow ``` pnpm why @b4run/cli ``` or check the `@b4run/*` entries in your `package.json`. Open [GitHub Releases](https://github.com/cacheplane/b4run/releases) and read every entry between your current version and the target version. Bump every `@b4run/*` dependency in `package.json` to the same target version, then reinstall. ``` b4 typegen b4 verify b4 test ``` `b4 verify` runs five phases: app discovery and config validation; route discovery; tool type extraction and typegen rendering; advisory checks for missing dependencies and provider environment variables; and runtime readiness for Node plus any configured sandbox provider. `b4 test` re-runs your scenario coverage against the new version. See [CLI](/docs/cli) and [Testing](/docs/testing). ## Node 24 is the minimum Current B4.run packages declare `node >=24.0.0`, and `create-b4-app` refuses to scaffold on older releases. Before upgrading dependencies, move local version-manager files, CI runners, and custom container bases to Node 24 or later, reinstall with that runtime, and run `b4 verify`. Node 24 also supplies the npm and unflagged `node:sqlite` versions the scaffold expects. ## Node-only imports moved to `/node` If application tooling imports filesystem or process-backed helpers, update the import specifier while keeping the symbol unchanged: - From `@b4run/core/node`: `discoverRoutes`, `findB4App`, `assertB4RoutesDir`, `extractToolSchemasForRoute`, `extractToolTypesForRoute`, and `registerTsxLoader`. - From `@b4run/permissions/node`: `createPermissionsStore`. - From `@b4run/workspace/node`: `localFilesystem` and `localExec`. For example, change `import { discoverRoutes } from "@b4run/core"` to `import { discoverRoutes } from "@b4run/core/node"`. Runtime-agnostic contracts and helpers remain on each package's main entry; use `/node` only for the explicit Node surface. ## Pinning Pin exact versions (no `^` or `~` ranges) for `@b4run/*` packages until the project reaches 1.0 — a floating range means an unreviewed minor bump can land in CI or production without the deliberate read-the-notes step above. ## `toolOutput` is now gated off the `hono` target If your app configures `toolOutput` **and** names `"hono"` in `build.targets`, `b4 build` and `b4 check` now fail with `B4_E1005` where they used to pass. Nothing about your app changed; the gate did. [Tool-output offloading](/docs/context-management) spills oversized tool results to a file under `workspace/` and hands the model a pointer to it, and an edge runtime has no filesystem to spill to — so the feature never worked on that target. It was also the only gated feature whose config is plain JSON, which is exactly why it slipped through: the other gated keys are live objects that get stripped at the build boundary, while `toolOutput` was inlined into the bundle intact and then ignored at runtime. A green build and a worker that silently never offloads is worse than a failed build. Two ways forward: remove `toolOutput` from `b4.config.ts`, or drop `"hono"` from `build.targets` and deploy with the `node` target, which serves offloading normally. An empty `toolOutput: {}` expresses no intent and is not gated. **Node deployments are unaffected** — the gate is specific to the `hono` build target, and the matching runtime check (below) cannot fire on Node at all. ## Skills, `plan.md`, and route `memory.md` now work on edge targets The `hono` and `vercel` targets used to fail the build when a route shipped skills, and `plan.md` and `memory.md` silently did nothing there. `b4 build` now bundles all three into `modules.edge.mjs` and serves them from the manifest at request time. An app that removed its skills to pass the gate can restore them; nothing else changes. Files over the build's per-marker cap — 32 KiB for `SKILL.md` (a build-only cap) and `memory.md`, 64 KiB for `plan.md`, the last two matching the runtime limits — fail the build with `B4_E1005` by name (`b4 check` applies the same limits). See [Skills, `plan.md`, and route `memory.md` are bundled](/docs/deployment/edge#skills-planmd-and-route-memorymd-are-bundled). ## Gated features now fail loudly at request time, not just at build time `sandbox` and `toolOutput` used to be read and then quietly do nothing on a runtime with no filesystem. Both now raise `B4_E1005` on every request instead, naming the feature and the config key that introduced it; a manifest that records skills but bundles no bodies for them is reported the same way. See [What the edge cannot serve](/docs/deployment/edge#what-the-edge-cannot-serve). This closes a gap the build gate could not: composing an entry by hand over `@b4run/cli/fetch` is a supported way to deploy, and such an app never runs the `hono` target, so it never met the build gate at all. **No Node app can hit this, whatever it configures.** The check short-circuits before it reads a single config key whenever the runtime supplied filesystem fallbacks, and every Node entry point supplies them unconditionally. An absent sandbox on Node remains the documented degrade it has always been. The deployments that can hit it are edge ones, and for them the affected config was already doing nothing. ## `@b4run/postgres-storage` now requires a `pool` on its main entry `connectionString` has moved to a new `@b4run/postgres-storage/node` subpath. In `0.8.19` the main entry accepted either and built its own `pg` pool from a connection string; it no longer does, because it now imports `pg` for **types only** so that the package links on a runtime with no TCP sockets — which is what makes the [`hono` edge target](/docs/deployment/edge) possible at all. `connectionString` is gone from the main entry's option type, so passing it there is a type error, and the factory throws at construction naming the missing pool. Nothing fails silently. Two ways to migrate: ```ts // 1. Change the import — same factories, connectionString still works, and the // store still builds and owns its pool. import { postgresCheckpointer } from "@b4run/postgres-storage/node" // 2. Or build the pool yourself and keep the main entry. This is what you want // anyway when one pool serves all three stores. import { Pool } from "pg" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) // Do not skip this — see below. pool.on("error", (error) => { console.error("postgres pool client error (connection dropped):", error) }) postgresCheckpointer({ pool }) ``` If you take option 2, **attach an `'error'` listener to the pool you build.** `pg` emits that event on the pool when an *idle* client fails, and an EventEmitter `'error'` with no listener is an uncaught exception — the process exits. Idle connections are dropped as a matter of course (server restart, failover, `idle_session_timeout`), so a routine Postgres blip becomes an outage. Earlier versions built the pool for you and attached this listener themselves; once you own the pool, you own its error handling, and the stores deliberately will not attach one to a pool you passed in. The `/node` entry in option 1 still attaches it to pools it builds. The `/node` entry re-exports everything the main entry does, so option 1 is usually one line. Pool ownership is unchanged in both shapes: a pool the store built is ended by `close()`, an injected pool is left alone (`ownsPool`, defaulting to `false`). This shipped as a **patch** despite being breaking: under [fixed-group versioning](#fixed-group-versioning) a minor bump would move every `@b4run/*` package to `1.0.0`, which is not what a pre-1.0 project wants to say about one entry-point split. It is also why the advice at the top of this page — pin exact versions and read the notes — is not boilerplate. ## `kind: "reflection"` is now accepted [Memory distillation](/docs/memory/distillation) wired the `reflection` kind. `defineMemory({ kind: "reflection" })` and the generated `remember` tool now accept it — previously it was typed but threw *"memory kind 'reflection' is not yet wired"* at write time. Reflections are **append-only** (like episodic writes): a later insight never supersedes an earlier one, and `ask` mode never prompts for one. `b4 memory reflect` produces them, as `candidate` records by default. `procedural` remains the one typed-but-unwired kind and still throws. **No action required.** This change is purely additive — nothing that worked before behaves differently, and no existing app needs to change. Adopt it only if you want a reflection collection or want to run the distillation commands. ## MemoryStore now requires `browse` and `stats` The `MemoryStore` contract gained two required methods to power the [Inspector](/docs/inspector)'s Memory panel. If you implement a custom store for `config.memory.store`, add both: - `browse(q?)` — cross-namespace/status listing, returning `{ records, total }` with `records` ordered `updated_at DESC, id ASC` and `total` counting all matches (ignoring `limit`/`offset`). The optional query narrows by `namespacePrefix`, `status`, `kind`, and `sourceType`. - `stats(opts?)` — aggregate counts: `{ total, byStatus, byKind, byNamespace, bySourceType }`, optionally scoped to a `namespacePrefix`. The built-in SQLite and pgvector stores already implement both, and the `runMemoryStoreConformance` kit in `@b4run/testing` covers them — run it against a custom store to verify the contract. Two related behavior changes in the same release: - The config-facing store type is now the **full** `MemoryStore` contract — `delete` and `listCandidates` included — rather than the narrower capability-facing surface. A custom store that already satisfied the CLI's `b4 memory` commands is unaffected. - `b4 memory approve` (and the Inspector's Approve) now **reconciles supersession**: approving a candidate that contradicts an active record with the same identity key supersedes the old record instead of leaving two active rows; approving an identical duplicate dedupes it. ## MemoryStore now requires `prune` [Episodic memory](/docs/memory/episodes#episodic-memory) added a required retention method to the `MemoryStore` contract. If you implement a custom store for `config.memory.store`, add it: ```ts prune(opts: { now: string // the clock — rows with expiresAt <= now are expired namespacePrefix?: string // scope the pass to matching namespaces cap?: number // per-namespace cap for episodic records }): Promise<{ deletedExpired: number; deletedOverCap: number }> ``` Semantics: - **TTL** — delete every record whose `expiresAt` is at or before `now` (records without `expiresAt` never expire). - **Cap** — within each namespace, keep at most `cap` **episodic** records, deleting the oldest beyond it. No `cap` means no cap pass. The runtime episode recorder calls `prune` lazily after each write, and `b4 memory prune` runs it manually. Two related behavior changes in the same release: - `search` and `browse` accept **`since`/`until`** (ISO instants; `since` inclusive, `until` exclusive) comparing against `effectiveAt` with a `createdAt` fallback. - When a query supplies **`now`**, `search` and `browse` must **exclude expired rows** (`expiresAt <= now`). Queries without `now` are unchanged. The built-in SQLite and pgvector stores already implement all of this, and the `runMemoryStoreConformance` kit in `@b4run/testing` covers it — run the kit against a custom store to verify the contract. ## Related --- ### Deployment Options # Deployment Options Choose the **Node target** unless a named platform constraint determines otherwise. Choose LangSmith when that platform must own the LangGraph deployment envelope. Choose Hono only when a web-standard edge runtime is required and the application fits its explicit capability gate. ## Choose a target | Target | Artifact | Runtime and protocol | Middleware | Storage | Filesystem and sandbox | Node requirement | Evidence boundary | |---|---|---|---|---|---|---|---| | Node | `.b4/build/server.mjs` and a generated `Dockerfile` | B4.run HTTP runtime: Agent Protocol and AG-UI | B4.run HTTP middleware is included | Local defaults or injected/shared stores | Filesystem features and a configured execution sandbox are available | Node 24+ | Local runtime and container tests; validate your deployed proxy, stores, and model path | | LangSmith | `.b4/build/langgraph.json` and one entry per route | Platform-owned LangGraph entries, not the B4.run HTTP server | B4.run HTTP middleware is absent | Platform/runtime-owned | No B4.run sandbox manager or HTTP filesystem surface | Generated config currently says Node 22 while B4.run requires Node 24+ | Validate authentication and execution on the target platform | | Hono | `.b4/build/modules.edge.mjs`, `stores.mjs`, `app.mjs`, and `wrangler.toml` | Web-standard B4.run HTTP runtime: Agent Protocol and AG-UI | Included when discovered at build time | Generated request-scoped Postgres stores, or hand-composed stores | Filesystem, shell, workspace, long-term memory, and sandbox surfaces are gated | No Node runtime requirement for the emitted worker | Local workerd and Node round trips are not proof of a live provider deployment | Node and LangSmith are the defaults. Hono is opt-in. Specifying build targets replaces the defaults; it does not add to them: ```ts title="b4.config.ts" import { config } from "@b4run/cli" export default config({ build: { targets: ["node", "hono"] }, }) ``` ## Validate and build Run the checks in this order so contract failures appear before artifact inspection: ```bash b4 check b4 build b4 verify ``` `b4 check` validates the authored app and selected target constraints. `b4 build` emits the selected artifacts. `b4 verify` repeats the app, route, typegen, dependency/environment, and runtime-readiness phases against the resulting project state. A green command is not a substitute for testing the deployed authentication, routing, storage, and provider boundary. ## Target guides ## Production foundations Deployment packaging does not decide data durability, replica coordination, or the external security boundary. Review these before exposing any target: ## What B4.run does not do B4.run emits deployment artifacts; it does not provision infrastructure, host applications, or manage secrets. Use the [Deployment Options](/docs/deployment) chooser with the target platform's hosting and security controls. ## Self-hosting Use [Node and Docker](/docs/deployment/node) when you want to run the full B4.run HTTP runtime on infrastructure you operate. ## Troubleshooting - If expected files are missing, inspect `build.targets`: an authored list replaces the Node and LangSmith defaults. - If `b4 build` succeeds but the deployed host cannot import a package, verify that packages imported by emitted artifacts are declared where the host installs or bundles production dependencies. - If local protocol tests pass but the target fails, test the target's real transport envelope, authentication layer, environment injection, and durable stores. Node/Hono HTTP evidence does not establish LangSmith behavior, and local workerd evidence does not establish a live edge provider. ## Deploying to production (Node/Docker) This section moved to [Node and Docker](/docs/deployment/node). ## Deploying on Kubernetes This section moved to [Kubernetes](/docs/deployment/kubernetes). ## The LangSmith / LangGraph Platform path This section moved to [LangSmith](/docs/deployment/langsmith). ## Edge runtimes This section moved to [Edge and Hono](/docs/deployment/edge). ### The `@b4run/cli/fetch` entry point See [Edge and Hono](/docs/deployment/edge). ### The `hono` build target See [Edge and Hono](/docs/deployment/edge). #### Why the stores are per-request See [Edge and Hono](/docs/deployment/edge). #### What the edge cannot serve See [Edge and Hono](/docs/deployment/edge). #### What is proven, and what is not See [Edge and Hono](/docs/deployment/edge). ## Related Continue with [Production Topology](/docs/production-topology), [Persistence and Tenancy](/docs/persistence), and [Security Architecture](/docs/security-architecture) before exposing a deployment. --- ### Node and Docker # Node and Docker The Node target is the recommended production default. It runs B4.run's complete HTTP runtime and is the target to choose when you need Agent Protocol, AG-UI, middleware, filesystem-backed capabilities, or an execution sandbox. ## Recommendation and prerequisites - Use Node 24 or newer. B4.run packages require Node 24+. - Keep `@b4run/cli` in production `dependencies`, because the emitted server imports it at runtime. - Run `b4 build` on the host before building an image. The generated Dockerfile copies `.b4/build`; it does not run B4.run inside the image. - Put blanket authentication, tenant authorization, and network restriction around the full service before exposing it. For an npm-managed app, this is a runnable way to make the CLI a production dependency: ```bash npm install @b4run/cli --save ``` ## Select the Node target Node and LangSmith are emitted when `build.targets` is absent. To build only Node, specify it explicitly: ```ts title="b4.config.ts" import { config } from "@b4run/cli" export default config({ build: { targets: ["node"] }, }) ``` An authored target list replaces the defaults. ## Emitted files `b4 build` writes: | File | Purpose | |---|---| | `.b4/build/modules.mjs` | Static imports for discovered routes, tools, state, route memory, and middleware | | `.b4/build/server.mjs` | Loads that manifest and calls `serveRuntime()` on the production listener | | `Dockerfile` | A marker-managed `node:24-slim` image definition | If the root `Dockerfile` still carries B4.run's generated marker, a later build refreshes it. If the root file has no marker, B4.run preserves it and writes the generated alternative to `.b4/build/Dockerfile`. ## Run directly Use `b4 start` to run the same Node assembly without Docker: ```bash b4 build b4 start ``` The default listener is `0.0.0.0:8000`. Override it with `--host` and `--port`, or with `HOST` and `PORT`: ```bash b4 start --host 127.0.0.1 --port 3000 ``` ## Use the generated Dockerfile Create `.dockerignore` before the first `docker build`, or merge these exclusions into the existing file. Keep `.b4/build` in the context because the generated image runs its `server.mjs`: ```text title=".dockerignore" .env .env.* .git .github node_modules **/node_modules coverage *.log .DS_Store ``` Build only after the host has emitted a fresh `.b4/build` directory and the ignore file is in place: ```bash b4 check b4 build docker build -t my-b4-app . docker run --rm -p 127.0.0.1:8000:8000 --env-file .env my-b4-app ``` The loopback-only publish, `127.0.0.1:8000:8000`, is an appropriate local smoke boundary. Production exposure belongs behind the deployment's authenticated proxy or network policy. The generated Dockerfile uses `COPY . .`. Every unignored file in the build context can therefore enter an image layer, including a secret that a later Dockerfile instruction deletes. `docker run --env-file .env` injects runtime values; it does not undo a secret already baked into an image layer. Review the final context and image history in CI, and pass production secrets through the runtime or orchestrator rather than the build context. The generated image runs `npm ci --omit=dev || npm install --omit=dev`. It does not invoke pnpm or Yarn, even though its copy step tolerates an optional pnpm lockfile in the build context. Use a hand-authored Dockerfile if your production install must use another package manager or stricter lockfile behavior. Do not exclude `.b4/build` in `.dockerignore`; the image's command is `node .b4/build/server.mjs`. The generated Dockerfile uses `COPY . .`, then runs only `chown -R 1000:1000 /app/.b4` before `USER 1000:1000`. If the build context contains `/app/workspace`, Docker copies it as root-owned. Local `writeFile`, shell commands that mutate the workspace, and tool-output offloading can then fail with `EACCES`. When local workspace writes are required, use a hand-authored Dockerfile that adjusts ownership—for example, `COPY --chown=1000:1000 . .` or an explicit `chown` for `/app/workspace`—and validate the resulting least-privilege boundary. The current emitter does not make that ownership change for you. ## Environment and secrets Pass model credentials, database URLs, and provider configuration at runtime. For local Docker smoke tests, `--env-file .env` is convenient; for production, use the orchestrator's secret mechanism and avoid copying secret files into the image. `src/middleware.ts` is execution middleware, not blanket service authentication. Health, thread management and state routes, cancellation, and memory-candidate management include middleware-bypassing paths. The external authentication and tenant boundary must cover the entire service, with only intentionally public probes exempted. ## Filesystem and sandbox The Node runtime can use local `.b4` stores and filesystem-backed capabilities, and it can construct the configured [execution sandbox](/docs/sandbox). Those are separate durability domains: - `.b4` inside a container is local to that container unless the deployment mounts or replaces it with durable storage; - sandbox workspaces follow the selected sandbox provider's volume lifecycle; - shared Postgres checkpoints, threads, and permissions do not automatically make long-term memory or workspace files shared. Choose those stores deliberately in [Persistence and Tenancy](/docs/persistence), and coordinate replicas as described in [Production Topology](/docs/production-topology). ## Health and shutdown `GET /healthz` returns a process-level success response. Treat it as **liveness**, not dependency readiness: the handler does not query the model, database, or sandbox provider. In an embedded fetch/Hono assembly with `requestStores`, store construction occurs before route dispatch, so even a health request may fail before the liveness handler runs; that still is not a complete dependency check. The generated `.b4/build/server.mjs` does not currently install signal handlers. `serveRuntime()` supports signal handling only when a caller opts into `installSignalHandlers: true`; a larger host may instead own signals and call `await runtime.close()` in its ordered shutdown path. Plan rollout grace periods and request draining around the generated server's current limitation. ## Production checklist 1. Use Node 24+ and keep `@b4run/cli` in `dependencies`. 2. Create or audit `.dockerignore`, then run `b4 check`, `b4 build`, and `b4 verify` before image construction. 3. Test the built image on loopback, including Agent Protocol and AG-UI streaming. 4. Inject secrets at runtime and authenticate/restrict the whole service. 5. Configure durable stores and backup/retention policy for every state domain you rely on. 6. Keep one replica unless thread-aware routing or distributed serialization and cancel routing are guaranteed. 7. Account for the generated server's missing signal handlers during rollout. ## Troubleshooting - **Container cannot import `@b4run/cli`:** move it from `devDependencies` to `dependencies`, rebuild on the host, then rebuild the image. - **`server.mjs` is missing in the image:** run `b4 build` first and remove `.b4/build` from `.dockerignore`. - **A custom Dockerfile was not replaced:** B4.run preserves unmarked root files; inspect `.b4/build/Dockerfile`. - **The health probe is green but requests fail:** test the actual database, model, sandbox, authentication, and route path; `/healthz` does not validate them. - **Cancellation or the one-run gate behaves inconsistently across replicas:** route a thread to its owning process or add distributed serialization and cancel routing. ## Related guides --- ### Kubernetes # Kubernetes Deploy Kubernetes applications by building the Node target image first, then installing the `b4-app` chart. The chart runs an image; it does not build one or translate `b4.config.ts`. ## Prerequisites Build and publish a Node image as described in [Node and Docker](/docs/deployment/node): Confirm that the app has the secret-safe `.dockerignore` from that guide before running `docker build`; keep `.b4/build` in the context. ```bash b4 check b4 build docker build -t ghcr.io/you/my-b4-app:2026-08-10 . docker push ghcr.io/you/my-b4-app:2026-08-10 ``` If the app configures `kubernetesSandbox`, keep the infrastructure Helm release, the B4.run app, and application credentials in the separate `b4-app` management namespace while sandbox resources live in `b4-sandboxes`. Before the first install, prepare the complete intended cross-namespace subject list: ```yaml title="b4-sandbox-infra-values.yaml" orchestrator: subjects: - kind: ServiceAccount name: b4-app namespace: b4-app ``` Install the sandbox infrastructure first: ```bash helm upgrade --install b4-sandbox-infra \ oci://ghcr.io/cacheplane/charts/b4-sandbox-infra \ --namespace b4-app \ --create-namespace \ --values b4-sandbox-infra-values.yaml ``` The application chart does not replace the sandbox infrastructure chart. See [Kubernetes Sandbox](/docs/sandbox/kubernetes) for the provider and cluster-side security boundary. ## Install or upgrade the application After the infrastructure RoleBinding contains the app ServiceAccount, install the app in the `b4-app` management namespace: ```bash helm install b4-app oci://ghcr.io/cacheplane/charts/b4-app \ --namespace b4-app \ --set image.repository=ghcr.io/you/my-b4-app \ --set image.tag=2026-08-10 ``` Use the same namespace and image values for upgrades: ```bash helm upgrade b4-app oci://ghcr.io/cacheplane/charts/b4-app \ --namespace b4-app \ --set image.repository=ghcr.io/you/my-b4-app \ --set image.tag=2026-08-10 ``` `image.repository` is required. Pin an immutable tag or set `image.digest` in production. ## Health probes The chart sends startup, readiness, and liveness HTTP probes to `healthPath`, which defaults to `/healthz`. B4.run's response is process **liveness**, not dependency readiness: it does not query the model, Postgres, or the sandbox provider. Some store construction happens while the runtime is assembled, so a boot failure can prevent the listener from starting. In fetch/Hono compositions, a request-store factory runs before route dispatch and can even fail a health request. Neither behavior turns `/healthz` into an active dependency test. Add a separately owned dependency-readiness check if rollout safety requires one. ## Environment and secrets Set ordinary environment entries through `env`/`envFrom`. For an existing Kubernetes Secret, use the convenience value: ```bash helm upgrade b4-app oci://ghcr.io/cacheplane/charts/b4-app \ --namespace b4-app \ --reuse-values \ --set secretName=my-b4-secrets ``` The chart references the Secret but does not create it. Put model credentials and `DATABASE_URL` in an operator-managed Secret, and apply the tenant and authentication boundary described in [Security Architecture](/docs/security-architecture). Shared Postgres stores and local `.b4` files have different lifetimes. Configure each state domain with [Persistence and Tenancy](/docs/persistence) before relying on restarts or replicas. ## Filesystem durability The `b4-app` chart mounts only an `emptyDir` at `/tmp`. It does not mount the app's `.b4` directory. Local SQLite checkpoints, threads, permissions, and other `.b4` data are therefore ephemeral across Pod replacement. Use external durable stores where replacement must preserve state. Sandbox-provider workspaces have their own volume lifecycle and are not made durable by the app Pod's `/tmp` mount. ## ServiceAccount ownership The chart defaults are: - `serviceAccount.create=true`; - `serviceAccount.name=""`, which resolves to the release-scoped chart fullname (`b4-app` for the canonical release); - `sandboxNamespace=b4-sandboxes`, which is informational only; - `automountServiceAccountToken=true` on the app Pod. For an app that uses `kubernetesSandbox`, use the default application-owned ServiceAccount in the management namespace. Add that planned ServiceAccount as a cross-namespace subject of the sandbox infrastructure chart's orchestrator Role **before** installing the application. A RoleBinding subject is a name reference, so it may refer to a future ServiceAccount. Recording that authorization first prevents a Ready-but-sandbox-broken window in which the app Pod is running but sandbox API calls are still forbidden. First capture the installed infrastructure chart version and export the release's effective values, including every existing RoleBinding subject: ```bash INFRA_CHART_VERSION="$(helm get metadata b4-sandbox-infra --namespace b4-app | awk '$1 == "VERSION:" { print $2 }')" test -n "$INFRA_CHART_VERSION" || { printf '%s\n' "unable to determine installed infrastructure chart version" >&2; exit 1; } helm get values b4-sandbox-infra --all --output yaml \ --namespace b4-app \ > b4-sandbox-infra-rbac-values.yaml ``` Edit `b4-sandbox-infra-rbac-values.yaml` so `orchestrator.subjects` contains the **complete intended subject list**: preserve every existing entry and append the planned application ServiceAccount as a cross-namespace subject. For a release whose existing extra-subject list is empty, that section is: ```yaml title="b4-sandbox-infra-rbac-values.yaml" orchestrator: subjects: - kind: ServiceAccount name: b4-app namespace: b4-app ``` Inspect the complete file, then apply it. Helm replaces arrays as values, so do not assign a guessed numeric subject index. ```bash helm upgrade b4-sandbox-infra oci://ghcr.io/cacheplane/charts/b4-sandbox-infra \ --version "$INFRA_CHART_VERSION" \ --namespace b4-app \ --values b4-sandbox-infra-rbac-values.yaml ``` Keep the provider's configured namespace, the infrastructure chart namespace, and the informational `sandboxNamespace` value aligned. Changing `sandboxNamespace` alone does not change RBAC or where sandbox Pods are created. Applications that do not configure `kubernetesSandbox` do not need Kubernetes API credentials or an orchestrator RoleBinding. Install with an application-owned ServiceAccount and disable token mounting as one complete mode: ```bash helm upgrade --install b4-app oci://ghcr.io/cacheplane/charts/b4-app \ --namespace my-app --create-namespace \ --set image.repository=ghcr.io/you/my-b4-app \ --set image.tag=2026-08-10 \ --set automountServiceAccountToken=false ``` No orchestrator RoleBinding is needed in this mode because the application does not call the Kubernetes API through `kubernetesSandbox`. If the application needs some unrelated Kubernetes API permission, grant that separately rather than binding the sandbox orchestrator Role. ## Replicas and run coordination Postgres can provide shared durable stores for checkpoints, thread metadata, and permission decisions. Long-term memory is configured separately. Even with all of those shared, the active one-run-per-thread gate and cancellation registry remain process-local. More than one replica therefore requires guaranteed thread-aware routing to one owning process, or distributed per-thread serialization plus cancel routing. An HPA changes replica count and a PodDisruptionBudget constrains disruption; neither coordinates B4.run runs or ensures that a cancel request reaches the owning process. Keep the conservative one-replica default until that coordination exists. See [Production Topology](/docs/production-topology) for request routing, cancellation, and failure-mode design. ## Rollouts and shutdown The generated Node `server.mjs` does not opt into `serveRuntime` signal handlers. The runtime has a drainable `close()` path, but the generated entry does not currently call it on `SIGTERM`. Set realistic termination grace periods, test streaming requests through a rollout, and use a caller-owned Node entry when coordinated signal handling is required. ## Chart reference Review the released chart's [`README.md`](https://github.com/cacheplane/b4run/blob/main/charts/b4-app/README.md) and [`values.yaml`](https://github.com/cacheplane/b4run/blob/main/charts/b4-app/values.yaml) before installation. The chart exposes image, probe, ServiceAccount, environment, ingress, autoscaling, disruption-budget, and security-context values without enforcing B4.run's higher-level replica or persistence requirements. --- ### LangSmith # LangSmith Choose the LangSmith target when LangSmith or another LangGraph deployment platform must own the transport and execution envelope. This target emits graph entries; it does not run B4.run's HTTP server. ## Select the target LangSmith is emitted by default alongside Node. To emit only the platform artifact: ```ts title="b4.config.ts" import { config } from "@b4run/cli" export default config({ build: { targets: ["langsmith"] }, }) ``` An authored `build.targets` list replaces the defaults. ## Build output Run the ordinary validation and build flow: ```bash b4 check b4 build b4 verify ``` The target writes `.b4/build/langgraph.json` and one `.b4/build/.ts` entry per discovered route. A representative config is: ```json title=".b4/build/langgraph.json" { "graphs": { "/research#agent": "./.b4/build/research.ts:graph" }, "dependencies": ["."], "env": ".env.example", "node_version": "22" } ``` Each graph key is `#`. Use that exact value, such as `/research#agent`, as the platform `assistant_id`. ## Route entry behavior Agent routes receive a generated entry that calls `materializeResolvedRouteGraph`. That path loads the agent descriptor and applies B4.run's agent materialization, including discovered route tools, tool scope, capabilities, and permission gates. Workflow, graph, and chain routes are different: the generated entry imports the route's authored `workflow`, `graph`, or `chain` export and re-exports that value as `graph`. They are not passed through agent materialization. Test every kind you deploy at the platform boundary. B4.run HTTP scenario coverage does not validate the platform's `assistant_id` request envelope. ## Environment file path The generated `env` field is `.env.example` when that file exists at the app root; otherwise it is `.env`. This is a path consumed by the platform build. Review the selected file before deployment and keep secret values in the platform's secret manager rather than committing them. ## Merge precedence If the app root contains a hand-authored `langgraph.json`, B4.run shallow-merges it into the generated output. User-defined extra keys survive. The generated `graphs`, `dependencies`, `env`, and `node_version` fields overwrite user values with those names. This is a shallow merge: a user `graphs` map is replaced rather than merged route by route. ## Platform authentication and missing B4.run surfaces Configure authentication and tenant authorization at the LangSmith or hosting-platform boundary. The generated target does not include B4.run HTTP `middleware`, the AG-UI server, or the B4.run `sandbox` manager. It also does not expose B4.run's Agent Protocol thread-management server. Agent materialization preserves policy-aware graph construction for agent routes, but that does not add the absent HTTP or sandbox surfaces. If those surfaces are requirements, deploy [Node and Docker](/docs/deployment/node) or, after passing its capability gate, [Edge and Hono](/docs/deployment/edge). ## Node version mismatch The emitter currently writes `node_version: "22"`, while B4.run packages require Node 24 or newer. This is an unresolved compatibility mismatch. Do not treat the generated value as evidence that the current B4.run package set runs correctly on Node 22; confirm the platform's supported Node versions before deploying. ## Deployment checklist 1. Confirm that every route has the expected `#` entry and use it as `assistant_id`. 2. Inspect the environment-file path and move actual secrets into platform-managed configuration. 3. Add platform authentication and tenant authorization. 4. Test agent materialization and raw workflow/graph/chain entries through the platform transport. 5. Resolve the Node 22/Node 24 incompatibility with the target platform before production use. 6. Confirm that no requirement depends on B4.run HTTP middleware, AG-UI, or the sandbox manager. ## Troubleshooting - **A user config field disappeared:** generated `graphs`, `dependencies`, `env`, and `node_version` intentionally take precedence. - **The platform cannot find an assistant:** use the full `#` key from generated `graphs`. - **HTTP middleware did not run:** it is not materialized into these entries; enforce authentication at the platform boundary. - **Local Node HTTP tests pass but platform calls fail:** exercise the platform transport and its environment/build process directly. --- ### Edge and Hono # Edge and Hono The Hono target is an opt-in deployment for web-standard runtimes. Use it only after the app passes B4.run's edge capability gate and after accepting its request-scoped Postgres and filesystem limitations. If those constraints are not named requirements, prefer [Node and Docker](/docs/deployment/node). ## Fit check and evidence boundary The emitted bundle is tested with local workerd and with a Node Hono round trip. That is useful runtime evidence, but it is not a live Cloudflare deployment test and does not prove another provider's limits, bindings, authentication, networking, or database path. Before selecting Hono, confirm all of these: - the app does not need sandbox, workspace, shell, route long-term memory, or tool-output offloading; - request-scoped WebSocket Postgres through `@neondatabase/serverless` fits the database environment; - the host can preserve the exact incoming `Request` object through Hono routing; - every authored route, tool, state module, middleware module, and transitive dependency is web-standard and edge-compatible; - the operator will test the deployed provider boundary rather than infer it from a successful bundle. `B4_E1005` checks only B4.run-known capabilities. It does not inspect arbitrary authored code or its dependency graph: arbitrary Node built-ins can pass `b4 check` and then fail during bundling or provider deployment. Bundle and run the actual application artifact under the target's conditions. ## Select Hono Hono is not a default target. Naming `build.targets` replaces the Node and LangSmith defaults, so list every target you still want: ```ts title="b4.config.ts" import { config } from "@b4run/cli" export default config({ build: { targets: ["node", "hono"] }, }) ``` Then build normally: ```bash b4 check b4 build b4 verify ``` ## Emitted artifacts The Hono target writes: | Artifact | Purpose | |---|---| | `.b4/build/modules.edge.mjs` | Edge-oriented static route, tool, state, middleware, and capability manifest; its transitive authored import graph is not guaranteed to be free of Node built-ins | | `.b4/build/stores.mjs` | Request-scoped Postgres checkpointer, thread store, and permission store factory | | `.b4/build/app.mjs` | Hono catch-all around `createRuntimeFetchHandler` | | `wrangler.toml` | Scaffold whose `main` is `.b4/build/app.mjs` | The emitted runtime imports `@b4run/cli`, `@b4run/postgres-storage`, `@neondatabase/serverless`, and `hono`, plus statically discovered model-provider packages. Declare the packages the application bundles. The build prints a notice when the four runtime packages are missing from the app package manifest. A root `wrangler.toml` is written only when one does not exist. B4.run does not overwrite a marked prior scaffold or a hand-authored root file; when a hand-authored file already exists, the target writes its scaffold to `.b4/build/wrangler.toml` for comparison. ## Compose through `@b4run/cli/fetch` `@b4run/cli/fetch` is the web-standard entry point. It has no filesystem discovery or SQLite fallback, so hand composition must supply the edge manifest, serialized config, stores, environment binding, and a static model-provider importer equivalent to the generated `app.mjs`. The generated `.b4/build/app.mjs` remains the complete recommended deployment entry. The JavaScript below is only a **lifecycle/composition skeleton** for a host that must replace that entry. It deliberately omits the generated static provider importer and serialized configuration/environment machinery; copy those responsibilities from the generated entry before deploying a hand-composed variant. ```js title="b4-edge.mjs" import { createRuntimeFetchHandler } from "@b4run/cli/fetch" import { Hono } from "hono" import modules from "./.b4/build/modules.edge.mjs" import { createRequestStores } from "./.b4/build/stores.mjs" const envByRequest = new WeakMap() let handlerPromise const b4App = new Hono() b4App.all("*", async (c) => { const request = c.req.raw envByRequest.set(request, c.env) handlerPromise ??= createRuntimeFetchHandler({ appRoot: "/my-app", modules, config: {}, requestStores: (currentRequest) => { const env = envByRequest.get(currentRequest) if (!env) throw new Error("No environment is bound to this request") return createRequestStores(env) }, }).catch((error) => { handlerPromise = undefined throw error }) return (await handlerPromise).fetch(request) }) export { b4App } ``` `"/my-app"` is a placeholder for the exact rooted namespace baked into `modules.edge.mjs`, currently `/`. It is not a public URL prefix. A mismatch separates the handler's cache/thread identity from the manifest identity. The generated `app.mjs` additionally seeds the build-discovered static model importer and serializable runtime environment. Reproduce those responsibilities when replacing the generated entry; do not rely on a variable dynamic import that the edge bundler cannot discover. ## Compose the Hono router B4.run endpoints remain rooted. Compose the generated or hand-built B4.run app at `/`: ```js title="host.mjs" import { Hono } from "hono" import { b4App } from "./b4-edge.mjs" const app = new Hono() app.use("*", authenticateAndAuthorize) app.route("/", b4App) export default app ``` Keep `app.route("/", b4App)` exact. The generated environment lookup is keyed by the original `Request`; Hono's request-rebuilding mount helper breaks that identity. A prefixed route also does not create an arbitrary B4.run base path. The runtime owns rooted `/healthz`, `/threads`, `/agui`, and `/memory` surfaces. ## Why the stores are per-request On workerd, a WebSocket connection belongs to the request I/O context that opened it. Reusing an idle module-scope pool on a later request can hang until the runtime cancels it. The generated `stores.mjs` therefore creates one `@neondatabase/serverless` pool and three Postgres stores per request, then disposes the pool only after the response body and any run started by that request have settled. The incoming `Request` is also the identity that joins Hono's environment binding to B4.run's `requestStores` callback. Preserve that object through `app.route`. The generated factory runs `ready()` for the checkpointer, thread store, and permission store on its first migration pass in an isolate. Ready and migrated does not mean permission-mode, static-policy, or hydration parity: the generated path does not call `permissionsStore.load()`, and it omits the resolved mode plus config-seeded allow/deny rules. Persisted interactive grants are therefore not hydrated by that scaffold. Use app-owned request-store wiring when those controls matter. The emitted factory supplies no long-term memory store. Reaching an otherwise omitted required store fails with `B4_E5301` rather than opening local SQLite. The generated Hono stores use the default `public` schema and default `b4` table prefix, producing `public.b4_*` tables with no application namespace. A generated Hono app therefore requires an app-dedicated database. Do not point generated artifacts from separate applications or trust boundaries at the same database. Sharing a database is supported only with hand-composed request stores that pass a unique `schema` or `tablePrefix` consistently to `postgresCheckpointer`, `createPostgresThreadsStore`, and `createPostgresPermissionsStore`. That composition must still preserve the generated path's workerd constraints: per-request pools and disposal, migration coordination, original-`Request` environment binding, static model-provider imports, serialized runtime config, and permission hydration/policy decisions. Do not edit generated `stores.mjs`; rebuilds replace it. ## What the edge cannot serve `b4 check` and the Hono build report all current B4.run-known target violations together as `B4_E1005`. The request-time fetch guard rejects the corresponding unsupported configured runtime instead of silently dropping it. This gate does not prove that authored code or transitive dependencies avoid Node-only APIs. | Gate | Source inspected | Why it is rejected | |---|---|---| | Sandbox | `sandbox` | An edge isolate cannot start or manage the configured container/Pod sandbox | | Tool-output offloading | non-empty `toolOutput` | Offloading writes oversized output under `workspace/` | | Filesystem backend | `backends.filesystem` | A live backend object cannot cross the serialized build boundary | | Exec backend | `backends.exec` | A live backend object cannot cross the serialized build boundary | | Custom checkpointer | `checkpointer` | It would be silently replaced by the emitted per-request Postgres checkpointer | | Custom thread store | `threadsStore` | It would be silently replaced by the emitted per-request Postgres thread store | | Custom permission store | `permissions.store` | It would be silently replaced by the emitted per-request Postgres permission store | | Custom memory store | `memory.store` | It is removed from the serialized config and no emitted memory store replaces it | | Workspace capability set | an app-root `workspace/` directory | File tools, shell, offloading, and `workspace/AGENTS.md` require a filesystem/process surface | | Long-term memory | `memory.ts` on an agent route | The emitted stores omit the memory store needed by `recall` and `remember` | `workspace/AGENTS.md` is the one marker file that stays off the edge: its contract is a file the agent rewrites through `writeFile` and B4.run re-reads every turn, and a read-only copy would keep half of that promise. The explicit build gates above cover surfaces that would otherwise be silently replaced, dropped, or fail only on first use. ## Skills, `plan.md`, and route `memory.md` are bundled Route [skills](/docs/skills), a route [`plan.md`](/docs/planning), and a route `memory.md` are static files, so `b4 build` reads them for the `hono` and `vercel` targets and inlines their contents into `modules.edge.mjs` beside the route that owns them. At request time they are served from that manifest through the same marker facade the node runtime reads from disk, so `readSkill`, seeded todos, and the route-memory prompt block behave exactly as they do under `b4 dev`. What is served is frozen at build time: on these targets a skill body, a `plan.md` seed, and `memory.md` are read when `b4 build` runs, so editing one of those files takes effect on the next build rather than live. The build caps each bundled marker — 32 KiB for a `SKILL.md` or `memory.md`, 64 KiB for `plan.md`; the last two match the limits the runtime already applies — and both `b4 build` and `b4 check` fail with `B4_E1005`, naming every oversized file, before any artifact is written. A manifest that records skill names but carries no bodies for them (one built before this behavior existed, or composed by hand) is detected at boot and fails every request with `B4_E1005` rather than dropping the skills silently; rebuild with `b4 build`. A [thread-access policy](/docs/thread-access) is not one of these gates: `src/thread-access.ts` is bundled into the manifest as a static import and runs on the edge like it does under `b4 dev`. Nothing in it is filesystem-dependent — the disk probe happens at build time, not at request time. If a manifest built before the app grew a policy is deployed beside a newer entry point, the boot fails rather than serving every thread endpoint ungated, because a bundled runtime has no disk to fall back to. The `langsmith` target still refuses to build while a policy file exists; it materializes graphs with no B4.run HTTP layer to run the policy in. ## Configuration is in the artifact The Hono emitter serializes the JSON-representable portion of `b4.config.ts` into `.b4/build/app.mjs`. Functions, class instances, and store handles are stripped; ordinary strings remain. Do not put secret literals in build-time config fields, because those values can become readable bundle content. Use host environment variables, Wrangler secrets, or provider bindings instead. For Workers, set the database URL as a secret: ```bash wrangler secret put DATABASE_URL ``` ## Deploy checklist 1. Run `b4 check` and resolve every `B4_E1005` violation. 2. Inspect `modules.edge.mjs`, `stores.mjs`, `app.mjs`, and the selected `wrangler.toml`. 3. Declare the emitted runtime dependencies and every discovered model-provider package. 4. Configure `DATABASE_URL` and model credentials as runtime secrets or bindings. 5. Put outer authentication and tenant authorization around all rooted B4.run paths. 6. Validate permission mode, static policy, hydration, and refresh if using generated stores. 7. Drive Agent Protocol, AG-UI streaming, cancellation, and request-store disposal on the deployed host. ## What is proven, and what is not The repository proves that the emitted module graph bundles without B4.run-owned Node built-ins, runs against local workerd in a gated lane, and completes a Node Hono round trip. That evidence is not a live Cloudflare deployment and is not observation of Vercel, Deno, or Bun behavior. Provider quotas, compatibility settings, WebSocket reachability, bindings, and production authentication remain deployment-specific validation work. --- ### Execution Sandbox # Execution Sandbox The execution sandbox gives a provider-keyed Agent Protocol thread an isolated filesystem, shell, and network boundary instead of letting workspace tools operate in the app's local `workspace/` directory. That boundary depends on thread IDs remaining distinct after provider resource naming; the built-in providers do not add an application or tenant namespace. Add a `sandbox` key to `b4.config.ts`, and B4.run routes `readFile`, `writeFile`, `listDir`, and `runBash` through a `SandboxProvider`. B4.run includes a Docker reference implementation; the same portable contract also supports other providers. This is one of three independent controls: [tool scoping](/docs/tools) decides which tools the model can call, [permissions](/docs/permissions) decide whether a call may run, and the sandbox constrains what an allowed call can touch. Use all three where your threat model calls for them. ## Quickstart Install Docker, start its daemon, and configure `dockerSandbox`: ```bash pnpm add @b4run/sandbox ``` ```ts title="b4.config.ts" import { config } from "@b4run/cli" import { dockerSandbox } from "@b4run/sandbox" export default config({ sandbox: { provider: dockerSandbox({ image: "node:24-slim" }), network: { mode: "allow", denylist: ["169.254.169.254"] }, env: { NODE_ENV: "production" }, resources: { memoryMb: 512, cpus: 1, timeoutMs: 120_000 }, idleTimeoutMs: 600_000, }, }) ``` No `sandbox` key means no behavior change: workspace tools still use the app's local `workspace/` directory. `b4 check` validates the configuration and calls the provider's optional `preflight()`. For Docker, that check confirms the daemon is reachable so a stopped daemon fails before the first agent turn. ## What's isolated - **Filesystem** — workspace file tools use a sandbox volume, not the host filesystem. - **Shell** — `runBash` executes in the sandbox and remains subject to permissions. - **Network** — `sandbox.network` expresses portable policy intent; enforcement depends on the provider. - **Environment** — the host environment is not inherited. Only `sandbox.env` entries are injected. - **Resources** — `memoryMb` and `cpus` cap compute; `timeoutMs` caps one command. ## Lifecycle B4.run passes the conversation thread ID to the provider and reuses the resulting sandbox across turns. - `acquire()` creates or reattaches the thread's live compute and workspace. - Idle reap and `release()` discard warm compute but retain the workspace volume. - A later turn reattaches that retained volume. - Thread deletion calls `destroy()`, removing both compute and workspace data. Storage is named from a provider-specific transformation of the thread ID so a provider can reattach it after compute is released. Provider retention can shorten this lifecycle: an operator-owned cleanup policy may delete released storage before the thread is deleted. See [Kubernetes Sandbox](/docs/sandbox/kubernetes) for that provider's reaper boundary. Subagents share their parent's thread and therefore share its sandbox. Docker substitutes unsupported name characters, while Kubernetes lowercases, substitutes, trims, and conditionally truncates its resource-name input. This is a lossy sanitizer boundary: distinct short IDs that differ only by case or substituted punctuation can name the same container, volume, Pod, PVC, or NetworkPolicy. The current runtime does not add an app or tenant discriminator and does not hash every sanitized ID. Before a caller can select `thread_id`, map application ownership to a collision-resistant, provider-safe canonical thread ID that is globally unique within that provider scope; opaque lowercase UUID/ULID-style IDs are safer than meaningful caller strings. Run each application or trust boundary against a separate Docker daemon or equivalent isolated Docker context. Mutually untrusted tenants must not share a provider scope in which their names can alias. Changing built-in resource naming to hash every ID would affect reattachment to existing volumes, so treat that runtime change as a compatibility-sensitive follow-up rather than assuming it here. ## Security hardening The Docker provider applies these defaults: | Control | Docker mechanism | | --- | --- | | Linux capabilities | `--cap-drop ALL` | | Privilege escalation | `--security-opt no-new-privileges` | | Process count | `--pids-limit 512` | | Root filesystem | `--read-only`, with writable `/tmp` and `/run` tmpfs mounts | | User | `--user 1000:1000`, with `HOME=/workspace` | The portable `security` policy exposes `dropAllCapabilities`, `noNewPrivileges`, `readOnlyRootFilesystem`, `runAsNonRoot`, and `pidsLimit`. Each can be overridden for a compatible image, but every relaxation changes the isolation boundary. The default non-root user and read-only root filesystem prevent runtime package installs and writes outside the workspace or scratch mounts. Install system and global dependencies when building the image. ### Per-command timeout `resources.timeoutMs` bounds one `runBash` call. Docker wraps the command with GNU `timeout`; an overrun exits with code `124`, rounded up to a whole second. The limit applies only when configured, and the sandbox image must contain the `timeout` binary. `node:24-slim`, Debian, and Ubuntu include or can install it; minimal and distroless images may not. ```ts sandbox: { provider: dockerSandbox({ image: "node:24-slim" }), resources: { timeoutMs: 120_000 }, }, ``` ## Network policy The portable policy has two modes: - `{ mode: "deny" }` asks the provider for default-closed egress. Docker enforces this with `--network none`. - `{ mode: "allow", denylist?: [...] }` leaves egress open and expresses hosts that should be blocked. When omitted, the policy defaults to allow mode with `169.254.169.254` in the denylist. Docker's allow-mode denylist is best-effort, not a rigorous firewall; use an egress proxy or another provider when host-level filtering is a security boundary. Docker deny mode is the stronger reference behavior because `--network none` removes network access. Provider mappings are intentionally not identical. Read the provider-specific guide before assuming Docker behavior applies elsewhere. ## Kubernetes provider Configuration, prerequisites, and the cluster security boundary live in [Kubernetes Sandbox](/docs/sandbox/kubernetes). ### Security hardening on Kubernetes See [Kubernetes Sandbox](/docs/sandbox/kubernetes) for Pod security contexts, Pod Security Standards, resource controls, ServiceAccount-token isolation, and PID-limit scope. ### Network policy on Kubernetes See [Kubernetes Sandbox](/docs/sandbox/kubernetes) for DNS exceptions, CNI requirements, and how chart and per-thread NetworkPolicies compose. ## Deploying the sandbox infrastructure (Helm) Install and operate `b4-sandbox-infra` with the canonical [Kubernetes Sandbox guide](/docs/sandbox/kubernetes). ### Key caveats Namespace alignment, storage, DNS, CNI enforcement, quotas, and PVC cleanup are covered in [Kubernetes Sandbox](/docs/sandbox/kubernetes). ## Deploying a B4.run app (Helm) Deploy the app with [Kubernetes](/docs/deployment/kubernetes); operate its provider with [Kubernetes Sandbox](/docs/sandbox/kubernetes). ### ServiceAccount and namespace wiring Use [Kubernetes Sandbox](/docs/sandbox/kubernetes) for RBAC and [Kubernetes deployment](/docs/deployment/kubernetes) for the app chart. ### Env, secrets, and replicas See [Kubernetes deployment](/docs/deployment/kubernetes) for app settings and [Kubernetes Sandbox](/docs/sandbox/kubernetes) for namespace controls. ## Subagents A subagent runs under its parent's conversation thread. The coordinator and all of its subagents therefore resolve to the same sandbox and workspace rather than receiving one sandbox each. ## Custom providers Implement `SandboxProvider` to integrate a microVM, cloud sandbox, or another isolation backend: ```ts import type { SandboxHandle, SandboxPolicy } from "@b4run/sandbox" export interface SandboxProvider { readonly name: string acquire(input: { readonly threadId: string readonly policy: SandboxPolicy readonly signal: AbortSignal }): Promise release(threadId: string): Promise destroy(threadId: string): Promise preflight?(): Promise<{ readonly ok: boolean readonly detail?: string readonly warnings?: readonly string[] }> } ``` `acquire()` must be idempotent for a thread. `release()` drops warm compute while retaining workspace data; `destroy()` removes both. `preflight()` can fail a configuration before runtime or return `warnings` when a provider cannot prove a requested control. Validate the implementation with the shared conformance suite: ```ts import { runProviderConformance } from "@b4run/sandbox/testing" import { describe } from "vitest" import { myCloudSandbox } from "./my-cloud-sandbox.js" runProviderConformance({ name: "my-cloud-sandbox", makeProvider: () => myCloudSandbox({ apiKey: process.env.MY_SANDBOX_KEY! }), describe, }) ``` The suite checks acquire/reattach idempotency, per-thread isolation, release-versus-destroy storage semantics, and numeric command exit codes. ## Testing your agent Use `fakeSandbox()` for deterministic tests that need the provider contract without Docker: ```ts title="b4.config.ts (test)" import { config } from "@b4run/cli" import { fakeSandbox } from "@b4run/sandbox/testing" export default config({ sandbox: { provider: fakeSandbox() }, }) ``` This exercises B4.run's per-thread acquisition, reuse, and subagent wiring. Provider conformance and gated integration tests cover the actual isolation backend. ## Verifying the full arc (end-to-end) Two gated CI lanes exercise a built B4.run app, a real provider, command output inside the isolated workload, and cleanup after thread deletion: - `sandbox-docker-e2e` runs the app as a container and drives `dockerSandbox` to create a sibling container. - `sandbox-k8s-e2e` deploys the app and drives `kubernetesSandbox` to create a sandbox Pod; operational setup is in [Kubernetes Sandbox](/docs/sandbox/kubernetes). Both use a mocked model, assert that the in-sandbox process runs as UID `1000`, and confirm compute and storage are removed on thread deletion. They run only when `B4_TEST_SMOKE_E2E=1` is enabled. Only a B4.run runtime entry (`b4 dev`, `b4 start`, or the Node build target) constructs the configured sandbox. LangSmith platform artifacts do not run that runtime. Edge targets reject incompatible filesystem and sandbox capabilities rather than silently running workspace tools without isolation; see [Deployment Options](/docs/deployment). ## What it is — and isn't **It is:** a filesystem and process boundary keyed by the provider's derived thread resource name; explicit environment injection; CPU and memory controls; provider-specific network policy; a workspace lifecycle that outlives warm compute; and hardened Docker defaults including a non-root user, dropped capabilities, no privilege escalation, a read-only root filesystem, and a process-count limit. **It is not:** authorization, tool selection, or a guarantee against container-escape vulnerabilities. Tool scoping and permissions remain separate controls. Docker's allow-mode denylist is best-effort, and container isolation is not a microVM boundary. For hostile multi-tenant workloads, implement a stronger provider behind the same `SandboxProvider` seam and validate its controls against your threat model. Kubernetes maps the same policy intent to different mechanisms and limitations. Treat [Kubernetes Sandbox](/docs/sandbox/kubernetes) as canonical for that provider rather than inferring Kubernetes behavior from Docker. ## Related --- ### Kubernetes Sandbox # Kubernetes Sandbox Use `kubernetesSandbox` when the B4.run runtime already runs in Kubernetes and each Agent Protocol thread needs an isolated Pod-backed workspace. This is a container boundary, not a microVM boundary. Before production use, provide a dynamic volume provisioner, a compatible sandbox image, and sufficient Pod/PVC quota. If NetworkPolicy is part of the security boundary, provide a policy-enforcing CNI. The application process needs Kubernetes API credentials to create and exec into sandbox workloads. The sandbox Pods themselves do not: B4.run sets `automountServiceAccountToken: false` on every provider-managed Pod. ## Install the sandbox infrastructure The `b4-sandbox-infra` chart creates the target namespace, orchestrator ServiceAccount and RBAC, default-deny egress backstop, resource controls, Pod Security Standard labels, and PVC reaper. Keep its Helm release, the B4.run app, and application credentials in the separate `b4-app` management namespace while sandbox resources live in `b4-sandboxes`. For a fresh release, prepare a values file containing every planned application ServiceAccount. For an existing release, export its effective values first. In either case, `orchestrator.subjects` must contain the complete intended subject list: preserve every existing item and append each application ServiceAccount as a cross-namespace subject. Helm replaces arrays, so never write a guessed numeric subject index. Follow [Kubernetes deployment](/docs/deployment/kubernetes) for the complete ordered workflow. Install the infrastructure once per cluster or environment: ```bash helm upgrade --install b4-sandbox-infra \ oci://ghcr.io/cacheplane/charts/b4-sandbox-infra \ --namespace b4-app \ --create-namespace \ --values b4-sandbox-infra-values.yaml ``` The Helm release namespace does not control where sandbox workloads run. The chart's `namespace.name` default remains `b4-sandboxes`; review values before changing namespace ownership or disabling a control. ## Configure the provider Point the provider at exactly the namespace the infrastructure chart manages: ```ts title="b4.config.ts" import { config } from "@b4run/cli" import { kubernetesSandbox } from "@b4run/sandbox" export default config({ sandbox: { provider: kubernetesSandbox({ image: "node:24-slim", namespace: "b4-sandboxes", }), network: { mode: "deny" }, resources: { memoryMb: 512, cpus: 1, diskGb: 2 }, }, }) ``` `storageClass` and `startupTimeoutMs` are optional provider settings. `resources.diskGb` becomes the PVC storage request; confirm the selected StorageClass can provision that request. The namespace is also the provider resource-name scope. Give each application or trust boundary a separate Kubernetes namespace. Before a caller can choose `thread_id`, map ownership to a collision-resistant, provider-safe canonical thread ID that is globally unique inside that namespace. The provider lowercases the ID, replaces unsupported characters, trims dashes, and only adds a hash when the cleaned form is overlength. That lossy sanitizer means distinct short IDs that differ by case or substituted punctuation can still address the same Pod, PVC, and NetworkPolicy. Mutually untrusted tenants must not share a provider namespace in which their resource names can alias. Hashing every ID in the runtime would break reattachment to existing PVC names and is therefore a compatibility-sensitive follow-up, not a current guarantee. The provider replaces the image entrypoint with `sleep infinity` and runs shell and filesystem operations through `sh -c`. The image must provide a POSIX `sh`, a `sleep` implementation that accepts `infinity`, and the core utilities used by the filesystem backend (`cat`, `mkdir`, `dirname`, `ls`, `realpath`, `stat`, `rm`, and `touch`). It also needs `timeout` when `resources.timeoutMs` is set. The hardened default runs as numeric UID/GID `1000:1000`; an `/etc/passwd` entry is not required, but the image's executables and filesystem permissions must work for that identity. Set an explicit compatible UID/GID through `security.runAsNonRoot` when they do not. ## Wire application RBAC The app Pod's ServiceAccount must be a subject of the infrastructure chart's `b4-orchestrator` RoleBinding. That Role is limited to the provider's Pod, Pod exec, PVC, and NetworkPolicy operations; it does not grant Secret access. Add the management-namespace app ServiceAccount to the complete `orchestrator.subjects` list before deploying the app so there is no running-but-unauthorized interval. Follow [Kubernetes deployment](/docs/deployment/kubernetes) for the ordered RoleBinding and application-chart commands. Keep these three values aligned: - `b4-sandbox-infra`'s `namespace.name`; - `kubernetesSandbox({ namespace: "b4-sandboxes" })`; - the application chart's informational `sandboxNamespace`. Changing `sandboxNamespace` alone does not move workloads or change RBAC. ## Pod and workspace lifecycle Each distinct provider resource key receives one keeper Pod and one `ReadWriteOnce` PVC mounted at `/workspace`. - `acquire()` creates the PVC and Pod or reattaches the live pair. - `release()` deletes the Pod and retains the PVC for the next turn. - `destroy()` deletes the Pod, per-thread NetworkPolicy, and PVC. Idle reap and server shutdown both call `release()`, so their Pods disappear while their PVCs remain unreferenced. With the chart reaper enabled, that storage is not retained indefinitely: the reaper has no B4.run thread metadata and considers every unreferenced B4.run PVC eligible. On its default hourly schedule, a run marks an unreferenced PVC that has no valid marker and clears the marker from a PVC it observes referenced by a Pod. A scheduled reaper run deletes a currently unreferenced PVC when its stored marker is older than `reaper.ttlHours` (default `168`). A reattachment resets the marker only if a reaper run observes the PVC referenced by a Pod. A short reattachment entirely between scheduled runs may therefore leave an old marker in place; after the Pod is released, the next run can delete the PVC after recent use. This includes a still-live thread released by idle reap or shutdown. If its PVC is deleted, the next `acquire()` provisions a replacement and starts with an empty workspace. Operators who need thread workspaces to survive longer must tune `reaper.ttlHours` or disable the reaper and own cleanup another way. The provider applies non-root execution, dropped capabilities, no privilege escalation, RuntimeDefault seccomp, and writable scratch mounts around its read-only-root default. The chart enforces Restricted Pod Security Standards by default. Baseline is needed only for workloads or settings that actually violate Restricted admission, such as `security.runAsNonRoot: false`. Setting `security.readOnlyRootFilesystem: false` alone remains Restricted-compatible and does not require lowering the namespace to Baseline. The chart never downgrades automatically, and the cluster admission controller decides whether an image and policy are accepted. Validate your image under the profile you enforce. `ResourceQuota` caps aggregate namespace consumption. `LimitRange` supplies CPU, memory, and ephemeral-storage defaults. Monitor reaper deletions alongside Pod and PVC lifecycle so storage-retention failures are distinguishable from application behavior. ## NetworkPolicy and DNS The infrastructure chart installs a default-deny egress backstop for Pods labeled `app.kubernetes.io/managed-by=b4`, with UDP/TCP port 53 allowed to `kube-system`. Therefore deny mode is **not zero egress**: DNS remains allowed, including the DNS-tunneling risk that follows from that exception. NetworkPolicy objects have no effect unless the cluster uses a policy-enforcing CNI such as Calico or Cilium. The chart does not install or configure a CNI. Operators must test DNS and policy behavior on the actual cluster. The chart backstop and provider policy are additive. With `networkPolicy.defaultDenyEgress=true`, a provider setting of `network: { mode: "allow" }` cannot override the chart policy and does not reopen egress. In allow mode the provider emits no per-thread NetworkPolicy and does not enforce `denylist`; the chart backstop is the only B4.run-supplied egress restriction. If you disable that backstop without an operator-owned replacement, egress is open. In deny mode, the provider creates a per-thread policy with DNS and configured CIDR exceptions. ## PID and resource exhaustion Kubernetes has no namespaced `LimitRange` or `ResourceQuota` field for process count. PID limits are a node/runtime concern: configure kubelet `podPidsLimit` (or its runtime equivalent) on nodes that schedule sandbox Pods. The chart does not supply PID limits, and `security.pidsLimit` is not enforced by the Kubernetes provider. CPU, memory, ephemeral-storage, PVC count, and requested storage remain chart-managed controls. Monitor quota pressure, Pending Pods, failed volume provisioning, evictions, and reaper activity; none of those failure modes is reported as model behavior. ## Preflight and end-to-end verification `b4 check` calls the provider's `preflight()`. It runs a SelfSubjectAccessReview for every Kubernetes API operation the provider can perform: create/get/delete Pods; create/get/delete PVCs; create/get `pods/exec`; and create/get/list/update/delete NetworkPolicies. It checks the complete set even when one review is denied or fails, then reports missing permissions, authorization-review failures, and API transport failures separately. Only after every required permission is granted does it check NetworkPolicy enforcement; an unconfirmed policy-capable CNI produces a warning rather than a successful enforcement claim. The compatibility command preflights storage selection, but it does not create a claim PVC during preflight. Dynamic RWO provisioning is a runtime prerequisite verified by the lifecycle, not proven by preflight. The policy pins Kind v0.32.0, kubectl v1.35.6, and Calico v3.32.1 across these patch releases: - Kubernetes 1.34 (1.34.8) - Kubernetes 1.35 (1.35.5) - Kubernetes 1.36 (1.36.1) Run the portable lifecycle against the cluster already selected in kubeconfig: ```bash pnpm verify:k8s:compat -- --target <1.34|1.35|1.36> --context [--storage-class ] [--keep-on-failure] ``` The target must match the server minor and the context must exactly match the current context. The command does not switch contexts. B4.run's Kind/Calico coverage does not certify managed Kubernetes services, other CNI implementations, or storage drivers. Use both gated lanes as combined evidence. `sandbox-k8s-e2e`, enabled with `B4_TEST_SMOKE_E2E=1`, proves the built app and provider are wired together: it creates a Pod, PVC, and per-thread NetworkPolicy, executes as a non-root user, and removes the Pod and PVC on thread deletion. The `sandbox-k8s-e2e` lane does not test DNS or blocked egress. The separate `sandbox-k8s` integration lane runs against kind with Calico and proves DNS resolution plus blocked egress, including the chart backstop around an allow-mode sandbox. Neither substitutes for verifying the StorageClass, CNI, DNS, admission, quotas, and cleanup settings in your production cluster. ## Operational references - [Kubernetes deployment](/docs/deployment/kubernetes) — build the Node image, install the app chart, wire its ServiceAccount, and plan rollouts and replicas. - [`b4-sandbox-infra` chart README](https://github.com/cacheplane/b4run/blob/main/charts/b4-sandbox-infra/README.md) — infrastructure behavior, reaper compatibility, and honest scope. - [`b4-sandbox-infra` values](https://github.com/cacheplane/b4run/blob/main/charts/b4-sandbox-infra/values.yaml) — namespace, PSS, quota, network, and reaper settings. - [`b4-app` chart values](https://github.com/cacheplane/b4run/blob/main/charts/b4-app/values.yaml) — application ServiceAccount and sandbox namespace settings. - [Execution Sandbox](/docs/sandbox) — portable provider contract, Docker reference behavior, and custom-provider testing. --- ### Recipes Overview # Recipes Overview Recipes answer the question: *how do I do X?* Each one is a single sitting — a goal, the canonical code, the gotchas, and a link back to the concept page if you want the deeper read. Concept pages explain the pieces. Recipes show the pieces wired up. For route-level agent behavior, start with [Memory](/docs/memory), [Planning](/docs/planning), [Skills](/docs/skills), [Subagents](/docs/subagents), and [Reasoning Effort](/docs/reasoning-effort). Those features are building blocks rather than single recipes today. ## Build - [Add a Tool](/docs/recipes/add-a-tool) — author a tool, get type-safe access from a route - [Typed State](/docs/recipes/typed-state) — declare route state shapes and defaults - [Retry Transient Model Calls](/docs/recipes/retry-flaky-tools) — recover from transient model/provider failures - [Dispatch from a Route](/docs/recipes/dispatch-from-route) — call one route from another ## Integrate - [Auth Middleware](/docs/recipes/auth-middleware) — add execution authentication; thread management/state, cancellation, memory-candidate, and health routes need outer host/proxy authentication - [Stream Output](/docs/recipes/stream-output) — send incremental responses via `runs/stream` - [Research Assistant Web UI](/docs/recipes/research-web-ui) — wire a CopilotKit client to the research demo over AG-UI ## Test These canonical guides cover testing workflows rather than hidden recipe leaves: - [Scenario Testing](/docs/testing) - [Agent Test Harness](/docs/testing-agents) - [Fixtures and Recording](/docs/testing-agents/fixtures) ## Deploy These canonical guides cover each supported deployment path: - [Deployment Options](/docs/deployment) - [Node and Docker](/docs/deployment/node) - [Kubernetes](/docs/deployment/kubernetes) --- ### Add a Tool # Add a Tool You want to add a new tool to a route and call it with full type safety. Here's how. ## The code ```ts title="src/app/(public)/research/state.ts" import { z } from "zod" export default z.object({ query: z.string(), count: z.number().default(0), }) ``` ```ts title="src/app/(public)/research/tools/lookup.ts" export default async ( input: { readonly tenant: string; readonly query: string }, ctx: { signal: AbortSignal; middleware?: Readonly> }, ) => { const res = await fetch( `https://api.example.com/${input.tenant}/search?q=${encodeURIComponent(input.query)}`, { signal: ctx.signal }, ) const records = (await res.json()) as readonly { readonly id: string; readonly title: string }[] return { records } } ``` ```ts title="src/app/(public)/research/index.ts" import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" import type { z } from "zod" import type state from "./state.js" type ResearchState = z.infer export async function workflow( state: ResearchState, ctx: RuntimeContext>, ) { const { records } = await ctx.tools.lookup({ tenant: "default", query: state.query }) return { ...state, count: records.length } } ``` ## Notes - **No registration.** Drop the file into `tools/`. Run `b4 typegen` to regenerate types, or let the dev server do it on reload. Use `b4 check` to validate the app without writing files. - **Two discovery locations.** Tools are discovered from both `/tools/` (route-local, only available to that route) and `src/tools/` (shared across all routes). Place a tool in `src/tools/` when multiple routes need it; place it in `/tools/` to keep it scoped. - **Serializable types wherever they are declared.** B4.run's compiler pass extracts input and output types written inline, declared as local aliases or interfaces, or imported from another module. Keep the root input object-shaped so B4.run can emit the model-facing tool schema. - **Plain JSON in, plain JSON out.** Tool inputs and outputs cross the runtime boundary. No `Date`, no `Map`, no class instances. - **`agent` routes don't call `ctx.tools`.** The LLM picks tools by name once `b4 build` binds them. The example above uses a `workflow` route to show the typed call site. ## Related --- ### Typed State # Typed State You want a typed state shape for a route, including the values needed to run it. Here's how. ## The code ```ts title="src/app/(public)/hello/[tenant]/state.ts" import { z } from "zod" export default z.object({ /** Tenant supplied in the run input */ tenant: z.string(), /** Accumulated tool-call results */ context: z.string().default(""), /** Number of records returned by the lookup tool */ count: z.number().default(0), }) ``` ```ts title="src/app/(public)/hello/[tenant]/index.ts" import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" import type { z } from "zod" import state from "./state.js" type HelloState = z.infer export async function workflow( input: unknown, ctx: RuntimeContext>, ) { const parsed: HelloState = state.parse(input) const { records } = await ctx.tools.lookup({ tenant: parsed.tenant, query: "*" }) return { ...parsed, count: records.length } } ``` ## Notes - **Include route values in the schema.** `[tenant]` is part of the route id; the workflow validates `tenant` from the input by calling `state.parse(input)`. - **Route ids stay parameterized.** Run this route as `/hello/[tenant]` with input for `tenant`: ```bash echo '{"tenant":"acme"}' | b4 run '/hello/[tenant]' ``` For an Agent Protocol run, send the same input in the body: ```json { "route": "/hello/[tenant]#workflow", "input": { "tenant": "acme" } } ``` A concrete `/hello/acme` path is not how B4.run populates route state. - **Parse workflow input explicitly.** Workflow routes do not automatically parse `state.ts`. Calling `state.parse(input)` validates the input and applies fields declared with `.default(...)` when the caller omits them. - **This workflow imports and parses the schema directly.** It can keep `tenant` required. For agent-state discovery, B4.run instead validates `{}` to extract defaults; if you reuse this schema for an agent route, make every top-level field accept missing input so discovery does not skip it. - **State is JSON-serializable.** It crosses the runtime boundary on every `runs/wait` and `runs/stream` call. Use primitives, plain objects, arrays. ## Related and generated state contracts" }, { href: "/docs/api/sdk#b4runsdk-1", title: "SDK Reference", subtitle: "RuntimeContext and route-authoring types" }, ]} /> --- ### Auth Middleware # Auth Middleware You want execution authentication that rejects unauthenticated route-execution requests and passes the verified user identity to every tool call in that execution. Here's how. ## The code ```ts title="src/middleware.ts" import { allow, defineMiddleware, reject } from "@b4run/sdk" export default defineMiddleware(async (req) => { const auth = req.headers["authorization"] if (!auth?.startsWith("Bearer ")) { return reject(401, { error: "Missing bearer token" }) } const token = auth.slice("Bearer ".length) const userId = await verifyJwt(token).catch(() => null) if (!userId) { return reject(401, { error: "Invalid token" }) } return allow({ userId }) }) async function verifyJwt(token: string): Promise { // Replace with your real verifier. const res = await fetch("https://auth.example.com/verify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token }), }) if (!res.ok) throw new Error("verify failed") const body = (await res.json()) as { readonly sub: string } return body.sub } ``` ```ts title="src/app/(private)/account/[tenant]/tools/loadProfile.ts" export default async ( input: { readonly tenant: string }, ctx: { signal: AbortSignal; middleware?: Readonly> }, ) => { const userId = ctx.middleware?.userId as string | undefined if (!userId) throw new Error("middleware did not populate userId") const profile = await db.profiles.find({ userId, tenant: input.tenant }) return { profile } } ``` ```ts title="src/app/(private)/account/[tenant]/index.ts" import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" type AccountState = { readonly tenant: string; readonly profile?: unknown } export async function workflow( state: AccountState, ctx: RuntimeContext>, ) { const { profile } = await ctx.tools.loadProfile({ tenant: state.tenant }) return { ...state, profile } } ``` ## Notes - **Tool name equals the file basename.** The tool file is `tools/loadProfile.ts`, so it is called as `ctx.tools.loadProfile(...)`. A file named `tools/load-profile.ts` would instead be `ctx.tools["load-profile"](...)`. Use camelCase filenames to get camelCase `ctx.tools` keys. - **One global middleware.** `src/middleware.ts` runs once per route-execution request before the route. Branch by `req.routeId` if you need per-route policy — there is no array of layered middlewares. - **`reject` short-circuits.** The route never executes; the body of `reject(status, body?)` is the HTTP response. - **`allow(context)` flows to tools.** Whatever you pass becomes `ctx.middleware` on every tool call for that request. It is `undefined` if you call `allow()` with no arguments. - **Throwing equals 500.** Any uncaught error becomes HTTP 500. Prefer explicit `reject(401, ...)` with a real body so callers see a useful response. - **Deployment scope matters.** This middleware runs under `b4 dev`, the Node B4.run HTTP runtime (`b4 start` or generated `server.mjs`), and Hono builds. Generated LangSmith graph entries do not include B4.run HTTP middleware, so enforce authentication at that platform boundary separately. B4.run middleware covers Agent Protocol stream, wait, and resume execution plus AG-UI. Thread create/read/delete/state, cancellation, memory-candidate management, and health checks bypass it. Put authentication, tenant authorization, and network policy around the entire B4.run HTTP surface in an outer host or reverse proxy; exempt only probes you deliberately make public. See [Security Architecture](/docs/security-architecture) for the endpoint boundary. ## Related --- ### Stream Output # Stream Output You want a route's response to arrive incrementally rather than as a single blob at the end. Here's how to call `/threads/:id/runs/stream` and read the SSE frames. ## The code ```ts title="scripts/stream-research.ts" // 1. Create a thread first (or reuse an existing one). const threadRes = await fetch("http://127.0.0.1:3001/threads", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), }) const { thread_id } = (await threadRes.json()) as { thread_id: string } // 2. Start a streaming run on that thread. const res = await fetch(`http://127.0.0.1:3001/threads/${thread_id}/runs/stream`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ route: "/research#agent", input: { messages: [{ role: "user", content: "latest LLM benchmarks" }] }, }), }) if (!res.ok || !res.body) { throw new Error(`stream failed: ${res.status}`) } const reader = res.body.pipeThrough(new TextDecoderStream()).getReader() let buffer = "" while (true) { const { value, done } = await reader.read() if (done) break buffer += value // SSE frames are delimited by a blank line. const frames = buffer.split("\n\n") buffer = frames.pop() ?? "" for (const frame of frames) { // Ignore SSE comments (for example, the `: ping` heartbeat) first. const lines = frame.split("\n").filter((line) => !line.startsWith(":")) // Event frames have an "event:" line and a "data:" line. const eventLine = lines.find((l) => l.startsWith("event: ")) const dataLine = lines.find((l) => l.startsWith("data: ")) if (!eventLine || !dataLine) continue const eventType = eventLine.slice("event: ".length) const payload = JSON.parse(dataLine.slice("data: ".length)) switch (eventType) { case "chunk": // Streamed text fragment — append to display. if (typeof payload === "string") process.stdout.write(payload) break case "tool_call": console.log(`\n[tool_call] ${payload.name}`, payload.input) break case "tool_result": console.log(`[tool_result] ${payload.name}`, payload.output) break case "plan_update": console.log("[plan_update]", payload) break case "interrupt": console.log("[interrupt]", payload) break case "done": console.log("\n[done]", payload.output) break default: // subagent.start, subagent.tool_call, subagent.tool_result, // subagent.message, subagent.end, etc. console.log(`[${eventType}]`, payload) } } } ``` ## SSE event types Every event frame is `event: \ndata: \n\n`. Parse the `event:` line to route frames correctly — do not treat all frames as the same type. | Event type | Payload shape | Notes | |---|---|---| | `chunk` | `string` | Streamed text fragment from the LLM. | | `tool_call` | `{ name: string, input: unknown }` | LLM is calling a tool. | | `tool_result` | `{ name: string, output: unknown }` | Tool returned a result. | | `plan_update` | plan object | Emitted after a [Planning](/docs/planning) `writeTodos` result updates the route's todo state. (Does not write back to `plan.md`.) | | `interrupt` | interrupt object | Emitted when the agent hits a HITL interrupt point. | | `subagent.start` | `{ name, routeId, depth, call_id }` | A subagent started. | | `subagent.tool_call` | `{ call_id, name, input }` | Tool call inside a subagent. | | `subagent.tool_result` | `{ call_id, name, output }` | Tool result inside a subagent. | | `subagent.message` | `{ call_id, content }` | Text chunk from a subagent. | | `subagent.end` | `{ call_id, final_message?, error? }` | Subagent finished or failed. | | `done` | `{ output: unknown }` | Run complete; `output` is the final route result. | ## Notes - **AP URL shape.** The endpoint is `/threads/:id/runs/stream`; the body is `{ route, input }`. Create or retrieve a thread id via `POST /threads` first. - **`text/event-stream`, not JSON.** The response is raw SSE. Parse `event:` and `data:` lines per frame, or use a client like `eventsource-parser`. - **Heartbeats are comments.** A `: ping` heartbeat contains no `event:` or `data:` payload, so clients ignore it. - **Use `/threads/:id/runs/wait` when you don't need progress.** Streaming adds parsing complexity for callers. Pick `runs/stream` when partial output is meaningful — long agent reasoning, token-by-token text, intermediate workflow states, [planning](/docs/planning) updates, or [subagent](/docs/subagents) activity. - **Retry has limits during streams.** Retry is available only before the first stream event; any emitted token or event commits the response. See [Retry](/docs/retry) for the streaming caveat. ## Related --- ### Retry Transient Model Calls # Retry Transient Model Calls You want an agent route to retry transient model and provider failures automatically. Here's how. ## The code ```ts title="src/app/(public)/summarize/[doc]/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", retry: { maxAttempts: 5, baseDelay: 500 }, systemPrompt: "Summarize the document the user provides in three bullet points.", }) ``` ```ts title="src/app/(public)/summarize/[doc]/tools/fetch-doc.ts" import { setTimeout as delay } from "node:timers/promises" // Tool retry is local application code. It is separate from agent retry. export default async ( input: { readonly doc: string }, ctx: { signal: AbortSignal }, ) => { for (let attempt = 0; attempt < 3; attempt++) { const res = await fetch(`https://docs.example.com/${input.doc}`, { signal: ctx.signal }) if (res.ok) return { text: await res.text() } const status = res.status const retryable = status === 429 || status >= 500 await res.arrayBuffer() // Drain the body so the connection can be reused. if (!retryable || attempt === 2) throw new Error(`fetch failed: ${status}`) await delay(250 * 2 ** attempt, undefined, { signal: ctx.signal }) } throw new Error("fetch retry exhausted") } ``` ```ts title="src/app/(public)/billing/[invoice]/index.ts" import { agent } from "@b4run/sdk" // Critical path — fail fast, do not retry. export default agent({ model: "gpt-5-mini", retry: { maxAttempts: 1 }, systemPrompt: "...", }) ``` ## Notes - **Per-route, not global.** Each `agent()` declares its own `retry`. Different routes can have different policies. - **Transient model/provider errors only.** Rate limits (`429`), server errors (`500`/`502`/`503`), network timeouts, and OpenAI `overloaded`/`server_error` are retried. Invalid API keys, missing models, and schema validation errors fail immediately. - **Exponential backoff with jitter, capped at 10s.** For non-stream fallback, `delay = min(baseDelay * 2^n + jitter, 10s)`. Lowering `baseDelay` makes the first retry faster. - **`maxAttempts: 1` disables retry.** The default is `3` if `retry` is omitted entirely. - **Streaming routes only retry before the first event.** Once an event has streamed, the response is committed. Agent retry covers transient model/provider execution before that point; applications needing tool retry implement it locally, as in the example above. ## Related --- ### Dispatch from a Route # Dispatch from a Route You want one route to invoke another — for example, a coordinator agent that delegates a step to a specialized research subagent. Here's how. ## Idiomatic: use `task()` with subagents The idiomatic approach is to use B4.run's built-in subagent dispatch. Add a `subagents/researcher/` directory next to your coordinator route, and the runtime auto-generates a `task` tool for the coordinator. ```text src/app/(public)/research/ index.ts ← coordinator agent subagents/ researcher/ index.ts ← specialist subagent tools/ webSearch.ts ``` ```ts title="src/app/(public)/research/subagents/researcher/index.ts" import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", description: "Search the web and return concise findings for a given query.", systemPrompt: "You are a research specialist. Search thoroughly and cite your sources.", }) ``` This is an internal `task` tool call emitted by the coordinator's model, not TypeScript route code that an author imports or calls: ```ts task({ subagent: "researcher", input: "Find the latest LLM benchmark results for 2025.", }) ``` B4.run wires the child route, runs it, and returns its final output as the tool result — all within the same process. See [Subagents](/docs/subagents) for the full convention and discovery rules. ## Advanced: cross-service dispatch Use raw HTTP when you need to call an agent in a **different service** (a separately deployed B4.run project or any AP-compatible endpoint). This is not the recommended path for routes within the same project. ```ts title="src/app/(public)/orchestrator/[job]/index.ts" import type { RuntimeContext } from "@b4run/sdk" import type { z } from "zod" import type state from "./state.js" type OrchestratorState = z.infer & { readonly job: string } export async function workflow(state: OrchestratorState, _ctx: RuntimeContext) { // The dev server binds an EPHEMERAL port unless `b4 dev --port ` is // passed. Set B4_RUNTIME_URL explicitly rather than hard-coding a port. const baseUrl = process.env.B4_RUNTIME_URL if (!baseUrl) throw new Error("B4_RUNTIME_URL is not set") // Create a thread first. const threadRes = await fetch(`${baseUrl}/threads`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), }) if (!threadRes.ok) { throw new Error(`thread creation failed: ${threadRes.status}`) } const { thread_id } = (await threadRes.json()) as { thread_id: string } // Run the remote route and wait for the result. const res = await fetch(`${baseUrl}/threads/${thread_id}/runs/wait`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ route: "/research#agent", input: { messages: [{ role: "user", content: state.job }] }, }), }) if (!res.ok) { throw new Error(`dispatch failed: ${res.status}`) } const remoteState = (await res.json()) as { readonly messages?: readonly { readonly content?: unknown }[] } const lastMessage = remoteState.messages?.at(-1) const summary = typeof lastMessage?.content === "string" ? lastMessage.content : "" return { ...state, summary } } ``` ### Notes on cross-service dispatch - **EPHEMERAL port.** `b4 dev` picks a random port unless you pass `--port `. Never hard-code `127.0.0.1:3001` — pass `B4_RUNTIME_URL` via env instead. With a fixed port: `b4 dev --port 3001`. - **AP body shape.** The request body is `{ route, input }` where `route` is the `assistant_id` string (e.g. `"/research#agent"`) and `input` is the route's state payload. - **Forward headers when needed.** If the target route's middleware expects auth headers, propagate them on the inner `fetch` — middleware runs on every `runs/wait` and `runs/stream` call. - **Cancellation does not cross the service boundary.** Parent cancellation or shutdown does not automatically cancel the remote Agent Protocol run. Disconnecting the wait request is an AP viewer disconnect, so the remote run intentionally continues. To propagate cancellation, retain the remote thread id and call `POST /threads/:thread_id/cancel` when the caller's own `ctx.signal` aborts. - **Use `/threads/:id/runs/stream` for long-running children.** Same body, SSE response — see [Stream Output](/docs/recipes/stream-output). ## Related --- ### Research Assistant Web UI # Research Assistant Web UI Wire a [CopilotKit](https://docs.copilotkit.ai) web client to B4.run's research demo over [AG-UI](/docs/ag-ui). The example is a workbench rather than a chat widget: it renders its own thread rail, transcript, and composer instead of mounting `CopilotSidebar`, so the streamed report and the plan, researcher, tool, and permission cards all appear inline in message order. This recipe focuses on application wiring. For the endpoint, event contract, threading, and standard interrupt outcome, see [AG-UI and Web Clients](/docs/ag-ui). ## What you'll build The demo lives in `examples/research`: a B4.run server (`server/`) and a Next.js CopilotKit client (`web/`). The client provides: - **Thread rail** - "New conversation" plus the list of threads, each titled from its first user message. - **Chat + report** - streamed markdown output and citations in the app's own transcript. - **Plan cards** - the root checklist updates in place while research runs. - **Researcher cards** - delegated-work status, child checklist progress, and a bounded trail of child-tool names and statuses. - **Suggestions + tools** - three discovery prompts on the empty state, and generic root-tool cards in the transcript. - **Permissions** - standard interrupt UI owns approval and denial actions, rendered at the end of the transcript where the run stopped. - **Composer** - send, and stop while a run is in flight; blocked while the agent is running or waiting on an approval, with the header saying which. The plan and researcher cards are informational. They never resolve or cancel an interrupt; interrupt outcomes and answers use the standard AG-UI fields documented on the protocol page. Durable-memory review is part of the Workbench: a bounded panel reaches B4.run through the same allowlisted, same-origin proxy used for thread hydration. See [Approve memory candidates](#approve-memory-candidates) below. ## Run it ```bash cd examples/research/server cp .env.example .env # set OPENAI_API_KEY here, not in the web app ``` ```bash cd examples/research pnpm install pnpm dev # B4.run server on :3002, web client on :3010 ``` Open [http://localhost:3010](http://localhost:3010) and ask a research question. ## Connect the client to B4.run The Next.js runtime route registers an AG-UI `HttpAgent` pointed at the encoded B4.run assistant id. Register it under CopilotKit's `default` agent id so every hook binds without per-component wiring: ```ts title="examples/research/web/app/api/copilotkit/[...path]/route.ts" import { HttpAgent } from "@ag-ui/client" import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2" export const runtime = "nodejs" export const dynamic = "force-dynamic" const b4Url = process.env.B4_SERVER_URL ?? "http://127.0.0.1:3002" const agUiUrl = `${b4Url}/agui/${encodeURIComponent("/research#agent")}` const handler = createCopilotRuntimeHandler({ runtime: new CopilotRuntime({ agents: { default: new HttpAgent({ url: agUiUrl }) }, }), basePath: "/api/copilotkit", }) export const GET = handler export const POST = handler ``` ```tsx title="examples/research/web/app/page.tsx" // Excerpt: the provider tree. Thread state and its handlers live in the same // file, over `app/lib/thread-source.ts`. ``` The required catch-all route exposes CopilotKit's V2 REST and SSE paths under `/api/copilotkit/*`. Setting `useSingleEndpoint={false}` makes the browser begin with `GET /api/copilotkit/info` instead of sending the legacy method envelope to the base URL. The web runtime holds no model credential; only the B4.run server has `OPENAI_API_KEY`. CopilotKit hooks that omit an `agentId` resolve the id `default`. Register the B4.run agent under that id, or set the same explicit id on every consumer. Three pieces of that tree are load-bearing: - `defaultThrottleMs={100}` — the re-render throttle defaults to *unthrottled*, and a full research run streams hundreds of events, which pegs the renderer. - `CopilotChatConfigurationProvider` — `CopilotKit` does not provide one; `` and `` did. Without it every thread-aware hook falls back to the agent's own auto-minted thread and selecting a row in the rail would change nothing. - `` and `` render nothing. They publish into CopilotKit's registries, and the transcript reads them back. ## The workbench shell `AppShell` is the only component that talks to the agent. It calls `useAgent()` with no arguments — the unscoped form, which takes its thread from the surrounding chat configuration — so the transcript, `useInterrupt`, `useSuggestions`, and the tool-call renderers all resolve the same agent and the same thread. Two consequences of dropping the sidebar are worth copying if you build your own shell: - **Run failures need a subscription.** `copilotkit.runAgent` does not reject when a run fails; it catches, emits an error, and resolves normally. Failures surface through `copilotkit.subscribe({ onError })`, which is what the sidebar used to do for you. - **The permission gate needs `renderInChat: false`.** The default (`true`) publishes the element into ``/``. With neither mounted, the gate would render nowhere: the run parks with no approve or deny UI, no error, and green tests. With it set, `useInterrupt` returns the element and `Transcript` places it at the end of the message list. Threads are local to the browser. B4.run's server can create and fetch a thread by id but cannot enumerate threads, so the rail keeps its own list in `localStorage` behind a `ThreadSource` interface (`examples/research/web/app/lib/thread-source.ts`); CopilotKit's `useThreads` is deliberately unused. That same seam is how history comes back on a switch: CopilotKit's own replay path (`connectAgent`) is reached only from inside ``, which this app does not mount, so the shell reads `GET /threads/:id/state` through the proxy instead and maps the checkpoint's LangChain envelopes into the shapes the transcript already renders (`app/lib/hydrate.ts`). Messages, tool calls and results, and the plan come back; subagent activity cards from earlier runs are not checkpointed and do not, which the app says in a line above the restored messages. The same seam carries `GET /threads/:id/pending_interrupts`, which is what makes a permission prompt survive a reload. `useInterrupt`'s state is fed only by live run events, so after a reload the server is still holding the gate and nothing on screen says so; `app/components/HydratedInterrupts.tsx` asks for the parked interrupts and reports their count upward so the composer stays blocked until one is answered. Restyling is one file: `examples/research/web/app/theme.css` defines the palette as CSS variables and re-exports it as Tailwind tokens through `@theme inline`, which is why the app's utilities read `bg-wb-surface`, `border-wb-border`, and `rounded-wb`. ## Render plan and researcher activities The plan and researcher cards ship with the adapter. Install the package and hand its renderer array to CopilotKit: ```bash pnpm add @b4run/ag-ui ``` ```tsx import { b4ActivityRenderers } from "@b4run/ag-ui/react" ``` Then import the stylesheet once, in your root layout: ```tsx title="app/layout.tsx" import "@b4run/ag-ui/react/styles.css" ``` That import is not optional polish. The cards carry no inline styles, so without it they render as bare markup. Once it is in place, restyle by overriding the `--b4-activity-*` custom properties in your own CSS — see the [package README](https://github.com/cacheplane/b4run/blob/main/packages/ag-ui/README.md#customizing-the-activity-cards) for the full customization ladder. `b4ActivityRenderers` is a module-scope constant, so the registry keeps a stable identity across React renders, and each renderer is keyed by the adapter's own activity-type constant. Both renderers validate the public activity content with a strict runtime schema: a payload with unknown or incompatible fields fails closed instead of dumping arbitrary JSON into the transcript. The example passes `workbenchActivityRenderers` instead — its own two renderers, in `examples/research/web/app/components/activity-renderers.tsx`. They are not forks: they wrap the packaged `PlanActivityCard` and `SubagentActivityCard` and pass the packaged content schemas, adding only per-part classes through the `classNames` prop, so validation and bounds stay in the package where they are tested. One constraint governs which classes work: a `classNames` entry can only set a property the package stylesheet leaves unset on that element, because the package's CSS is unlayered and Tailwind's utilities are not. What the sheet does claim on the card's own box — background, border color, radius, text color, font-size, margin and padding — plus the header's font weight and the depth badge's background, is reachable at rung 1 instead, through the `--b4-activity-*` tokens the app sets in `app/theme.css`. React and `@copilotkit/react-core` are optional peer dependencies of `@b4run/ag-ui`, and only the `./react` subpath needs them. A server that uses the root or `./sse` entry installs nothing extra. To present the same activities your own way, the subpath also exports the pieces behind that array: `b4PlanActivityRenderer` and `b4SubagentActivityRenderer` for registering one without the other; `PlanActivityCard`, `SubagentActivityCard`, and `ActivityChecklist` as plain React components; and `planActivityContentSchema` and `subagentActivityContentSchema` for validating content before rendering it yourself. The cards take a `content` prop and need no CopilotKit context. Root plan snapshots replace `b4:plan:${runId}` and carry the complete todo list. Subagent snapshots replace `b4:subagent:${call_id}` and carry the name, depth, `running`/`completed`/`failed` status, optional todos, up to five recent tool name/status summaries, the total tool count, and an optional 400-character failure summary. Both root and child checklist views display at most eight todos, while their snapshots retain the complete valid todo lists. The `b4.plan` and `b4.subagent` activity content supplied to these cards excludes child reasoning or prose, prompts, tool inputs, tool outputs, final child answers, route ids, and raw runtime ids. These activities are the whole presentation of the two built-in orchestration tools. When a `writeTodos` or `task` call produced its activity, B4.run's AG-UI adapter emits no tool call/result events for that call, so the wildcard tool card receives the ordinary tools (`recall`, `searchCorpus`, `readDoc`, `writeFile`, and `runBash` once approved) but never `writeTodos` or `task`. The suppression happens in the adapter, not in CopilotKit or in these renderers. The wildcard card is still the fail-open fallback. If an activity cannot be produced — no tool-call id, a malformed payload, delegation that never starts — the ordinary tool events survive and the generic card renders them, which is why its `task` argument summary is worth keeping. Choose **Research a topic** on the empty transcript to see the root plan and researcher progress update before the cited answer. This live flow still uses the B4.run server's model key. The automated browser check proves only V2 transport selection; it does not replace this live-model flow. ## Approve memory candidates When the coordinator calls `remember()`, B4.run stores a durable-memory candidate. The runtime exposes candidates over HTTP: ```text GET /memory/candidates POST /memory/candidates/:id/approve POST /memory/candidates/:id/reject ``` The browser cannot call those routes directly unless the server sets [`server.cors`](/docs/configuration#server); with it absent the B4.run dev server sends no CORS headers — so the example reaches them through a same-origin catch-all, `app/api/b4/[...path]/route.ts`. That proxy is **allowlisted**, not pass-through: `app/lib/proxy-allowlist.ts` is a pure function listing the exact method and path shape of every route the browser may reach — the three memory routes above plus `GET /threads/:id/state` and `GET /threads/:id/pending_interrupts`. Anything else is rejected with 403 and never forwarded, which is what keeps `POST /threads/:id/resume` and the rest of the agent surface out of reach of a template every B4.run developer copies. Running, resuming, and cancelling a thread are deliberately absent from the list; those go through CopilotKit's own runtime route. Add a route to the allowlist only when the browser genuinely needs it, and prefer reads. Note what the allowlist is not: the proxy forwards with no authentication and the example installs no `threadAccess` policy, so it bounds which routes are reachable, not who may reach them — anything that can reach the Next app can read any thread's transcript by guessing its id. Add a `threadAccess` policy on the B4.run side before this leaves your own machine. `app/components/MemoryPanel.tsx` renders the candidates in the thread rail with Approve and Delete on each — Delete maps to `/reject`, a hard delete on the server. It reads on mount and again at the end of every run, because `remember()` lands mid-run and a memory proposed in the answer you are reading should be reviewable without a reload: ```tsx title="examples/research/web/app/components/MemoryPanel.tsx" useEffect(() => { const controller = new AbortController() void load(controller.signal) return () => { controller.abort() } }, [load]) useEffect(() => { const subscription = agent.subscribe({ onRunFinishedEvent: () => { void load() }, }) return () => { subscription.unsubscribe() } }, [agent, load]) ``` The panel is review-only, and only of candidates: at most three are listed and the rest are counted. Browsing, searching, and editing stored memories remain the [`b4 memory`](/docs/memory/long-term) CLI's job. The coordinator plans, dispatches subagents, and makes many tool calls. The research route sets `recursionLimit: 100` so a full run can exceed LangGraph's default step ceiling. ## Related --- ### Configuration Reference # Configuration Reference `b4.config.ts` is the typed application contract at the app root. The CLI uses it with `package.json` to find a B4.run app, so the file is required even though every `B4Config` field is optional. Node processes load the module once; `b4 dev` picks up an edit by restarting its child runtime. Filesystem-free runtimes receive an already-constructed config object instead of loading this file. Use [`b4 verify`](/docs/cli) to check the config, route tree, Node version, provider credentials, and configured sandbox preflight before serving the app. ## Complete annotated example This file is copyable as written. Uncomment the instance-valued fields only after importing or constructing the corresponding implementation. ```ts title="b4.config.ts" import { config } from "@b4run/core" export default config({ appDir: "src/app", // A configured sandbox overrides these backends for sandboxed threads. backends: { // filesystem: customFilesystem, // exec: customExec, }, permissions: { mode: "interactive", allow: { bash: ["ls", "cat"], tool: ["deployPreview"] }, deny: { bash: ["rm -rf"] }, // store: sharedPermissionsStore, }, // Defaults are local SQLite/file stores under .b4/. // checkpointer: sharedCheckpointer, // threadsStore: sharedThreadsStore, // Used by b4 dev/inspect; b4 start receives env from its host. env: "./.env", toolOutput: { offloadThresholdChars: 40_000, previewLines: 10, maxBytes: 268_435_456, ttlMs: 10_800_000, gcThrottleMs: 10_000, noOffloadTools: [], }, summarization: { enabled: false, maxTokens: 12_000, keepRecentTurns: 6, // model: "gpt-5-mini", // tokenCounter: async (text) => countTokens(text), // summarize: async ({ messages, model, previousSummary, signal }) => "...", }, build: { targets: ["node", "langsmith"] }, // Cross-origin access. Omit and the runtime sends no `Access-Control-*` // header at all, so a browser on another origin cannot call it — the // default, because opening a server to other origins is a deployment // decision. `localhost` and `127.0.0.1` are different origins to a browser. server: { cors: { origins: ["https://app.example.com"] }, }, // sandbox: { // provider: dockerSandbox({ image: "node:24-slim" }), // network: { mode: "allow", denylist: ["169.254.169.254"] }, // // network: { mode: "deny", allowlist: ["api.openai.com"] }, // env: { NODE_ENV: "production" }, // resources: { memoryMb: 1024, cpus: 1, timeoutMs: 120_000, diskGb: 10 }, // security: { // dropAllCapabilities: true, // noNewPrivileges: true, // readOnlyRootFilesystem: true, // runAsNonRoot: true, // // runAsNonRoot: { uid: 1000, gid: 1000 }, // pidsLimit: 512, // }, // idleTimeoutMs: 600_000, // }, memory: { // enabled: true, // inert compatibility field; memory.ts opts a route in // store: sharedMemoryStore, writes: "candidate", indexMaxEntries: 20, recall: { weights: { relevance: 0.6, recency: 0.3, confidence: 0.1 }, recencyHalfLifeMs: 1_209_600_000, candidatePool: 256, }, // vector: { // embedder, // weights: { keyword: 1, vector: 1 }, // rrfK: 60, // vectorK: 64, // recencyWeight: 0.3, // confidenceWeight: 0.1, // }, episodes: { enabled: false, ttlMs: 2_592_000_000, cap: 500, includeFailedRuns: true, embed: false, }, distill: { model: "gpt-5-mini", provider: "openai", maxBatches: 5, consolidate: { olderThanMs: 604_800_000, minBatchSize: 5, maxBatchSize: 50, // ttlMs: 2_592_000_000, sourceTtlMs: 604_800_000, }, reflect: { minNewRecords: 10, maxRecords: 100, writes: "candidate" }, }, // resolveScope: ({ routePath, appRoot }) => ({ route: routePath, app: appRoot }), }, }) ``` ## Key reference ### `appDir` ```ts appDir?: string ``` Default: `"src/app"`. The path is relative to the app root and selects the tree where B4.run discovers route `index.ts` files. An authored value replaces the default; there is no environment override. Keep it inside the app root. See [Routes](/docs/routes) for the discovered layout. ### `backends` ```ts backends?: { filesystem?: FilesystemBackend exec?: ExecBackend } ``` Defaults: the local filesystem and local child-process executor. For a sandboxed thread, the sandbox handle's filesystem and executor take precedence; otherwise configured backends beat the local defaults. Live backend objects cannot cross a static edge build boundary. See [Workspace Filesystem](/docs/workspace) for backend composition and path-jail behavior. ### `permissions` ```ts permissions?: { mode?: "interactive" | "non-interactive" | "bypass" allow?: Readonly> deny?: Readonly> store?: PermissionsStore } ``` Without a custom store, the defaults are `mode: "interactive"`, empty config maps, and `.b4/permissions.json` for persisted interactive decisions. `B4_PERMISSIONS_MODE` overrides `permissions.mode`; a deny match beats an allow match. The reserved `tool` and `subagent` keys use exact matching, while resource paths, bash commands, and memory scopes use prefix matching. Config `allow` and `deny` maps stay in memory and do not seed the runtime permissions store. An interactive **Always** decision is what persists a runtime allow entry. See [Permissions](/docs/permissions) for mode behavior and the interrupt/resume lifecycle. #### `permissions.store` A supplied `PermissionsStore` replaces the file-backed store and is loaded before use. The custom store owns its mode and allow/deny policy: sibling `permissions.mode`, `allow`, `deny`, and `B4_PERMISSIONS_MODE` are not applied again. In an embedded runtime, a boot-supplied store takes precedence over this config field. ### `checkpointer` ```ts checkpointer?: BaseCheckpointSaver ``` Default: SQLite at `.b4/checkpoints.sqlite`. A boot-supplied checkpointer takes precedence, then this field, then the default. Checkpoints hold graph state; they are not thread metadata. See [Persistence and Tenancy](/docs/persistence) before sharing or deleting state across replicas. ### `threadsStore` ```ts threadsStore?: ThreadsStore ``` Default: SQLite at `.b4/threads.sqlite`. A boot-supplied thread store takes precedence, then this field, then the default. It stores Agent Protocol thread metadata, not graph checkpoints; configure both stores when replicas share threads. See [Persistence and Tenancy](/docs/persistence) for the storage boundary and deletion order. ### `env` ```ts env?: string ``` Default: `"./.env"`, relative to the app root. For `b4 dev` and `b4 inspect`, precedence is `--env-file`, then `config.env`, then the default. `b4 verify` checks the resolved file without loading it into the current process. `config.env` is loaded by `b4 dev` and `b4 inspect`, not by `b4 start`; production variables must come from the shell or hosting platform. The LangSmith artifact independently chooses `.env.example` when present and `.env` otherwise. Its JSON records an env-file path, not variable names, and it does not use `config.env`. See [Deployment Options](/docs/deployment) for each target's environment contract. ### `toolOutput` ```ts toolOutput?: { offloadThresholdChars?: number previewLines?: number maxBytes?: number ttlMs?: number gcThrottleMs?: number noOffloadTools?: readonly string[] } ``` On a Node runtime with a workspace filesystem, defaults are `40_000` characters, `10` preview lines, `268_435_456` bytes (256 MB), a `10_800_000` ms (3 hour) TTL, and a `10_000` ms GC throttle. `noOffloadTools` defaults to `[]` and is unioned with the always-exempt `readFile` and `listDir`. Authored values replace their individual defaults. Offloaded data lives under `workspace/tool-outputs/`; retention is a context budget, not durable storage. A non-empty block is incompatible with the filesystem-free Hono target (`B4_E1005`). See [Context Management](/docs/context-management) for retrieval and cleanup behavior. ### `summarization` ```ts summarization?: { enabled?: boolean maxTokens?: number keepRecentTurns?: number model?: string tokenCounter?: (text: string) => number | Promise summarize?: (args: { messages: readonly unknown[] model: string previousSummary?: string signal: AbortSignal }) => Promise } ``` Defaults: disabled, `maxTokens: 12_000`, `keepRecentTurns: 6`, the route model, a lazy `gpt-tokenizer` `o200k_base` counter, and the built-in one-call summarizer. Each authored field replaces its default. Enabling summarization without either a route model or `summarization.model` cannot create a summarizer. See [Context Management](/docs/context-management) for when history is compressed and what remains verbatim. ### `build` ```ts build?: { targets?: readonly string[] } ``` Default: `["node", "langsmith"]`. An authored `targets` array replaces that list; it does not add to it. Supported values are `"node"`, `"langsmith"`, and the opt-in `"hono"` subset. Choose only targets whose runtime can materialize the configured capabilities. See [Deployment Options](/docs/deployment) for the artifact and compatibility matrix. ### `sandbox` ```ts sandbox?: { provider: SandboxProvider network?: | { mode: "allow"; denylist?: readonly string[] } | { mode: "deny"; allowlist?: readonly string[] } env?: Readonly> resources?: { memoryMb?: number; cpus?: number; timeoutMs?: number; diskGb?: number } security?: { dropAllCapabilities?: boolean noNewPrivileges?: boolean readOnlyRootFilesystem?: boolean runAsNonRoot?: boolean | { uid: number; gid: number } pidsLimit?: number } idleTimeoutMs?: number } ``` Default: no sandbox, so workspace operations use the selected host backends. When configured, `provider` is required; the manager defaults to an allow-mode network policy that denies `169.254.169.254`, injects no host environment, and releases idle compute after `600_000` ms. Resource and security enforcement is provider-specific; Docker's security fields default hardened. `diskGb` matters to PVC-backed providers and Docker ignores it. At runtime, an injected `sandboxManager` takes precedence over `config.sandbox`; config is used only when the host does not supply a manager. See [Execution Sandbox](/docs/sandbox) before relaxing isolation or network policy. ### `memory` ```ts memory?: { enabled?: boolean store?: MemoryStoreLike writes?: "off" | "candidate" | "auto" | "ask" indexMaxEntries?: number recall?: { weights?: { relevance?: number; recency?: number; confidence?: number } recencyHalfLifeMs?: number candidatePool?: number } vector?: { embedder: Embedder weights?: { keyword?: number; vector?: number } rrfK?: number vectorK?: number recencyWeight?: number confidenceWeight?: number } episodes?: { enabled?: boolean ttlMs?: number cap?: number includeFailedRuns?: boolean embed?: boolean } distill?: { model?: string provider?: ModelProviderId maxBatches?: number consolidate?: { olderThanMs?: number minBatchSize?: number maxBatchSize?: number ttlMs?: number sourceTtlMs?: number } reflect?: { minNewRecords?: number maxRecords?: number writes?: "candidate" | "auto" } } resolveScope?: (ctx: { routePath: string; appRoot: string }) => Record } ``` The route's `memory.ts`, not `memory.enabled`, opts into typed memory; `enabled` is an inert compatibility field. Defaults are SQLite at `.b4/memory.sqlite`, `writes: "candidate"`, and `indexMaxEntries: 20`. Keyword recall defaults to relevance/recency/confidence weights `0.6`/`0.3`/`0.1`, a 14-day half-life, and a `256`-record candidate pool. Vector recall is absent by default; when present it requires `embedder` and defaults to keyword/vector weights `1`/`1`, `rrfK: 60`, `vectorK: 64`, `recencyWeight: 0.3`, and `confidenceWeight: 0.1`. Episodes default disabled with a 30-day TTL, cap `500`, failed runs included, and no embeddings (`embed: true` is unsupported). Distillation runs only when invoked: defaults are `gpt-5-mini`, inferred provider then `openai`, five batches; consolidate after seven days in batches of 5–50 with no summary TTL and a seven-day source TTL; reflect after 10 new records over at most 100, writing candidates. `resolveScope` receives only `routePath` and `appRoot`, so derive tenant or user dimensions from application-owned context. A custom store owns its own retrieval behavior rather than these default-store tuning blocks. At runtime, an injected `memoryStore` takes precedence over `config.memory.store`; config precedes the default SQLite store. See [Long-term Memory](/docs/memory/long-term) for governance, [Recall and Retrieval](/docs/memory/retrieval) for ranking, [Episodes](/docs/memory/episodes) for run history, and [Distillation](/docs/memory/distillation) for explicit consolidation and reflection. ### `server` ```ts server?: { cors?: { origins: readonly string[] | "*" credentials?: boolean methods?: readonly string[] headers?: readonly string[] exposeHeaders?: readonly string[] maxAgeSeconds?: number } } ``` Absent by default, and absence means **no `Access-Control-*` header on any response** — a browser on another origin cannot call `/agui/*`, `/threads/*` or `/memory/*`, and `OPTIONS` falls through to the router's 404. Opening a server to other origins is a deployment decision, so nothing here is inferred. Set it when a browser client talks to B4.run directly instead of through a same-origin proxy: ```ts server: { cors: { origins: ["https://app.example.com"] } } ``` Origins are compared exactly, after normalizing case and a trailing slash, so `"HTTP://LocalHost:3010/"` matches an `Origin: http://localhost:3010`. Note that `localhost` and `127.0.0.1` are *different* origins — list both if your dev client may be opened at either. The policy is resolved and validated once at boot, so a malformed origin list fails on startup rather than on the first cross-origin request. `origins: "*"` with `credentials: true` is rejected outright: browsers refuse a wildcard allow-origin on a credentialed request, and accepting it would produce a server that looks configured and fails only in the console. Defaults for the rest: `credentials` false; `methods` `GET, POST, DELETE, OPTIONS`; `headers` echoes the browser's own `Access-Control-Request-Headers` (so an app can add an auth header without changing server config); `exposeHeaders` empty; `maxAgeSeconds` 600. Two behaviors worth knowing. A request from an origin **not** on the list is still served normally — it just carries no CORS header, and the browser is what refuses to hand it to the page; answering 403 there would break every non-browser client that happens to send an `Origin`. And error responses are stamped too, including the shutdown 503, because a cross-origin caller that cannot read a 404 sees only an opaque CORS failure. CORS is not authentication. It controls which origins a browser will let read a response; it does not decide who may call the server. Pair it with [`defineThreadAccess`](/docs/thread-access). ## Postgres backend Shared Postgres persistence requires three explicit entries: the checkpointer, thread store, and permission store. They are separate because checkpoints, thread metadata, and runtime grants have different contracts. ```bash pnpm add @b4run/postgres-storage pg ``` ```ts title="b4.config.ts" import { config } from "@b4run/core" import { createPostgresPermissionsStore, createPostgresThreadsStore, postgresCheckpointer, } from "@b4run/postgres-storage" import { Pool } from "pg" export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) pool.on("error", (error) => { console.error("Postgres pool client error:", error) }) export default config({ checkpointer: postgresCheckpointer({ pool }), threadsStore: createPostgresThreadsStore({ pool }), permissions: { store: createPostgresPermissionsStore({ pool, mode: "non-interactive" }), }, }) ``` Attach an `error` listener to an injected `pg.Pool`. The application owns the pool and must end it after B4.run has stopped accepting and draining requests; the stores do not close an injected pool. Postgres shares durable rows, but it does not distribute active-run or cancel coordination between processes. Use sticky or thread-aware routing, or provide a distributed coordination layer. See [Persistence and Tenancy](/docs/persistence) for migration, lifecycle, retention, and deletion contracts, then [Production Topology](/docs/production-topology) for replica routing and shutdown order. --- ### CLI Reference # CLI Reference B4.run ships a single `b4` binary with fourteen commands: `add`, `build`, `check`, `dev`, `docs`, `eval`, `inspect`, `memory`, `routes`, `run`, `start`, `test`, `typegen`, and `verify`. Most commands read `b4.config.ts` from the current working directory or from `--cwd `; `b4 dev` currently uses the current working directory and exposes only its own server flags. ## Running the CLI Always invoke B4.run through the `b4` **bin** — `pnpm exec b4 ` (or `npx b4 …` / a `package.json` script). The bin is what `@b4run/cli` installs into `node_modules/.bin`, and it resolves correctly regardless of where the package physically lives. In an npm/pnpm-hoisted monorepo, `@b4run/cli` typically hoists to the workspace root, so a hardcoded path like `node node_modules/@b4run/cli/dist/index.js` from a sub-package will fail to resolve. Run `pnpm exec b4 …` (which finds the bin via the workspace's `node_modules/.bin`) instead of pointing Node at the package's `dist/` directly. ## `b4 check` Validates the app structure and configuration. ``` b4 check ``` Internally, `b4 check` loads `b4.config.ts`, resolves the app root, runs `discoverRoutes`, and parses each route's tool definitions with `discoverToolDefinitions`. It surfaces: - A `b4.config.ts` that fails to load. - Any discovered route whose `index.ts` exports more than one of `agent`, `workflow`, `graph`, `chain`. - Any tool file that fails to parse. - A stale build manifest: when `.b4/build/modules.mjs` exists (written by `b4 build`'s `node` target), its routes are compared against the routes on disk — a mismatch (for example a route renamed after the last build) fails with the missing/extra route ids and a prompt to re-run `b4 build`. `.b4/build/modules.edge.mjs` is checked the same way whenever `"hono"` is a configured target: it is a separate artifact with its own copy of every route's static imports, and an app that builds for `hono` **alone** emits no `modules.mjs` at all — so this pass used to have nothing to look at for exactly the deployment shape that cannot re-walk its route tree at runtime. A manifest that is simply absent is a no-op, and the edge manifest is skipped when `hono` is not a configured target, so a leftover file from an experiment is not an error. - An unknown `build.targets` entry, and — when `"hono"` is one of them — every feature that target [cannot serve](/docs/deployment/edge#what-the-edge-cannot-serve), reported together as `B4_E1005`. The same gate `b4 build` applies, mirrored here so you learn about all of them before building. Output is a `B4.run app is valid: N routes discovered.` line followed by a per-route `- ()` summary. Exits non-zero on any violation with a detailed message. ### Troubleshooting imports When a route, tool, or config module fails to load with the opaque ESM error `does not provide an export named X`, B4.run now prints the offending package plus a likely cause and fix instead of the raw `SyntaxError`. Two cases account for almost all of these: - **An older `@langchain/core` got hoisted.** Run `npm ls @langchain/core` to find the duplicate, then upgrade or dedupe so the installed version satisfies B4.run's peer range (`^1.1.47`). - **A CommonJS dependency imported with named bindings.** Under B4.run's ESM resolver a CommonJS package only has a default export, so `import { thing } from "x"` fails. Use a default import and destructure instead: ```ts import pkg from "x" const { thing } = pkg ``` Or import the package's ESM build if it ships one. ## `b4 verify` Runs five checks in one call (`app`, `routes`, `typegen`, `deps`, `runtime`) — the canonical preflight before `b4 dev`, `b4 start`, or a deploy. A green `b4 verify` means "this app will boot in this environment." ``` b4 verify b4 verify --json ``` Flags: - `--cwd ` — operate on a different app root. - `--json` — emit a structured report (`{ status, appRoot, checks, counts }`) instead of human-readable text. - `--env-file ` — path to a `.env` file (overrides `b4.config.ts` `env` and the default `./.env`). The `deps` check covers missing packages and missing env vars (advisory). It is **provider-aware**: it derives the API-key env var from the providers your routes actually use — an Anthropic-only app is checked for `ANTHROPIC_API_KEY`, an OpenAI app for `OPENAI_API_KEY`, a multi-provider app for the union, and a local Ollama app for none. A missing key is a warning, not a failure (the key may come from the runtime environment). The `runtime` check gates **environment readiness**: - **Node** — asserts the running Node version is at least `24.0.0` (B4.run's floor: the active LTS line, which bundles npm ≥ 11 and ships `node:sqlite` unflagged). Below the floor **fails** `verify` with a non-zero exit. - **Docker** — present only when `b4.config.ts` configures a sandbox provider; it runs the provider's daemon preflight and **fails** if the daemon is unreachable. Apps with no sandbox skip this sub-check entirely. See [Deployment](/docs/deployment) for the recommended workflow. ## `b4 routes` Lists every route B4.run discovered and its computed pathname. ``` b4 routes b4 routes --json ``` Output: ``` Discovered 2 B4.run routes in /path/to/app /research -> src/app/research/index.ts /admin/users -> src/app/(internal)/admin/users/index.ts ``` Use this to confirm that route groups and dynamic segments are being parsed the way you expect. ## `b4 typegen` Regenerates `.b4/b4.generated.d.ts` plus per-route `.b4/routes//tools.json` and `.b4/routes//state.json` manifests. ``` b4 typegen ``` The success log reports route, tool-schema, and stateful-route counts. The `tools.json` artifacts are consumed by `b4 build` to emit LangGraph entries. `b4 dev` runs `typegen` before every child (re)start, so saves that touch tool signatures or state schemas are picked up on the automatic restart. You only need to invoke it manually after a fresh clone, before CI, or after a tool signature change while the dev server isn't running. ## `b4 build` Writes deployment artifacts for the configured `build.targets` (default: `["node", "langsmith"]`). ``` b4 build b4 build --clean ``` Flags: - `--cwd ` — operate on a different app root. - `--clean` — wipe `.b4/build/` before writing. Emits, per target: - **`node`** — `.b4/build/server.mjs` (boots `serveRuntime` — the real B4.run runtime) and a hardened `Dockerfile` (written to the app root unless one already exists there, else to `.b4/build/Dockerfile`). Run it with `b4 start` or `docker build`/`docker run`. - **`langsmith`** — `.b4/build/langgraph.json` plus per-route entry files under `.b4/build/.ts` — the artifacts you hand to LangSmith. Includes `graphs` (keyed by `#`), `dependencies: ["."]`, `env` (`.env.example` if present, else `.env`), and `node_version: "22"`. For `agent` routes, the generated entry imports the default `agent()` descriptor, materializes it as a LangGraph graph, and wires in every discovered route tool. - **`hono`** — **opt-in, not a default.** `.b4/build/app.mjs` (a Hono app around B4.run's web-standard fetch handler, `export default`ed for Cloudflare Workers, Vercel, or Bun), `.b4/build/modules.edge.mjs` (the node-builtin-free module manifest), `.b4/build/stores.mjs` (a per-request Postgres store factory), and a `wrangler.toml` scaffold at the app root — written only if you have none, and never overwritten. Deploy with `wrangler deploy`. Restrict which targets are emitted via `build.targets` in `b4.config.ts` (e.g. `{ build: { targets: ["node"] } }`). The list replaces the defaults rather than adding to them, so an app deploying to the edge names `{ build: { targets: ["node", "hono"] } }`. The `hono` target serves a **subset** of B4.run, and the build fails with `B4_E1005` — naming every offending config key and file at once — when the app uses the sandbox, `backends.filesystem`/`backends.exec`, a config-supplied store, `toolOutput`, a `workspace/` directory, or route-level long-term memory. [`b4 check`](#b4-check) applies the identical gate whenever `hono` is a configured target. See [Edge and Hono](/docs/deployment/edge). `toolOutput` is a recent addition to that list, and the one gated key that is plain JSON — so it used to be inlined into the bundle and then ignored, and the build went green while the deployed worker never offloaded. An app that sets both `toolOutput` and `"hono"` therefore sees a build that passed before start failing: remove the key, or drop `"hono"` from `build.targets`. See [Upgrading](/docs/upgrading#tooloutput-is-now-gated-off-the-hono-target). See [Deployment](/docs/deployment) for the full bridge. ## `b4 start` Serves the app in production using the real B4.run runtime — Agent Protocol, AG-UI, and `/healthz` — binding `0.0.0.0:8000` by default. ``` b4 start b4 start --host 127.0.0.1 --port 3000 ``` Flags: - `--host ` — host to bind. Default: `0.0.0.0` (or the `HOST` env var). - `--port ` — port to bind. Default: `8000` (or the `PORT` env var). This is what the `node` build target's generated `Dockerfile` runs (`CMD ["node", ".b4/build/server.mjs"]`), and it's the only server that engages the [execution sandbox](/docs/sandbox) in production. See [Node and Docker](/docs/deployment/node). ## `b4 run` Executes a single route invocation with JSON stdin/stdout. ``` echo '{"messages":[{"role":"user","content":"Hello"}]}' | b4 run '/research' ``` Flags: - `--cwd ` — operate on a different app root. - `--url ` — run against a live dev server instead of the in-process runtime. The route argument can be the parameterized id (e.g. `/research`) or the relative route entry file path (e.g. `src/app/research/index.ts`). Dynamic segment values come from the JSON input. When `--url` is set, `b4 run` POSTs to `/threads//runs/wait` with `{ route: "#", input }`. Route paths containing `(`, `)`, `[`, or `]` should be quoted in shell so they aren't expanded. ## `b4 test` Runs every colocated `run.test.ts` scenario in the app. ``` b4 test b4 test src/app/(public)/hello ``` Flags: - `--cwd ` — operate on a different app root. The optional positional `[path]` argument narrows the discovered scenario set to a subdirectory. To target a live dev server, add `.server(url)` to that scenario's builder chain inside `run.test.ts` (there is no command-level `--url` flag on `b4 test`). Exits non-zero on any failure with a diff per mismatched scenario. See [Testing](/docs/testing) for scenario authoring. ## `b4 eval` Runs every colocated `*.eval.ts` over its dataset and reports per-case scores, then gates on the aggregate. ``` b4 eval b4 eval src/app/chat b4 eval --live b4 eval --record b4 eval --json ``` Flags: - `--cwd ` — operate on a different app root. - `--live` — run against the real model (requires `OPENAI_API_KEY`); never use in CI. - `--record` — record real-model responses into sibling fixture files (requires `OPENAI_API_KEY`); never use in CI. Mutually exclusive with `--live`. - `--json [file]` — write a JSON report. Defaults to `.b4/eval-report.json`. The optional positional `[path]` narrows discovery to a subdirectory. By default each case replays its aimock fixtures (deterministic, CI-safe); `--live` calls the real provider for local prompt tuning; `--record` captures real-model responses as committed fixture files that plain `b4 eval` replays. A gated eval that fails exits non-zero, so CI fails when quality drops below the bar; informational evals (no `gate`/`threshold`) never affect the exit code. See [Evals](/docs/evals) for authoring. ## `b4 dev` Starts the local runtime — hot reload + [Agent Protocol (AP) HTTP endpoints](/docs/dev-server/agent-protocol). Bind address is fixed at `127.0.0.1`. ``` b4 dev b4 dev --port 3001 ``` Flags: - `--port ` — HTTP port. Default: dynamically allocated. Pass `--port` for a stable address. - `--env-file ` — path to a `.env` file (overrides `b4.config.ts` `env` and the default `./.env`). Because the default port is chosen dynamically, copy-paste `curl` examples should pass an explicit `--port` (or read the port `b4 dev` prints on startup) rather than assuming a fixed value. If `LANGSMITH_API_KEY` is present in the loaded environment and `LANGCHAIN_TRACING_V2` is not already set, `b4 dev` automatically enables LangSmith tracing by setting `LANGCHAIN_TRACING_V2=true`. Set `LANGCHAIN_PROJECT` to control which project receives the traces. See [Observability](/docs/observability) for the full tracing guide. See [Agent Protocol](/docs/dev-server/agent-protocol) for the full protocol reference and architecture notes. ## `b4 memory` Inspects and manages the app's [long-term memory](/docs/memory/browse) store — the typed records the agent writes via its generated `remember` tool. Use it to review and promote the `candidate` writes that the default `memory: { writes: "candidate" }` config holds back from `recall`. ``` b4 memory list b4 memory search b4 memory inspect b4 memory approve b4 memory reject b4 memory forget b4 memory prune [--cap ] [--namespace ] b4 memory consolidate [--dry-run] [--namespace ] [--model ] [--provider ] [--max-batches ] b4 memory reflect [--dry-run] [--namespace ] [--model ] [--provider ] [--max-batches ] ``` Subcommands: - `list` — list pending candidate records. - `search ` — list candidates whose content or namespace matches ``. - `inspect ` — print one record as formatted JSON. - `approve ` — promote a candidate to an `active` record so `recall` surfaces it. - `reject ` — drop a candidate without promoting it. - `forget ` — delete a record by id. - `prune` — run [episodic retention](/docs/memory/episodes#retention) manually: delete expired records (TTL) and enforce the per-namespace episode cap. `--cap ` overrides the cap for this pass; `--namespace ` scopes the pass to namespaces matching the prefix. - `consolidate` — run [distillation](/docs/memory/distillation)'s compaction pass: group old episodic records per (namespace, ISO week), summarize each group with one model call, then supersede the sources and stamp them with a TTL so `prune` reaps them later. - `reflect` — run distillation's insight pass: derive durable insights from each namespace's records newer than its watermark. Insights are written as `candidate` by default — approve them with `approve ` or the Inspector. `consolidate` and `reflect` share the same flags, and both are threshold-aware no-ops: below the configured thresholds they print one line, exit `0`, construct no model and require no API key — which is what makes the cron recipe (`b4 memory consolidate && b4 memory reflect`) safe to run nightly on any app. `--dry-run` reports the plan without making a single model call; `--namespace ` scopes the pass; `--model ` / `--provider ` override `memory.distill`; `--max-batches ` caps the work (and spend) for one invocation. These are the only `b4 memory` subcommands that spend model tokens. See [Distillation](/docs/memory/distillation) for the full configuration block. Flags: - `--cwd ` — operate on a different app root. ## `b4 inspect` Opens the [B4.run Inspector](/docs/inspector) — a localhost-only browser UI for browsing, searching, and approving the app's [long-term memory](/docs/memory/browse) records. ``` b4 inspect ``` The inspector binds to `127.0.0.1` on a free port and prints the URL to open. Flags: - `--cwd ` — operate on a different app root. - `--port ` — bind the inspector to a stable localhost port. - `--env-file ` — path to a `.env` file (overrides `b4.config.ts` `env` and the default `./.env`). ## `b4 add` Fetches a blueprint (an agent-facing integration guide) and prints it to stdout so you can hand it to your coding agent. ``` b4 add # list available blueprints, grouped by category b4 add pgvector # print the pgvector blueprint b4 add # fetch a third-party blueprint from any URL ``` `b4 add` only prints the guide — your agent applies the changes, and you review them. Set `B4_BLUEPRINTS_URL` to point at a self-hosted catalog instead of `b4.run`. See [Blueprints](/docs/blueprints) for authoring and catalog details. ## `b4 docs` Prints the **bundled, version-matched** B4.run docs that ship inside the installed CLI — so a coding agent (or you) can read the docs for the exact version in use without a network round-trip. With no topic it lists the available topics and the index; with a topic it prints that doc to stdout. ``` b4 docs b4 docs README b4 docs cli ``` The optional positional `[topic]` selects a single doc by slug (with or without the `.md` suffix); an unknown topic exits non-zero and lists the available topics. Running from a source checkout requires the CLI to be built first (`pnpm --filter @b4run/cli build`) so the bundled docs exist. ## `b4 threads` Inspects and reattaches to Agent Protocol threads on a running B4.run server. ``` b4 threads tail b4 threads tail --url http://127.0.0.1:3000 b4 threads tail --header "authorization: Bearer $TOKEN" b4 threads tail --json ``` `tail` rejoins a thread through [`GET /threads/:thread_id/runs/stream`](/docs/dev-server/agent-protocol) — the reattach endpoint — and is the recovery path after a disconnect, a reload, or a lost terminal. It prints a snapshot first, then follows whatever comes next: - When a turn is **streaming in that server process**, the snapshot carries the committed transcript plus the turn's output so far, and the command then tails live frames until the turn emits `done`. - When **no turn is live** there, the snapshot is the durable one — the latest checkpoint plus any parked human-in-the-loop prompts — and the command exits immediately. This path works across restarts and replicas because it is checkpoint-backed. The header line reports `status`, whether the attach is `live`, the turn's `run_started_at`, and the checkpoint `anchor`. Comparing `run_started_at` across reattachments tells you whether a *different* turn now owns the thread; `anchor` correlates a resumed turn back to the run it continues. Attaching never cancels anything and never takes the thread's run slot, so it is safe to tail a thread someone else is driving. To stop a run, use `POST /threads/:thread_id/cancel` instead. Flags: - `--url ` — base URL of the running B4.run server (default `http://127.0.0.1:3000`). - `--header ` — extra request header, repeatable. Use it when the server's middleware authenticates the thread's route. - `--json` — print raw SSE frames as JSON, one per line, instead of rendered text. Exit codes follow the table below: `1` when the stream ends without a terminal `done` frame or the server detaches the viewer (the per-thread viewer cap, or a consumer too slow to keep up — reconnect for a fresh snapshot), and `2` when the server is unreachable, the thread does not exist, or the thread has never run and so has no route identity to authorize against. ## Exit codes | Code | Meaning | |---|---| | 0 | Success | | 1 | Validation failure (e.g. `b4 check`) or scenario failure (e.g. `b4 test`) | | 2 | Configuration / runtime error (missing `b4.config.ts`, bad `appDir`, scenario load failure) | Non-zero exit codes from underlying tools (Commander, child processes) may be propagated unchanged. ## Related --- ### API Reference # API Reference Use this catalog to choose the B4.run package, subpath, or executable that owns a contract. Application developers should start with `@b4run/sdk`; use the focused references for exact exports, defaults, errors, and lifecycle rules. ## Package and surface index Application shortcuts: author routes with the [`@b4run/sdk` reference](/docs/api/sdk); use the [`b4` command or runtime entries](/docs/api/cli), including `/fetch` for supported edge integrations; test programmatically with [`@b4run/testing`](/docs/api/testing); score behavior with [`@b4run/evals`](/docs/api/evals); and import app-specific tool and state types from [`b4:routes`](/docs/api/generated-routes). Read the runtime column precisely: `node-only` records that a surface does not pass B4.run's edge-safety guard, not that the surface cannot run in a browser. Most `node-only` entries are genuinely server-side, but `@b4run/ag-ui/react` is browser-targeted and fails the guard only because React's JSX runtime reads `process.env.NODE_ENV`. | Package | Purpose | Audience | Stability | Surfaces | Artifact boundaries | README | Reference | Guide | |---|---|---|---|---|---|---|---|---| | `@b4run/ag-ui` | AG-UI protocol translation for B4.run runtimes and web clients. | `integration` | `supported` | `@b4run/ag-ui`
`@b4run/ag-ui/sse`
`@b4run/ag-ui/react`
`@b4run/ag-ui/react/styles.css` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity
catalog summary · stylesheet asset; resolved by a bundler, never evaluated as JS · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/ag-ui/README.md) | [Reference](/docs/api/ag-ui) | [Guide](/docs/ag-ui) | | `@b4run/cli` | B4.run development, build, type generation, and runtime commands. | `tooling` | `supported` | `@b4run/cli`
`@b4run/cli/fetch`
`@b4run/cli/runtime`
`@b4run/cli/testing`
`bin.b4` | focused reference · `node-only` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity
focused reference · `node-only` executable command · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/cli/README.md) | [Reference](/docs/api/cli) | [Guide](/docs/cli) | | `@b4run/config-biome` | Shared Biome configuration for B4.run projects. | `tooling` | `supported` | `@b4run/config-biome`
`@b4run/config-biome/biome` | internal only · static configuration; no runtime import · purity n/a
internal only · static configuration; no runtime import · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/config-biome/README.md) | [Reference](/docs/api#b4runconfig-biome) | [Guide](/docs/getting-started) | | `@b4run/config-typescript` | Shared TypeScript configurations for B4.run projects. | `tooling` | `supported` | `@b4run/config-typescript`
`@b4run/config-typescript/base`
`@b4run/config-typescript/library`
`@b4run/config-typescript/node`
`@b4run/config-typescript/nextjs` | internal only · static configuration; no runtime import · purity n/a
internal only · static configuration; no runtime import · purity n/a
internal only · static configuration; no runtime import · purity n/a
internal only · static configuration; no runtime import · purity n/a
internal only · static configuration; no runtime import · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/config-typescript/README.md) | [Reference](/docs/api#b4runconfig-typescript) | [Guide](/docs/getting-started) | | `@b4run/core` | Route discovery, app configuration, capabilities, and type generation. | `integration` | `low-level` | `@b4run/core`
`@b4run/core/node`
`@b4run/core/internal/compiler` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity
internal only · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/core/README.md) | [Reference](/docs/api/core) | [Guide](/docs/routes) | | `@b4run/devkit` | Internal scaffold templates and generated-app test utilities. | `internal` | `internal` | `@b4run/devkit` | internal only · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/devkit/README.md) | [Reference](/docs/api#b4rundevkit) | [Guide](/docs/getting-started) | | `@b4run/evals` | Evaluation definitions, scorers, datasets, and runners. | `testing` | `supported` | `@b4run/evals` | focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/evals/README.md) | [Reference](/docs/api/evals) | [Guide](/docs/evals) | | `@b4run/inspector` | Browser application for inspecting a running B4.run app. | `tooling` | `supported` | `b4Inspector.server` | catalog summary · `node-only` separately operated application · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/inspector/README.md) | [Reference](/docs/api#b4runinspector) | [Guide](/docs/inspector) | | `@b4run/langchain` | LangChain backend adapters for B4.run agents and chains. | `integration` | `supported` | `@b4run/langchain`
`@b4run/langchain/package.json` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · package metadata; read as data, not runtime code · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/langchain/README.md) | [Reference](/docs/api/langchain) | [Guide](/docs/agents) | | `@b4run/langgraph` | LangGraph runtime adapters and route contracts. | `integration` | `supported` | `@b4run/langgraph`
`@b4run/langgraph/define-entry`
`@b4run/langgraph/route-module` | focused reference · `edge-safe` runtime · `dependency-free` purity
focused reference · `edge-safe` runtime · `dependency-free` purity
focused reference · `edge-safe` runtime · `dependency-free` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/langgraph/README.md) | [Reference](/docs/api/langgraph) | [Guide](/docs/routes) | | `@b4run/memory` | Long-term memory storage, ranking, browsing, and reconciliation. | `application` | `supported` | `@b4run/memory`
`@b4run/memory/browse`
`@b4run/memory/namespace`
`@b4run/memory/reconcile` | focused reference · `node-only` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `dependency-free` purity
focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/memory/README.md) | [Reference](/docs/api/memory) | [Guide](/docs/memory/long-term) | | `@b4run/memory-pgvector` | Postgres and pgvector storage for shared long-term memory. | `application` | `supported` | `@b4run/memory-pgvector` | focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/memory-pgvector/README.md) | [Reference](/docs/api/memory-pgvector) | [Guide](/docs/memory/long-term) | | `@b4run/permissions` | Permission matching and Node-backed approval stores. | `integration` | `supported` | `@b4run/permissions`
`@b4run/permissions/node` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/permissions/README.md) | [Reference](/docs/api/permissions) | [Guide](/docs/permissions) | | `@b4run/postgres-storage` | Postgres persistence for checkpoints, threads, and permissions. | `application` | `supported` | `@b4run/postgres-storage`
`@b4run/postgres-storage/node` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/postgres-storage/README.md) | [Reference](/docs/api/postgres-storage) | [Guide](/docs/persistence) | | `@b4run/sandbox` | Docker-backed isolated workspace execution for B4.run agents. | `application` | `supported` | `@b4run/sandbox`
`@b4run/sandbox/testing` | focused reference · `node-only` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/sandbox/README.md) | [Reference](/docs/api/sandbox) | [Guide](/docs/sandbox) | | `@b4run/sdk` | Author-facing declarations for agents, tools, middleware, and routes. | `application` | `supported` | `@b4run/sdk`
`@b4run/sdk/pure`
`@b4run/sdk/testing` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `dependency-free` purity
focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/sdk/README.md) | [Reference](/docs/api/sdk) | [Guide](/docs/agents) | | `@b4run/sqlite-storage` | Local SQLite persistence for B4.run runtime state. | `application` | `supported` | `@b4run/sqlite-storage` | focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/sqlite-storage/README.md) | [Reference](/docs/api/sqlite-storage) | [Guide](/docs/persistence) | | `@b4run/testing` | Harnesses, fixtures, matchers, and runtime test utilities. | `testing` | `supported` | `@b4run/testing` | focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/testing/README.md) | [Reference](/docs/api/testing) | [Guide](/docs/testing-agents) | | `@b4run/vite-plugin` | Internal Vite integration for B4.run type generation. | `tooling` | `internal` | `@b4run/vite-plugin` | internal only · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/vite-plugin/README.md) | [Reference](/docs/api#b4runvite-plugin) | [Guide](/docs/routes) | | `@b4run/workspace` | Filesystem and shell tools for agent workspaces. | `application` | `supported` | `@b4run/workspace`
`@b4run/workspace/node` | focused reference · `edge-safe` runtime · `dependency-free` purity
focused reference · `node-only` runtime · `not-claimed` purity | [README](https://github.com/cacheplane/b4run/blob/main/packages/workspace/README.md) | [Reference](/docs/api/workspace) | [Guide](/docs/workspace) | | `create-b4-app` | Scaffolder for new B4.run applications. | `tooling` | `supported` | `bin.create-b4-app` | catalog summary · `node-only` executable command · purity n/a | [README](https://github.com/cacheplane/b4run/blob/main/packages/create-b4-app/README.md) | [Reference](/docs/api#create-b4-app) | [Guide](/docs/getting-started) | Generated surface: `b4:routes` — focused reference · generated types; compile-time only, no runtime import · purity n/a. ## Reference conventions **Audience** says who should import a surface: application code, integration code, tests, tooling, or B4.run internals. **Stability** distinguishes supported APIs from low-level or internal contracts. **Documentation** says whether this release provides a focused reference, a catalog summary, or an internal-only boundary. Each artifact boundary also states whether a surface is runtime code, static configuration, metadata, generated types, a command, or a separately operated application; runtime imports state their purity claim. The headings below preserve links from the former single-page reference. They point to the canonical package page and section; use the canonical destination for new links. ## @b4run/sdk The application authoring surface now lives at [`@b4run/sdk`](/docs/api/sdk#use-this-when). ### Agent See the canonical [`agent()` and `AgentConfig` contract](/docs/api/sdk#agent-and-agentconfig). ### `agent(config)` See the canonical [`agent()` signature](/docs/api/sdk#agent-and-agentconfig). ### `AgentConfig` See the canonical [`AgentConfig` fields](/docs/api/sdk#agent-and-agentconfig). ### `ReasoningConfig` See the canonical [agent configuration contract](/docs/api/sdk#agent-and-agentconfig). ### `RetryConfig` See the canonical [agent configuration contract](/docs/api/sdk#agent-and-agentconfig). ### `B4Agent` See the canonical [agent descriptor contract](/docs/api/sdk#agent-and-agentconfig). ### Subagent delegation types See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `isB4Agent(value)` See the canonical [agent descriptor contract](/docs/api/sdk#agent-and-agentconfig). ### Middleware See the canonical [middleware result contract](/docs/api/sdk#allow-and-reject). ### `defineMiddleware(fn)` See the canonical [middleware export inventory](/docs/api/sdk#b4runsdk-1). ### `allow(context?)` See the canonical [`allow()` and `reject()` contract](/docs/api/sdk#allow-and-reject). ### `reject(status, body?)` See the canonical [`allow()` and `reject()` contract](/docs/api/sdk#allow-and-reject). ### `B4Middleware` See the canonical [middleware export inventory](/docs/api/sdk#b4runsdk-1). ### `MiddlewareRequest` See the canonical [middleware export inventory](/docs/api/sdk#b4runsdk-1). ### `MiddlewareResult` See the canonical [middleware result contract](/docs/api/sdk#allow-and-reject). ### `ContinueResult` See the canonical [middleware result contract](/docs/api/sdk#allow-and-reject). ### `RejectResult` See the canonical [middleware result contract](/docs/api/sdk#allow-and-reject). ### Memory See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1) and the [long-term memory guide](/docs/memory/long-term). ### `defineMemory(def)` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `DefinedMemory` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `MemoryScopeDimension` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### Route configuration See the canonical [`RouteConfig` contract](/docs/api/sdk#routeconfig). ### `RouteConfig` See the canonical [`RouteConfig` contract](/docs/api/sdk#routeconfig). ### `RouteKind` See the canonical [`RouteConfig` contract](/docs/api/sdk#routeconfig). ### Route types See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `RouteStateMap` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `RouteToolMap` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### Runtime See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `RuntimeContext` See the canonical [`RuntimeContext` export](/docs/api/sdk#b4runsdk-1). ### `RuntimeTool` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `ToolRegistry` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `B4ToolContext` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `WorkspaceFs` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### Models See the canonical [model validation contract](/docs/api/sdk#validatemodelid). ### `KnownModelId` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `ModelProviderId` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `OpenAiModelId` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `GoogleModelId` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `AnthropicModelId` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `XaiModelId` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `inferProvider(model)` See the canonical [model validation contract](/docs/api/sdk#validatemodelid). ### `SUPPORTED_AGENT_PROVIDERS` See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### `validateModelId(opts)` See the canonical [`validateModelId()` contract](/docs/api/sdk#validatemodelid). ### `ModelIdValidation` See the canonical [`validateModelId()` contract](/docs/api/sdk#validatemodelid). ### Model id constants See the canonical [SDK model export inventory](/docs/api/sdk#b4runsdk-1). ### Backend adapter See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `BackendAdapter` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### Utilities See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ### `Prettify` See the canonical [SDK public export inventory](/docs/api/sdk#b4runsdk-1). ## @b4run/cli The command and embedding surfaces now live at [`@b4run/cli`](/docs/api/cli#use-this-when). ### `serveRuntime(options)` See the canonical [`serveRuntime()` contract](/docs/api/cli#serveruntime). ### `loadStaticModules(manifestUrl)` See the canonical [CLI public export inventory](/docs/api/cli#b4runcli-1). ### `B4StaticModules` and `StaticRouteModule` See the canonical [CLI root export inventory](/docs/api/cli#b4runcli-1). ### @b4run/cli/fetch See the canonical [edge-safe fetch subpath](/docs/api/cli#b4runclifetch). ### @b4run/cli/runtime See the canonical [low-level runtime subpath](/docs/api/cli#b4runcliruntime). ## @b4run/core The integration surface now lives at [`@b4run/core`](/docs/api/core#use-this-when). ### Capability exports See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ### `createCapabilityRegistry(markers)` and `applyCapabilities()` See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ### `gateToolOp()` and `wrapToolWithApproval()` See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ### `createWorkspaceFs(options)` See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ### `loadB4Config(options)` and `config(value)` See the canonical [`loadB4Config()` contract](/docs/api/core#loadb4config). ### `discoverRoutes(options)`, `findB4App(options)`, and route segments See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ### State and typegen helpers See the canonical [`resolveStateFields()` contract](/docs/api/core#resolvestatefields). ### Tool scope See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ### Storage type re-export See the canonical [Core public export inventory](/docs/api/core#b4runcore-1). ## @b4run/ag-ui The AG-UI adapter surface now lives at [`@b4run/ag-ui`](/docs/api/ag-ui#use-this-when). ### ID factories See the canonical [AG-UI public export inventory](/docs/api/ag-ui#b4runag-ui-1). ### `toAguiEvents(chunks, context)` See the canonical [inbound and outbound contracts](/docs/api/ag-ui#inbound-and-outbound-calls). ### `fromRunAgentInput(input)` See the canonical [inbound and outbound contracts](/docs/api/ag-ui#inbound-and-outbound-calls). ### SSE subpath: `encodeAgUiSse(event, accept?)` See the canonical [SSE subpath inventory](/docs/api/ag-ui#b4runag-uisse). ## @b4run/memory The long-term memory package surface now lives at [`@b4run/memory`](/docs/api/memory#use-this-when). ### `MemoryStore` See the canonical [store and query shapes](/docs/api/memory#store-and-query-shapes). ### `MemoryRecord` See the canonical [store and query shapes](/docs/api/memory#store-and-query-shapes). ### `MemoryQuery` See the canonical [store and query shapes](/docs/api/memory#store-and-query-shapes). ### `BrowseQuery`, `BrowsePage`, and `MemoryStats` See the canonical [browse subpath inventory](/docs/api/memory#b4runmemorybrowse). ### @b4run/memory/browse See the canonical [browse subpath inventory](/docs/api/memory#b4runmemorybrowse). ## @b4run/memory-pgvector The shared memory backend now lives at [`@b4run/memory-pgvector`](/docs/api/memory-pgvector#use-this-when). ### `pgvectorMemoryStore(options)` See the canonical [`PgvectorMemoryStore` contract](/docs/api/memory-pgvector#pgvectormemorystore). ### `PgvectorMemoryStore` See the canonical [`PgvectorMemoryStore` contract](/docs/api/memory-pgvector#pgvectormemorystore). ### `vectorColumnDef(dimensions)` See the canonical [dimension branches contract](/docs/api/memory-pgvector#behavior-contract-memory-pgvectordimension-branches). ### `initSchema(client, options)` See the canonical [initialization contract](/docs/api/memory-pgvector#initialization-and-retrieval). ### `assertIdentifier(name, value)` See the canonical [schema identifier contract](/docs/api/memory-pgvector#behavior-contract-memory-pgvectorschemaidentifier-validation). ## @b4run/postgres-storage The durable runtime storage surface now lives at [`@b4run/postgres-storage`](/docs/api/postgres-storage#use-this-when). ### `PostgresStoreOptions` See the canonical [`PostgresStoreOptions` fields](/docs/api/postgres-storage#postgresstoreoptions). ### `@b4run/postgres-storage/node` See the canonical [Node subpath inventory](/docs/api/postgres-storage#b4runpostgres-storagenode). ### `postgresCheckpointer(options)` See the canonical [Postgres root export inventory](/docs/api/postgres-storage#b4runpostgres-storage-1). ### `createPostgresThreadsStore(options)` See the canonical [Postgres root export inventory](/docs/api/postgres-storage#b4runpostgres-storage-1). ### `createPostgresPermissionsStore(options)` See the canonical [Postgres root export inventory](/docs/api/postgres-storage#b4runpostgres-storage-1). ### `assertIdentifier(name, value)` See the canonical [Postgres root export inventory](/docs/api/postgres-storage#b4runpostgres-storage-1). ### `DEFAULT_SCHEMA` / `DEFAULT_TABLE_PREFIX` See the canonical [Postgres root export inventory](/docs/api/postgres-storage#b4runpostgres-storage-1). ## @b4run/testing The programmatic test surface now lives at [`@b4run/testing`](/docs/api/testing#use-this-when). For fixture workflows, see [Fixtures and Recording](/docs/testing-agents/fixtures). ### Harnesses See the canonical [harness options contract](/docs/api/testing#agentharnessoptions). ### Aimock fixtures and recording See the canonical [harness and fixture lifecycle](/docs/api/testing#harness-and-fixture-lifecycle). ### Matchers See the canonical [testing public export inventory](/docs/api/testing#b4runtesting-1). ### Run-result utilities See the canonical [testing public export inventory](/docs/api/testing#b4runtesting-1). ### Memory, protocol, and subprocess helpers See the canonical [harness and fixture lifecycle](/docs/api/testing#harness-and-fixture-lifecycle). ### Example See the canonical [testing examples](/docs/api/testing#examples-and-related-guides). ## @b4run/evals The evaluation surface now lives at [`@b4run/evals`](/docs/api/evals#use-this-when). ### Eval definition and execution See the canonical [`EvalDefinition` contract](/docs/api/evals#evaldefinition). ### Scores and gates See the canonical [evaluation semantics](/docs/api/evals#evaluation-semantics). ### Built-in scorers See the canonical [eval public export inventory](/docs/api/evals#b4runevals-1). ### Memory scorers See the canonical [eval public export inventory](/docs/api/evals#b4runevals-1). ### Example See the canonical [eval examples](/docs/api/evals#examples-and-related-guides). ## b4:routes (generated) The generated application types now live at [`b4:routes`](/docs/api/generated-routes#use-this-when). ### `RouteTools

` See the canonical [generated tool contract](/docs/api/generated-routes#tools). ### `RouteState

` See the canonical [generated state contract](/docs/api/generated-routes#state). ## Where to read more Start with [Agents](/docs/agents), [Routes](/docs/routes), and [Tools](/docs/tools). Use the focused package references above when you need an exact import, field, default, error, or lifecycle rule. ## Related --- ### @b4run/sdk # @b4run/sdk ## Use this when Start here when you build a B4.run application. `@b4run/sdk` owns the route-authoring types and helpers most application code uses. Reach for `/pure` only when an integration needs dependency-free path or hash utilities, and `/testing` when you author route scenarios for `b4 test`. ## Install and import ```bash pnpm add @b4run/sdk ``` ```ts import { agent, defineMiddleware, defineMemory } from "@b4run/sdk" import { pureJoin } from "@b4run/sdk/pure" import { scenarios } from "@b4run/sdk/testing" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/sdk` | edge-safe | not-claimed | application | supported | | `@b4run/sdk/pure` | edge-safe | dependency-free | integration | supported | | `@b4run/sdk/testing` | node-only | not-claimed | testing | supported | `@b4run/sdk/testing` is the route-scoped scenario API used by `b4 test`; it is not the `@b4run/testing` package, which provides a programmatic agent harness. ## Public exports ### `@b4run/sdk` | Export | Responsibility | |---|---| | `AgentConfig` | Configure an agent route. | | `ConstraintContext` | Describe the live context passed to a tool constraint. | | `ConstraintPredicate` | Validate one tool call before execution. | | `ConstraintVerdict` | Express a constraint decision. | | `B4Agent` | Represent an authored agent route. | | `DelegationConfig` | Configure subagent delegation policy. | | `DelegationConstraintPredicate` | Validate a delegated request. | | `DelegationContext` | Describe a delegated request's live context. | | `DelegationRequest` | Describe a delegated input. | | `DelegationRule` | Express one named delegation policy. | | `DelegationRules` | Map subagent names to delegation policy. | | `DelegationVerdict` | Express a delegation constraint decision. | | `ReasoningConfig` | Configure model reasoning effort. | | `RetryConfig` | Configure model-call retry attempts and delay. | | `SubagentMap` | Map local subagent names to agents. | | `ToolScope` | Select and gate route tools. | | `agent` | Declare an agent route. | | `isB4Agent` | Test whether a value is an authored B4.run agent. | | `BackendAdapter` | Define a route backend adapter contract. | | `B4ErrorCode` | Name a registered B4.run error. | | `B4ErrorDescriptor` | Describe a registered B4.run error. | | `B4_ERRORS` | Read the error descriptor registry. | | `describeError` | Look up an error descriptor. | | `errorDocsUrl` | Build the documentation URL for an error code. | | `AnthropicModelId` | Name a curated Anthropic model. | | `GoogleModelId` | Name a curated Google model. | | `KnownModelId` | Offer curated model autocomplete while accepting custom string IDs. | | `OpenAiModelId` | Name a curated OpenAI model. | | `XaiModelId` | Name a curated xAI model. | | `ANTHROPIC_MODEL_IDS` | List curated Anthropic model IDs. | | `CURATED_MODEL_IDS` | Map curated providers to model IDs. | | `GOOGLE_MODEL_IDS` | List curated Google model IDs. | | `OPENAI_MODEL_IDS` | List curated OpenAI model IDs. | | `XAI_MODEL_IDS` | List curated xAI model IDs. | | `DefinedMemory` | Represent a typed memory declaration. | | `MemoryScopeDimension` | Define one typed memory namespace dimension. | | `defineMemory` | Declare a typed long-term memory schema. | | `ContinueResult` | Continue middleware execution with optional context. | | `B4Middleware` | Define the middleware function contract. | | `MiddlewareRequest` | Describe a middleware request. | | `MiddlewareResult` | Express a middleware decision. | | `RejectResult` | Stop middleware execution with a response. | | `allow` | Continue middleware execution. | | `defineMiddleware` | Preserve types for a middleware function. | | `reject` | Stop middleware execution. | | `B4ThreadAccess` | Define one thread-access action handler. | | `ThreadAccessAllow` | Express an allow decision, optionally stamping a thread on create. | | `ThreadAccessDeny` | Express a deny decision with an optional status and body. | | `ThreadAccessPolicy` | Declare per-action thread authorization handlers. | | `ThreadAccessRequest` | Describe the thread request being authorized. | | `ThreadAccessResult` | Express a thread authorization decision. | | `ThreadAction` | Name the coarse thread action being authorized. | | `ThreadOperation` | Name the specific endpoint operation being authorized. | | `ThreadSubject` | Describe the thread a decision is made about. | | `THREAD_ACCESS_METADATA_KEY` | Name the reserved metadata key holding the server-issued access stamp. | | `defineThreadAccess` | Preserve types for a thread access policy. | | `deny` | Refuse a thread request. | | `permit` | Allow a thread request, optionally stamping it on create. | | `BuiltInModelProviderId` | Name a built-in model provider. | | `ModelProviderId` | Name a built-in or custom model provider. | | `inferProvider` | Infer a provider from a model ID. | | `SUPPORTED_AGENT_PROVIDERS` | List providers built into agent materialization. | | `RouteConfig` | Describe route metadata. | | `RouteKind` | Name a supported route kind. | | `RouteStateMap` | Expose an open compatibility interface for route state. | | `RouteToolMap` | Expose an open compatibility interface for route tools. | | `RuntimeContext` | Provide request-scoped runtime data. | | `RuntimeTool` | Describe a runtime tool callable. | | `ToolRegistry` | Map runtime tool names to callables. | | `Prettify` | Flatten an object type for editor display. | | `ModelIdValidation` | Represent advisory model-ID validation. | | `validateModelId` | Check a model ID against curated suggestions. | | `B4ToolContext` | Provide context to a B4.run tool. | | `WorkspaceFs` | Define the workspace filesystem contract exposed to tools. | ### `@b4run/sdk/pure` | Export | Responsibility | |---|---| | `POSIX_SEP` | Expose the portable path separator. | | `pureBasename` | Read the last path component. | | `pureDirname` | Read the parent path. | | `pureJoin` | Join path segments without Node dependencies. | | `pureRelative` | Compute one portable relative path. | | `pureResolve` | Resolve portable path segments. | | `sha1Hex` | Compute a SHA-1 hexadecimal digest. | | `sha256Hex` | Compute a SHA-256 hexadecimal digest. | ### `@b4run/sdk/testing` | Export | Responsibility | |---|---| | `RouteScenarioMap` | Receive generated route-to-tool augmentation. | | `RuntimeErrorExpectation` | Describe an expected scenario error. | | `RuntimeExecutionBaseResult` | Share fields across scenario execution results. | | `RuntimeExecutionError` | Describe a failed execution error. | | `RuntimeExecutionErrorKind` | Name a runtime failure kind. | | `RuntimeExecutionFailureResult` | Represent a failed execution. | | `RuntimeExecutionMode` | Name the materialized route mode. | | `RuntimeExecutionResult` | Represent either execution outcome. | | `RuntimeExecutionSuccessResult` | Represent a successful execution. | | `RuntimeExecutionTiming` | Describe execution timing. | | `RuntimeMetaExpectation` | Describe expected runtime metadata. | | `ScenarioDescriptor` | Represent one authored scenario. | | `ScenarioSuiteBuilder` | Build route-scoped scenarios fluently. | | `ScenarioSuiteDescriptor` | Represent a completed scenario suite. | | `ScenarioToolCallExpectationDescriptor` | Describe an expected tool call. | | `ScenarioToolCallRecord` | Record an observed tool call. | | `ScenarioToolMockDescriptor` | Describe a mocked tool response. | | `expectError` | Assert a failed runtime result. | | `expectMeta` | Assert selected runtime metadata. | | `expectOutput` | Assert a successful runtime output. | | `isScenarioSuite` | Test whether a value is a scenario suite. | | `readScenarioSuite` | Read a scenario suite descriptor. | | `scenarios` | Start a typed scenario suite for a route. | ## Key contracts ### `agent()` and `AgentConfig` Use `agent()` as a route module's default export. `model` and `systemPrompt` are required; optional policy, retry, reasoning, subagent, and tool fields are preserved on the branded descriptor. ```ts api-contract="@b4run/sdk#.:agent" export declare function agent( config: AgentConfig, ): B4Agent ``` ```ts api-contract="@b4run/sdk#.:AgentConfig" export interface AgentConfig { readonly delegation?: DelegationConfig>> readonly description?: string readonly model: KnownModelId readonly provider?: ModelProviderId readonly reasoning?: ReasoningConfig readonly retry?: RetryConfig readonly recursionLimit?: number readonly subagents?: Subagents readonly tools?: ToolScope readonly systemPrompt: string } ``` **Fields: `@b4run/sdk#.:AgentConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly delegation` | `DelegationConfig>>` | no | Control dispatch to named subagents. | | `readonly description` | `string` | no | Describe the agent for selection. | | `readonly model` | `KnownModelId` | yes | Select the model. | | `readonly provider` | `ModelProviderId` | no | Override provider inference. | | `readonly reasoning` | `ReasoningConfig` | no | Tune reasoning effort. | | `readonly retry` | `RetryConfig` | no | Tune transient retry behavior. | | `readonly recursionLimit` | `number` | no | Cap LangGraph super-steps. | | `readonly subagents` | `Subagents` | no | Register child agents by name. | | `readonly tools` | `ToolScope` | no | Scope and gate tools. | | `readonly systemPrompt` | `string` | yes | Set the agent's system instruction. | #### Behavior contract `sdk.agent.descriptor-shape` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sdk/test/agent.test.ts","testNames":["descriptor is recognized by isB4Agent","carries a tools scope through to the descriptor","omits tools when not provided"]}] */} agent() returns a branded descriptor and includes optional tool scope only when supplied. #### Exact agent configuration signatures ```ts api-contract="@b4run/sdk#.:ReasoningConfig" export interface ReasoningConfig { readonly effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" } ``` **Fields: `@b4run/sdk#.:ReasoningConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly effort` | `"none" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | no | Set the model reasoning budget. | ```ts api-contract="@b4run/sdk#.:RetryConfig" export interface RetryConfig { readonly maxAttempts?: number readonly baseDelay?: number } ``` **Fields: `@b4run/sdk#.:RetryConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly maxAttempts` | `number` | no | Cap attempts for a model call. | | `readonly baseDelay` | `number` | no | Set the base retry delay. | ```ts api-contract="@b4run/sdk#.:isB4Agent" export declare function isB4Agent(value: unknown): value is B4Agent ``` ### `allow()` and `reject()` Middleware returns an explicit continue or reject result. Use `allow(context?)` to continue and `reject(status, body?)` to stop the request. ```ts api-contract="@b4run/sdk#.:defineMiddleware" export declare function defineMiddleware(fn: B4Middleware): B4Middleware ``` ```ts api-contract="@b4run/sdk#.:allow" export declare function allow(context?: Record): ContinueResult ``` ```ts api-contract="@b4run/sdk#.:reject" export declare function reject(status: number, body?: unknown): RejectResult ``` #### Behavior contract `sdk.middleware.result-shapes` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sdk/test/middleware.test.ts","testNames":["returns a reject result with status and body","omits body when not provided","returns a continue result with context","omits context when not provided"]}] */} allow() and reject() return discriminated result objects; omitted context and body properties are absent. ### `validateModelId()` Use this helper to improve author feedback for likely model-ID typos. It does not restrict custom model IDs. ```ts api-contract="@b4run/sdk#.:validateModelId" export declare function validateModelId(opts: { readonly model: string readonly provider?: string }): ModelIdValidation ``` #### Behavior contract `sdk.validate-model-id.advisory` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sdk/test/validate-model-id.test.ts","testNames":["flags a near-miss on a curated provider with distance-then-prefix-ranked suggestions","stays silent for uncurated providers","stays silent when no provider can be resolved"]}] */} Model validation is advisory: curated near-misses return provider-specific suggestions, while an uncurated or unresolved provider returns ok: true. #### Application guidance Treat `ok: false` as a warning for application feedback, not a runtime gate. ### `RouteConfig` `runtime`, `streaming`, and `tags` are reserved metadata and do not change execution today. Select deployment runtime with `build.targets`, select streaming through the endpoint or transport, and do not rely on tags for behavior. ```ts api-contract="@b4run/sdk#.:RouteConfig" export interface RouteConfig { readonly runtime?: "node" | "edge" readonly streaming?: boolean readonly tags?: readonly string[] } ``` **Fields: `@b4run/sdk#.:RouteConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly runtime` | `"node" \| "edge"` | no | Reserved; has no effect. | | `readonly streaming` | `boolean` | no | Reserved; has no effect. | | `readonly tags` | `readonly string[]` | no | Reserved; has no effect. | ### `defineMemory()` Declare the typed schema, kind, and namespace dimensions for a route's `memory.ts` module. `identity` is optional; semantic memory defaults its reconciliation identity elsewhere. ```ts api-contract="@b4run/sdk#.:defineMemory" export declare function defineMemory(def: { kind: DefinedMemory["kind"] scope: readonly MemoryScopeDimension[] schema: S identity?: readonly string[] }): DefinedMemory ``` ### Choosing a surface Application routes normally import the root. Use `/pure` for infrastructure that must avoid runtime dependencies. Use `/testing` in `*.scenario.ts` files; use `@b4run/testing` when a test needs a programmatic harness instead. Its detailed reference lands with the testing package page. ## Examples and related guides ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "Answer concisely.", }) ``` Continue with [Agents](/docs/agents), [Middleware](/docs/middleware), [Long-term Memory](/docs/memory/long-term), [Scenario Testing](/docs/testing), and [Agent Test Harness](/docs/testing-agents). --- ### @b4run/cli # @b4run/cli ## Use this when Most application developers use the `b4` command to develop, test, build, and serve an app. Import the package root only to embed the Node server. Use `/fetch` when an edge adapter supplies generated modules and the durable stores its routes need; it may also supply an already-constructed configuration, while omitted configuration uses runtime defaults. `/runtime` is low-level tooling; `/testing` is a deprecated compatibility alias. ## Install and import For command-only development, install the CLI as a development dependency: ```bash pnpm add -D @b4run/cli pnpm exec b4 dev ``` Install it as a runtime dependency when production code or a generated server imports the package: ```bash pnpm add @b4run/cli ``` ```ts import { serveRuntime } from "@b4run/cli" import { createRuntimeFetchHandler } from "@b4run/cli/fetch" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/cli` | node-only | not-claimed | application | supported | | `@b4run/cli/fetch` | edge-safe | not-claimed | integration | supported | | `@b4run/cli/runtime` | node-only | not-claimed | tooling | low-level | | `@b4run/cli/testing` | node-only | not-claimed | testing | supported | | `bin:b4` | node-only | n/a | tooling | supported | ## Public exports ### `@b4run/cli` | Export | Responsibility | |---|---| | `B4StaticModules` | Map generated route modules for runtime boot. | | `ServeRuntimeHandle` | Control an embedded server. | | `ServeRuntimeOptions` | Configure an embedded Node server. | | `StaticRouteModule` | Describe one generated route module. | | `config` | Re-export [`@b4run/core` configuration typing](/docs/api/core#b4runcore). | | `createProgram` | Construct the command program for custom I/O. | | `isExecutedAsMain` | Detect direct executable invocation. | | `loadStaticModules` | Load generated static route modules. | | `renderError` | Format a CLI-facing error. | | `run` | Run the command program with explicit arguments. | | `serveRuntime` | Start the production Node runtime server. | ### `@b4run/cli/fetch` | Export | Responsibility | |---|---| | `BootResolvedInstances` | Hold stores resolved before request handling. | | `B4StaticModules` | Map generated route modules. | | `RequestStores` | Supply request-scoped durable stores. | | `RuntimeBootFallbacks` | Define optional runtime boot fallbacks. | | `RuntimeEnv` | Re-export the [`@b4run/core` runtime environment type](/docs/api/core#b4runcore). | | `RuntimeFetchHandler` | Handle a web-standard runtime request. | | `StartRuntimeServerOptions` | Share runtime assembly inputs. | | `StaticRouteModule` | Describe one materialized route module. | | `StaticRouteModuleInput` | Describe a generated route-module input. | | `StaticToolModuleInput` | Describe a generated tool-module input. | | `StreamChunk` | Represent one runtime stream chunk. | | `buildStaticRouteModule` | Build one route module without filesystem discovery. | | `createRuntimeFetchHandler` | Assemble a web-standard request handler. | | `normalizeMiddlewareModule` | Normalize generated middleware exports. | | `normalizeThreadAccessModule` | Normalize generated thread-access policy exports. | | `readRuntimeEnv` | Re-export [`@b4run/core` environment lookup](/docs/api/core#b4runcore). | | `seedB4Config` | Re-export [`@b4run/core` static configuration seeding](/docs/api/core#b4runcore). | | `seedModelImporter` | Re-export the `@b4run/langchain` static importer seam; its deep reference is deferred. | | `seedRuntimeEnv` | Re-export [`@b4run/core` environment seeding](/docs/api/core#b4runcore). | ### `@b4run/cli/runtime` | Export | Responsibility | |---|---| | `B4ResumeEntry` | Describe one submitted interrupt decision. | | `normalizeThreadAccessResult` | Coerce a policy's return value into a decision the runtime can act on. | | `B4StaticModules` | Map generated route modules. | | `MaterializeResolvedRouteGraphOptions` | Configure route graph materialization. | | `PendingInterrupt` | Describe a pending human decision. | | `PendingInterruptSnapshot` | Snapshot pending decisions for a thread. | | `PermissionDecision` | Represent a human permission decision. | | `PreparedRouteModules` | Hold prepared modules for a route. | | `ResumeResolution` | Represent resolved resume input. | | `RuntimeFetchHandler` | Handle runtime fetch requests. | | `RuntimeRegistry` | Resolve materialized routes. | | `RuntimeRequestListener` | Handle Node HTTP requests. | | `SandboxManager` | Manage runtime sandboxes. | | `ServeRuntimeHandle` | Control a running server. | | `ServeRuntimeOptions` | Configure production server boot. | | `StartRuntimeServerOptions` | Configure low-level server assembly. | | `StaticRouteModule` | Describe a generated route module. | | `StaticRouteModuleInput` | Describe a route-module input. | | `StaticToolModuleInput` | Describe a tool-module input. | | `StreamChunk` | Represent one stream chunk. | | `__resetMaterializedAgentsForTests` | Reset LangChain materialization caches in tests. | | `__resetRouteLoadCachesForTests` | Reset route-loader caches in tests. | | `buildStaticRouteModule` | Build a static route module. | | `createRuntimeFetchHandler` | Assemble the Node fetch handler. | | `createRuntimeRegistry` | Construct a runtime route registry. | | `createRuntimeRequestListener` | Construct a Node request listener. | | `executeResolvedRoute` | Execute a resolved route. | | `invokeResolvedRoute` | Invoke a resolved route graph. | | `loadStaticModules` | Load generated route modules. | | `materializeResolvedRouteGraph` | Materialize a resolved graph. | | `normalizeMiddlewareModule` | Normalize middleware exports. | | `normalizeThreadAccessModule` | Normalize thread-access policy exports. | | `readPendingInterrupts` | Read pending human decisions. | | `resolveCheckpointer` | Resolve the configured checkpointer. | | `resolvePendingResume` | Resolve submitted interrupt decisions. | | `resolveSandboxManager` | Resolve the server sandbox manager. | | `resolveThreadsStore` | Resolve the configured thread store. | | `runMemoryCommand` | Run the low-level memory command implementation. | | `runTypegen` | Generate application route declarations. | | `seedPreparedRouteModules` | Seed prepared route modules. | | `serveRuntime` | Start the production runtime server. | | `startRuntimeServer` | Assemble and start the Node server. | | `streamResolvedRoute` | Stream a resolved route. | These exports exist for framework tooling and test harnesses. Application code should prefer root `serveRuntime` or the edge-safe `/fetch` entry. ### `@b4run/cli/testing` | Export | Responsibility | |---|---| | `expectError` | Deprecated alias of `@b4run/sdk/testing` `expectError`. | | `expectMeta` | Deprecated alias of `@b4run/sdk/testing` `expectMeta`. | | `expectOutput` | Deprecated alias of `@b4run/sdk/testing` `expectOutput`. | New scenario files should import these aliases from [`@b4run/sdk/testing`](/docs/api/sdk#b4runsdktesting). ### `bin:b4` `bin:b4` is the executable target, not a TypeScript subpath. It runs the `add`, `build`, `check`, `dev`, `docs`, `eval`, `inspect`, `memory`, `routes`, `run`, `start`, `test`, `typegen`, and `verify` commands. See the [CLI Reference](/docs/cli) for command syntax. ## Key contracts ### `serveRuntime()` Use this root export to embed B4.run's production Node server. It starts once, returns a close handle, and does not watch the filesystem. ```ts api-contract="@b4run/cli#.:serveRuntime" export declare function serveRuntime(opts: ServeRuntimeOptions): Promise ``` ```ts api-contract="@b4run/cli#.:ServeRuntimeOptions" export interface ServeRuntimeOptions { readonly appRoot: string readonly host?: string readonly port?: number readonly installSignalHandlers?: boolean readonly modules?: B4StaticModules readonly config?: B4Config readonly checkpointer?: BaseCheckpointSaver readonly threadsStore?: ThreadsStore readonly permissionsStore?: PermissionsStore | (() => Promise) readonly memoryStore?: () => Promise readonly middleware?: B4Middleware } ``` **Fields: `@b4run/cli#.:ServeRuntimeOptions`** | Field | Type | Required | Description | |---|---|---|---| | `readonly appRoot` | `string` | yes | Select the B4.run application root. | | `readonly host` | `string` | no | Override the listen host. | | `readonly port` | `number` | no | Override the listen port. | | `readonly installSignalHandlers` | `boolean` | no | Opt into SIGTERM and SIGINT shutdown handlers. | | `readonly modules` | `B4StaticModules` | no | Supply build-time generated route modules. | | `readonly config` | `B4Config` | no | Supply an already-constructed application configuration. | | `readonly checkpointer` | `BaseCheckpointSaver` | no | Supply the boot-resolved checkpointer. | | `readonly threadsStore` | `ThreadsStore` | no | Supply the boot-resolved thread store. | | `readonly permissionsStore` | `PermissionsStore \| (() => Promise)` | no | Supply a permissions store or its async factory. | | `readonly memoryStore` | `() => Promise` | no | Supply the lazy memory-store factory. | | `readonly middleware` | `B4Middleware` | no | Supply preloaded middleware. | #### Behavior contract `cli.serve.production-boot` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/cli/test/serve-runtime.test.ts","testNames":["boots without running typegen (never writes .b4 artifacts)"]}] */} serveRuntime starts without running type generation or writing .b4 artifacts. #### Behavior contract `cli.serve-runtime.port-precedence` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/cli/test/serve-runtime.test.ts","testNames":["empty PORT env resolves to the 8000 default (not a random port)","non-numeric PORT env resolves to the 8000 default","numeric PORT env is honored","explicit port always wins, including 0 for a random port"]}] */} serveRuntime uses an explicit port first, then a numeric PORT value, then 8000. Empty or non-numeric PORT values also fall back to 8000; an explicit 0 still requests a random port. ### Runtime ownership `serveRuntime()` starts once and does not watch files or run type generation at boot. The `/fetch` handler has no filesystem fallback for its module map or for applicable required stores, so an edge host must inject them. Configuration may be supplied already constructed; when omitted, runtime defaults apply. #### Behavior contract `cli.fetch.request-store-lifecycle` {/* api-behavior-authorities: [{"kind":"source-ast","file":"packages/cli/src/lib/dev/runtime-fetch-core.ts","selector":"CLOSE_DRAIN_DEADLINE_MS"},{"kind":"test-assertion","file":"packages/cli/test/request-stores.test.ts","testNames":["builds and disposes stores once per request, never reusing them"]},{"kind":"test-assertion","file":"packages/cli/test/request-stores.test.ts","testNames":["disposes only AFTER an SSE body finishes, not when fetch resolves"]},{"kind":"test-assertion","file":"packages/cli/test/request-stores.test.ts","testNames":["close() does not return while a store disposal is still in flight"]},{"kind":"test-assertion","file":"packages/cli/test/runtime-fetch-parity.test.ts","testNames":["close() with an entirely unread SSE body warns after the drain deadline and proceeds"]}] */} A requestStores factory creates and disposes stores per request. Disposal waits for an SSE body to finish. close() waits for in-flight disposal while its bounded shutdown drain remains open; after the 30-second default deadline it warns and proceeds. ## Examples and related guides ```ts import { serveRuntime } from "@b4run/cli" const server = await serveRuntime({ appRoot: process.cwd(), port: 8000 }) console.log(server.url) ``` Continue with [CLI Reference](/docs/cli), [Embed the Runtime](/docs/embedding), [Edge and Hono](/docs/deployment/edge), and [Deployment Options](/docs/deployment). --- ### @b4run/core # @b4run/core ## Use this when Route authors normally use [`@b4run/sdk`](/docs/api/sdk), not Core. Use `@b4run/core` when you build a B4.run integration around configuration, capabilities, static type generation, or route metadata. Use `/node` for filesystem discovery and compiler-backed extraction. Do not build application code on `/internal/compiler`. ## Install and import ```bash pnpm add @b4run/core ``` ```ts import { config, renderB4Types } from "@b4run/core" import { discoverRoutes } from "@b4run/core/node" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/core` | edge-safe | not-claimed | integration | low-level | | `@b4run/core/node` | node-only | not-claimed | integration | low-level | | `@b4run/core/internal/compiler` | node-only | not-claimed | internal | internal | Importing `/node` registers the disk-backed `b4.config.ts` loader. The root remains free of that filesystem-loader edge so a static runtime can seed configuration instead. ## Public exports ### `@b4run/core` | Export | Responsibility | |---|---| | `ThreadsStore` | Re-export the `@b4run/sqlite-storage` thread-store contract; its deep reference is deferred. | | `createAgentsMdMarker` | Create the AGENTS.md capability marker. | | `createMemoryMarker` | Create the typed-memory capability marker. | | `createMemoryMdMarker` | Create the memory.md capability marker. | | `MAX_MEMORY_BYTES` | Cap the size of a route `memory.md`. | | `RuntimeTodo` | Describe one runtime plan item. | | `createPlanningMarker` | Create the planning capability marker. | | `MAX_PLAN_BYTES` | Cap the size of a route `plan.md`. | | `createSkillsMarker` | Create the skills capability marker. | | `createSubagentsMarker` | Create the subagents capability marker. | | `createWorkspaceMarker` | Create the workspace capability marker. | | `BUILT_IN_TOOL_NAMES` | List built-in capability tool names. | | `MemorySupersedeDetail` | Describe a gated memory supersede. | | `SubagentGateRequest` | Describe a gated subagent operation. | | `gateMemorySupersede` | Gate a memory supersede operation. | | `gateSubagentOp` | Gate a subagent operation. | | `gateToolOp` | Gate a tool operation. | | `wrapToolWithApproval` | Wrap a tool with approval handling. | | `wrapToolWithConstraint` | Wrap a tool with argument constraints. | | `AppliedContribution` | Record an applied capability contribution. | | `ApplyResult` | Return contributions and capability errors. | | `CapabilityError` | Describe capability detection or load failure. | | `CapabilityRegistry` | Hold capability markers. | | `applyCapabilities` | Detect and load registered capabilities. | | `createCapabilityRegistry` | Construct a capability registry. | | `BrowseFilterLike` | Define a memory browse filter boundary. | | `BrowsePageLike` | Define a memory browse page boundary. | | `BrowseQueryLike` | Define a memory browse query boundary. | | `BrowseSortEntryLike` | Define a memory sort entry boundary. | | `BrowseSortFieldLike` | Name memory browse sort fields. | | `CapabilityContribution` | Describe capability-provided runtime features. | | `CapabilityMarker` | Define capability detection and loading. | | `CapabilityMarkerContext` | Provide capability marker context. | | `B4ToolDefinition` | Describe a contributed B4.run tool. | | `Embedder` | Define the memory embedding seam. | | `MarkerFs` | Define capability filesystem operations. | | `StaticMarkerFiles` | Describe bundled marker file bodies. | | `staticMarkerFs` | Serve bundled marker files without a filesystem. | | `MemoryContext` | Provide memory tool context. | | `MemoryKindLike` | Name memory kinds at the Core boundary. | | `MemoryRecordLike` | Describe a memory record at the Core boundary. | | `MemorySourceTypeLike` | Name memory source types. | | `MemoryStatusLike` | Name memory statuses. | | `MemoryStoreLike` | Define the runtime memory-store boundary. | | `MemoryWritesMode` | Configure runtime memory writes. | | `PromptFragment` | Describe capability prompt text. | | `StreamTransformer` | Transform runtime stream chunks. | | `StreamTransformerInput` | Describe a stream-transform input. | | `StreamTransformerOutput` | Describe a stream-transform output. | | `CreateWorkspaceFsOptions` | Configure workspace filesystem creation. | | `createWorkspaceFs` | Create a workspace filesystem facade. | | `B4ConfigLoader` | Define the configuration loader seam. | | `__clearB4ConfigCacheForTests` | Clear the config memo in tests. | | `loadB4Config` | Load or read a memoized app configuration. | | `registerConfigLoader` | Register a runtime-specific config loader. | | `seedB4Config` | Seed configuration for a static runtime. | | `config` | Preserve types for a B4.run config object. | | `isPrivateSegment` | Test whether a route segment is private. | | `isRouteGroupSegment` | Test whether a route segment is a group. | | `toRouteSegments` | Parse filesystem route segments. | | `RuntimeEnv` | Describe seeded runtime environment values. | | `__clearSeededRuntimeEnvForTests` | Clear seeded environment values in tests. | | `readRuntimeEnv` | Read process or seeded environment values. | | `seedRuntimeEnv` | Seed environment values for a static runtime. | | `ResolveStateFieldsOptions` | Configure state-field resolution. | | `resolveStateFields` | Normalize state defaults and reducers. | | `GuardedSubagentResult` | Represent a gated subagent result. | | `ResolveGuardedSubagentArgs` | Configure guarded subagent resolution. | | `resolveGuardedSubagent` | Resolve a subagent under policy. | | `ResolveSubagentRegistryArgs` | Configure subagent registry resolution. | | `dispatchableSubagents` | List subagents eligible for dispatch. | | `resolveSubagentRegistry` | Resolve local and conventional subagents. | | `DescriptorRouteIndex` | Index route descriptors for subagents. | | `ResolvedDelegationRule` | Represent a resolved delegation rule. | | `ResolvedSubagent` | Represent one resolved subagent. | | `ScopeInput` | Configure route tool scoping. | | `ToolOrigin` | Identify where a tool came from. | | `resolveToolScope` | Apply route tool policy. | | `toolOrigin` | Read a tool's origin. | | `renderB4Types` | Render the complete generated route module. | | `renderRouteTypes` | Render route path and parameter types. | | `renderScenarioTypes` | Render scenario map augmentation. | | `SCENARIO_TYPES_FILE` | Name the generated scenario declaration file. | | `RouteStateFields` | Describe generated state fields for one route. | | `renderStateTypes` | Render route state types. | | `renderToolTypes` | Render route tool types. | | `CorsConfig` | Configure cross-origin access to the runtime. | | `B4Config` | Configure a B4.run application. | | `DiscoveredB4App` | Describe a located B4.run application. | | `DiscoverRoutesOptions` | Configure route discovery. | | `ExtractedToolSchema` | Describe one extracted JSON tool schema. | | `ExtractedToolType` | Describe one extracted tool type. | | `FindB4AppOptions` | Configure application-root discovery. | | `JsonSchemaProperty` | Describe a JSON Schema property. | | `LoadB4ConfigOptions` | Select the application whose config is loaded. | | `LoadedB4Config` | Return config, root, and config path. | | `NormalizedRouteModule` | Describe a normalized route module. | | `ResolvedStateField` | Describe a normalized state field. | | `RouteDefinition` | Describe one discovered route. | | `RouteKind` | Re-export the [`@b4run/sdk` route-kind type](/docs/api/sdk#b4runsdk). | | `RouteManifest` | Describe all routes in an application. | | `RouteSegment` | Describe a static or dynamic route segment. | | `RouteToolSchemas` | Group extracted schemas by route. | | `RouteToolTypes` | Group extracted tool types by route. | | `StateFieldReducer` | Name a built-in state reducer. | ### `@b4run/core/node` | Export | Responsibility | |---|---| | `loadB4ConfigUncached` | Load `b4.config.ts` directly from disk. | | `registerNodeConfigLoader` | Register the disk-backed config loader. | | `registerTsxLoader` | Register TypeScript module loading. | | `discoverRoutes` | Discover filesystem routes. | | `assertB4RoutesDir` | Validate the routes directory. | | `findB4App` | Locate a B4.run application root. | | `nodeMarkerFs` | Provide Node filesystem operations to capability markers. | | `ExtractToolSchemasOptions` | Configure compiler-backed schema extraction. | | `extractToolSchemasForRoute` | Extract tool schemas for a route. | | `ExtractToolTypesOptions` | Configure compiler-backed type extraction. | | `extractToolTypesForRoute` | Extract tool types for a route. | ### `@b4run/core/internal/compiler` This discoverable subpath is internal. It has no application compatibility promise; the CLI owns its use during type generation. ## Key contracts ### `loadB4Config()` Use this low-level seam when an integration owns configuration loading. Most Node callers import `/node` to register disk loading; static runtimes seed config instead. ```ts api-contract="@b4run/core#.:loadB4Config" export declare function loadB4Config(options: LoadB4ConfigOptions): Promise ``` #### Behavior contract `core.load-config.failed-load-eviction` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/core/test/config-loader-seam.test.ts","testNames":["dispatches through the registered loader and memoizes its result","a seed survives an in-flight registered load rejecting after the seed lands"]}] */} loadB4Config memoizes per app root. A failed in-flight load is evicted only while that same promise remains cached, so a seed written during the load survives its later rejection. ### `resolveStateFields()` This low-level helper turns state defaults and optional reducer overrides into the normalized fields consumed by type generation and runtime state assembly. ```ts api-contract="@b4run/core#.:resolveStateFields" export declare function resolveStateFields( options: ResolveStateFieldsOptions, ): readonly ResolvedStateField[] ``` #### Behavior contract `core.state.reducer-resolution` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/core/test/resolve-state-fields.test.ts","testNames":["infers append reducer for array defaults","infers replace reducer for scalar defaults","reducer overrides take precedence","sorts fields alphabetically by name"]}] */} resolveStateFields infers append for array defaults and replace for scalar defaults, honors explicit reducer overrides, and sorts fields by name. ### Configuration ownership Use [Configuration Reference](/docs/configuration) for the full `B4Config` schema. The root loader is a runtime seam: Node callers import `/node` to register disk loading, while edge callers seed an already-constructed config. ## Examples and related guides ```ts import { config } from "@b4run/core" export default config({ appDir: "src/app", build: { targets: ["node"] }, }) ``` Continue with [Configuration Reference](/docs/configuration), [Routes](/docs/routes), [State](/docs/state), and [`b4:routes`](/docs/api/generated-routes). --- ### @b4run/ag-ui # @b4run/ag-ui ## Use this when Use this package when a web client speaks AG-UI and your B4.run route emits B4.run stream chunks. Most applications should start with [AG-UI and Web Clients](/docs/ag-ui); use this reference when you are wiring a custom transport or client adapter. ## Install and import ```bash pnpm add @b4run/ag-ui ``` ```ts import { fromRunAgentInput, toAguiEvents } from "@b4run/ag-ui" import { encodeAgUiSse } from "@b4run/ag-ui/sse" ``` In a React client, and only there, import the renderers from the `./react` subpath: ```tsx import { b4ActivityRenderers } from "@b4run/ag-ui/react" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/ag-ui` | edge-safe | not-claimed | integration | supported | | `@b4run/ag-ui/sse` | edge-safe | not-claimed | integration | supported | | `@b4run/ag-ui/react` | node-only | not-claimed | application | supported | | `@b4run/ag-ui/react/styles.css` | n/a | n/a | integration | supported | The adapter translates protocol data; it does not authenticate a caller or make client-supplied thread, run, state, tool, or context data authoritative. `@b4run/ag-ui/react` is the client-side entry: it ships ready-made CopilotKit renderers for B4.run's built-in orchestration activities, and React and `@copilotkit/react-core` are optional peer dependencies, so a server-only consumer that imports only the root or `/sse` entry installs neither. Its runtime is recorded as `node-only` because B4.run's edge guard requires an import graph free of unguarded Node globals, and React's own JSX runtime reaches for `process.env.NODE_ENV`. The entry is meant for browser bundles, where an application bundler substitutes that value as usual; it simply cannot carry B4.run's stricter edge-safe claim. ## Public exports ### `@b4run/ag-ui` | Export | Responsibility | |---|---| | `createCounterIdFactory` | Create deterministic IDs for tests and reproducible adapters. | | `createDefaultIdFactory` | Create unique runtime event IDs. | | `B4_PLAN_ACTIVITY_TYPE` | Identify B4.run plan activity snapshots as `b4.plan`. | | `B4_SUBAGENT_ACTIVITY_TYPE` | Identify B4.run subagent activity snapshots as `b4.subagent`. | | `B4PlanActivityContent` | Describe the complete public plan snapshot. | | `B4SubagentActivityContent` | Describe allowlisted subagent progress. | | `IdFactory` | Define event-ID generation. | | `B4Message` | Describe a normalized inbound message. | | `B4RunInput` | Describe normalized B4.run run input. | | `fromRunAgentInput` | Translate AG-UI run input to B4.run input. | | `B4InterruptEnvelope` | Describe a B4.run interrupt payload. | | `B4ResumeRequest` | Describe one resume decision. | | `AguiOutboundEvent` | Name events emitted by the outbound mapper. | | `ToAguiOptions` | Configure outbound event IDs. | | `toAguiEvents` | Translate a B4.run stream into AG-UI events. | | `B4AgentStreamChunk` | Describe accepted B4.run stream chunks. | | `RunContext` | Supply thread and run IDs for outbound events. | ### `@b4run/ag-ui/sse` | Export | Responsibility | |---|---| | `encodeAgUiSse` | Encode one AG-UI event as an SSE frame. | ### `@b4run/ag-ui/react` | Export | Responsibility | |---|---| | `b4ActivityRenderers` | Register both built-in B4.run activity renderers with CopilotKit's `renderActivityMessages`. | | `b4PlanActivityRenderer` | Register only the plan activity renderer. | | `b4SubagentActivityRenderer` | Register only the subagent activity renderer. | | `PlanActivityCard` | Present one plan snapshot from its `content`. | | `SubagentActivityCard` | Present one subagent snapshot from its `content`. | | `ActivityChecklist` | Present a todo list from `todos`, truncated at `limit`. | | `planActivityContentSchema` | Validate plan activity content in a custom renderer. | | `subagentActivityContentSchema` | Validate subagent activity content in a custom renderer. | | `SubagentActivityContentOutput` | Describe parsed subagent content, which admits an explicit `undefined` `todos`. | | `B4ActivityClassNames` | Name the per-part classes a card appends to its defaults (customization rung 2). | | `B4ActivityComponents` | Name the leaf slots (`TodoRow`, `ToolRow`) a card lets a consumer replace (customization rung 3). | | `B4TodoRowProps` | Describe the props passed to a replacement `TodoRow`. | | `B4ToolRowProps` | Describe the props passed to a replacement `ToolRow`. | | `cx` | Join a package default class with an optional consumer class (customization rung 4: what an ejected card imports in place of its internal `./parts.js` path). | `b4ActivityRenderers` is the drop-in default; the two individual renderers exist for clients that register only one of them or mix them with their own. The cards are plain React components and need no CopilotKit. `SubagentActivityContentOutput` is wider than the published `B4SubagentActivityContent` in exactly one way: this package compiles with `exactOptionalPropertyTypes` and zod does not, so a parsed value can carry an explicit `todos: undefined`. `ActivityChecklist`, `PlanActivityCard`, and `SubagentActivityCard` all accept optional `classNames` and `components` props built from these two types; see [`@b4run/ag-ui/react/styles.css`](#b4runag-uireactstylescss) below for rung 1 and the [package README](https://github.com/cacheplane/b4run/blob/main/packages/ag-ui/README.md#customizing-the-activity-cards) for a runnable example of every rung. ### `@b4run/ag-ui/react/styles.css` This subpath exposes the activity cards' default appearance as a stylesheet asset. It has no TypeScript export inventory, runtime compatibility classification, or purity claim; a bundler resolves it and it is never evaluated as JavaScript. Import it once, wherever your application imports its global CSS: ```ts import "@b4run/ag-ui/react/styles.css" ``` The sheet is optional — the cards render without it — and every rule that styles an element is scoped to the `b4-activity` prefix, so it cannot restyle the rest of an application; it additionally declares `--b4-activity-*` custom properties on `:root`. Restyle it by overriding its custom properties in your own CSS: `--b4-activity-surface`, `--b4-activity-border`, `--b4-activity-text`, `--b4-activity-muted`, `--b4-activity-running`, `--b4-activity-complete`, `--b4-activity-failed`, `--b4-activity-badge-bg`, `--b4-activity-radius`, `--b4-activity-gap`, `--b4-activity-font-size`, `--b4-activity-margin`, `--b4-activity-padding`, and `--b4-activity-header-weight`. `--b4-activity-badge-bg` defaults to `var(--b4-activity-border)`, so the depth badge follows the palette unless it is pointed elsewhere. Put the overrides in plain, unlayered CSS — a Tailwind `@theme` block is not a substitute, because token values declared there lose to this sheet. A dark palette applies under `prefers-color-scheme: dark`; setting `data-b4-theme="light"` or `data-b4-theme="dark"` on the root element pins one explicitly (the selectors match only `:root`, not an arbitrary ancestor). All three token blocks are wrapped in `:where()`, so they carry no specificity and an application's own `:root` override wins in every theme regardless of stylesheet order. ## Key contracts ### Activity identifiers and payloads ```ts api-contract="@b4run/ag-ui#.:B4_PLAN_ACTIVITY_TYPE" export declare const B4_PLAN_ACTIVITY_TYPE: "b4.plan" ``` ```ts api-contract="@b4run/ag-ui#.:B4_SUBAGENT_ACTIVITY_TYPE" export declare const B4_SUBAGENT_ACTIVITY_TYPE: "b4.subagent" ``` ```ts api-contract="@b4run/ag-ui#.:B4PlanActivityContent" export interface B4PlanActivityContent { readonly todos: ReadonlyArray<{ readonly content: string readonly status: "pending" | "in_progress" | "completed" }> } ``` ```ts api-contract="@b4run/ag-ui#.:B4SubagentActivityContent" export interface B4SubagentActivityContent { readonly name: string readonly depth: number readonly status: "running" | "completed" | "failed" readonly todos?: B4PlanActivityContent["todos"] readonly tools: ReadonlyArray<{ readonly name: string readonly status: "running" | "completed" | "incomplete" }> readonly totalToolCount: number readonly error?: string } ``` #### Behavior contract `ag-ui.activities.plan-snapshot` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/ag-ui/test/outbound.test.ts","testNames":["plan activity does not flush an open text message"]}] */} A valid plan update becomes a complete replacement snapshot with activity type `b4.plan` and the stable message ID `b4:plan:` without breaking an open assistant text message. #### Behavior contract `ag-ui.activities.subagent-privacy` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/ag-ui/test/activities.test.ts","testNames":["retains only the five newest tool summaries while counting each id once","completes once, marks running tools incomplete, and freezes terminal state","caps failure errors at 400 characters","consumes child messages and exposes only allowlisted public fields"]}] */} A subagent snapshot exposes allowlisted progress only: name, depth, status, optional todos, at most five tool name/status summaries, the total tool count, and an error capped at 400 characters. It never includes child prompts, prose, tool inputs, tool outputs, final answers, route IDs, call IDs, or raw runtime IDs. ### `ToAguiOptions` Supply an `IdFactory` when emitted IDs must be deterministic. Production integrations normally accept the default factory. ```ts api-contract="@b4run/ag-ui#.:ToAguiOptions" export interface ToAguiOptions { readonly idFactory?: IdFactory } ``` **Fields: `@b4run/ag-ui#.:ToAguiOptions`** | Field | Type | Required | Description | |---|---|---|---| | `readonly idFactory` | `IdFactory` | no | Override outbound event-ID generation. | ### Inbound and outbound calls ```ts api-contract="@b4run/ag-ui#.:B4RunInput" export interface B4RunInput { readonly messages: B4Message[] readonly resume?: B4ResumeRequest[] readonly raw: RunAgentInput } ``` **Fields: `@b4run/ag-ui#.:B4RunInput`** | Field | Type | Required | Description | |---|---|---|---| | `readonly messages` | `B4Message[]` | yes | Supply normalized messages. | | `readonly resume` | `B4ResumeRequest[]` | no | Supply normalized resume decisions. | | `readonly raw` | `RunAgentInput` | yes | Preserve the untouched AG-UI input. | ```ts api-contract="@b4run/ag-ui#.:RunContext" export interface RunContext { readonly threadId: string readonly runId: string } ``` ```ts api-contract="@b4run/ag-ui#.:fromRunAgentInput" export declare function fromRunAgentInput(input: RunAgentInput): B4RunInput ``` ```ts api-contract="@b4run/ag-ui#.:toAguiEvents" export declare function toAguiEvents( chunks: AsyncIterable, ctx: RunContext, options?: ToAguiOptions, ): AsyncGenerator ``` ```ts api-contract="@b4run/ag-ui#./sse:encodeAgUiSse" export declare function encodeAgUiSse(event: BaseEvent, accept?: string): string ``` #### Behavior contract `ag-ui.outbound.errors-as-events` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/ag-ui/test/outbound.test.ts","testNames":["upstream throw is emitted as RUN_ERROR, not thrown to the consumer"]}] */} toAguiEvents turns an upstream throw into a final `RUN_ERROR` event and closes an open text frame instead of throwing to the consumer. #### Behavior contract `ag-ui.inbound.lossless-input` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/ag-ui/test/inbound.test.ts","testNames":["maps user and assistant messages to B4.run messages","maps a resume array to B4.run resume requests","omits the resume property for an empty resume array","raw preserves the original input for tools/state/context access"]}] */} fromRunAgentInput maps supported messages and resume entries into B4.run shapes, omits resume for an empty array, and preserves the untouched AG-UI input under raw for tools, state, and context. ### Stream boundaries Unknown chunk kinds are ignored after closing an open text frame. A stream that ends without `done` still finishes successfully. Interrupts are accumulated in the `RUN_FINISHED` outcome; after an interrupt, later non-interrupt chunks are suppressed until the outcome. A malformed interrupt terminates with `RUN_ERROR`. `fromRunAgentInput()` normalizes messages and resume entries. It preserves the original input as `raw`, leaving tools, state, and context for application-owned validation and policy. ## Examples and related guides ```ts import { type B4AgentStreamChunk, toAguiEvents } from "@b4run/ag-ui" import { encodeAgUiSse } from "@b4run/ag-ui/sse" async function* aguiFrames(chunks: AsyncIterable) { for await (const event of toAguiEvents(chunks, { threadId: "thread-1", runId: "run-1" })) { yield encodeAgUiSse(event) } } ``` Continue with [AG-UI and Web Clients](/docs/ag-ui), [Embed the Runtime](/docs/embedding), and [Security Architecture](/docs/security-architecture). --- ### @b4run/memory # @b4run/memory ## Use this when Use this package when your application needs a standalone long-term-memory store or lower-level browse, namespace, ranking, and reconciliation utilities. Most B4.run routes should declare memory with `defineMemory()` and follow [Long-term Memory](/docs/memory/long-term). ## Install and import ```bash pnpm add @b4run/memory ``` ```ts import { sqliteMemoryStore } from "@b4run/memory" import { validateBrowseQuery } from "@b4run/memory/browse" import { serializeNamespace } from "@b4run/memory/namespace" import { approveWithReconcile } from "@b4run/memory/reconcile" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/memory` | node-only | not-claimed | application | supported | | `@b4run/memory/browse` | edge-safe | dependency-free | integration | supported | | `@b4run/memory/namespace` | edge-safe | not-claimed | integration | supported | | `@b4run/memory/reconcile` | edge-safe | not-claimed | integration | supported | The root includes the SQLite store. Import `/browse`, `/namespace`, or `/reconcile` directly when an edge integration needs only that contract. ## Public exports ### `@b4run/memory` | Export | Responsibility | |---|---| | `BrowseCursorPayload` | Describe decoded browse cursor metadata. | | `BrowseCursorValue` | Describe one cursor sort value. | | `BROWSE_CURSOR_VERSION` | Identify the cursor format. | | `browseCursorKey` | Build a cursor comparison key. | | `browseQueryFingerprint` | Fingerprint cursor-bound query inputs. | | `decodeBrowseCursor` | Decode and validate cursor structure. | | `encodeBrowseCursor` | Encode a browse cursor. | | `normalizeSetFilter` | Normalize a set-valued browse filter. | | `ResolvedBrowseSort` | Describe normalized browse ordering. | | `DEFAULT_BROWSE_ORDER` | Expose default browse ordering. | | `resolveBrowseOrder` | Normalize requested browse ordering. | | `namespacePrefixUpperBound` | Compute a half-open namespace-prefix bound. | | `utcDayAfter` | Compute the next UTC day. | | `utcDayStart` | Normalize a UTC day start. | | `BROWSE_DEFAULT_LIMIT` | Expose the default page size. | | `BROWSE_MAX_LIMIT` | Expose the recommended untrusted-boundary ceiling. | | `BROWSE_SORT_FIELDS` | List supported sort fields. | | `BrowseQueryError` | Report invalid browse input. | | `validateBrowseQuery` | Validate or reject browse input. | | `buildConsolidationPrompt` | Build a consolidation prompt. | | `buildReflectionPrompt` | Build a reflection prompt. | | `buildReflectionRecords` | Materialize reflection records. | | `buildReflectionWatermarkRecord` | Materialize a reflection watermark. | | `buildSummaryRecord` | Materialize a summary record. | | `ConsolidationBatch` | Describe one consolidation batch. | | `eventTimeOf` | Resolve a record's event time. | | `isoWeekKey` | Group an instant by ISO week. | | `parseConsolidationOutput` | Parse consolidation model output. | | `parseReflectionOutput` | Parse reflection model output. | | `ReflectionInput` | Describe reflection input. | | `ReflectionInsight` | Describe a parsed insight. | | `selectConsolidationBatches` | Select deterministic consolidation work. | | `selectReflectionInput` | Select deterministic reflection input. | | `fuseHybrid` | Fuse keyword and vector candidates. | | `rankKeywordCandidates` | Rank keyword candidates. | | `MemoryScopeTuple` | Describe ordered namespace dimensions. | | `parseNamespace` | Parse a serialized namespace. | | `routeNamespaceKey` | Normalize a route path for namespacing. | | `serializeNamespace` | Serialize namespace dimensions. | | `ApproveResult` | Describe reconciliation approval. | | `approveWithReconcile` | Approve a candidate with reconciliation. | | `classifyWrite` | Classify an add, update, or supersede. | | `WriteOp` | Name a classified write operation. | | `WritePolicy` | Describe per-kind write discipline. | | `writePolicyFor` | Resolve per-kind write discipline. | | `DEFAULT_CANDIDATE_POOL` | Expose default ranking candidate count. | | `DEFAULT_RECALL_WEIGHTS` | Expose default recall weights. | | `DEFAULT_RECENCY_HALF_LIFE_MS` | Expose default recency decay. | | `idf` | Compute inverse document frequency. | | `RecallRankingOptions` | Configure recall ranking. | | `RecallWeights` | Configure ranking weights. | | `recencyDecay` | Compute recency contribution. | | `scoreMemory` | Score one memory candidate. | | `sqliteMemoryStore` | Create a SQLite memory store. | | `tokenize` | Tokenize text for keyword recall. | | `BrowseFilter` | Describe one browse filter. | | `BrowsePage` | Describe a browse page. | | `BrowseQuery` | Describe browse input. | | `BrowseSortEntry` | Describe one sort entry. | | `BrowseSortField` | Name a sortable field. | | `MemoryKind` | Name a memory kind. | | `MemoryQuery` | Describe recall input. | | `MemoryRecord` | Describe a stored memory. | | `MemorySource` | Describe record provenance. | | `MemoryStats` | Describe store statistics. | | `MemoryStatus` | Name a record status. | | `MemoryStore` | Define the memory-store contract. | | `VectorRankingOptions` | Configure vector recall. | | `cosineSimilarity` | Compare vector direction. | | `DEFAULT_RRF_K` | Expose the reciprocal-rank constant. | | `DEFAULT_VECTOR_K` | Expose the default vector candidate count. | | `fuseRRF` | Fuse ranked lists by reciprocal rank. | | `RankedList` | Describe a ranked list. | ### `@b4run/memory/browse` | Export | Responsibility | |---|---| | `BrowseCursorPayload` | Describe decoded cursor metadata. | | `BrowseCursorValue` | Describe one cursor sort value. | | `BROWSE_CURSOR_VERSION` | Identify the cursor format. | | `browseCursorKey` | Build a cursor comparison key. | | `browseQueryFingerprint` | Bind a cursor to query inputs. | | `decodeBrowseCursor` | Decode a cursor. | | `encodeBrowseCursor` | Encode a cursor. | | `normalizeSetFilter` | Normalize a set filter. | | `ResolvedBrowseSort` | Describe normalized ordering. | | `DEFAULT_BROWSE_ORDER` | Expose default ordering. | | `resolveBrowseOrder` | Normalize ordering. | | `namespacePrefixUpperBound` | Compute a namespace-prefix bound. | | `utcDayAfter` | Compute the next UTC day. | | `utcDayStart` | Normalize a UTC day start. | | `BROWSE_DEFAULT_LIMIT` | Expose the default limit. | | `BROWSE_MAX_LIMIT` | Expose the recommended untrusted-boundary ceiling. | | `BROWSE_SORT_FIELDS` | List sortable fields. | | `BrowseQueryError` | Report invalid input. | | `validateBrowseQuery` | Validate or reject browse input. | | `BrowseFilter` | Describe one filter. | | `BrowsePage` | Describe a page. | | `BrowseQuery` | Describe browse input. | | `BrowseSortEntry` | Describe one sort entry. | | `BrowseSortField` | Name a sortable field. | | `MemoryKind` | Name a memory kind. | | `MemoryRecord` | Describe a memory row. | | `MemorySource` | Describe provenance. | | `MemoryStatus` | Name record status. | ### `@b4run/memory/namespace` | Export | Responsibility | |---|---| | `MemoryScopeTuple` | Describe ordered namespace dimensions. | | `parseNamespace` | Parse a serialized namespace. | | `routeNamespaceKey` | Normalize a route path. | | `serializeNamespace` | Serialize dimensions. | ### `@b4run/memory/reconcile` | Export | Responsibility | |---|---| | `ApproveResult` | Describe approval output. | | `approveWithReconcile` | Approve with deterministic reconciliation. | | `classifyWrite` | Classify a write. | | `WriteOp` | Name a write operation. | | `WritePolicy` | Describe write discipline. | | `writePolicyFor` | Resolve write discipline. | ## Key contracts ### `MemoryScopeTuple` ```ts api-contract="@b4run/memory#./namespace:MemoryScopeTuple" export interface MemoryScopeTuple { readonly workspace?: string readonly route?: string readonly tenant?: string readonly user?: string readonly agent?: string } ``` **Fields: `@b4run/memory#./namespace:MemoryScopeTuple`** | Field | Type | Required | Description | |---|---|---|---| | `readonly workspace` | `string` | no | Scope by application workspace. | | `readonly route` | `string` | no | Scope by route. | | `readonly tenant` | `string` | no | Scope by tenant. | | `readonly user` | `string` | no | Scope by user. | | `readonly agent` | `string` | no | Scope by agent. | ### Store and query shapes ```ts api-contract="@b4run/memory#.:MemoryRecord" export interface MemoryRecord { readonly id: string readonly kind: MemoryKind readonly namespace: string readonly content: string readonly data: Record readonly source: MemorySource readonly confidence: number readonly tags: readonly string[] readonly status: MemoryStatus readonly supersedes?: readonly string[] readonly createdAt: string readonly updatedAt: string readonly effectiveAt?: string readonly expiresAt?: string } ``` ```ts api-contract="@b4run/memory#.:MemoryQuery" export interface MemoryQuery { readonly namespace: string readonly query?: string readonly kind?: MemoryKind readonly tags?: readonly string[] readonly status?: MemoryStatus readonly limit?: number readonly now?: string readonly since?: string readonly until?: string readonly queryEmbedding?: Float32Array readonly embedderId?: string readonly vector?: VectorRankingOptions } ``` ```ts api-contract="@b4run/memory#.:BrowsePage" export interface BrowsePage { readonly records: readonly MemoryRecord[] readonly total: number readonly continuation: string | null } ``` ```ts api-contract="@b4run/memory#.:BrowseQuery" export interface BrowseQuery { readonly namespacePrefix?: string readonly namespace?: string readonly status?: MemoryStatus | readonly MemoryStatus[] readonly kind?: MemoryKind | readonly MemoryKind[] readonly sourceType?: MemorySource["type"] readonly limit?: number readonly offset?: number readonly since?: string readonly until?: string readonly now?: string readonly filters?: readonly BrowseFilter[] readonly orderBy?: readonly BrowseSortEntry[] readonly cursor?: string } ``` ```ts api-contract="@b4run/memory#.:MemoryStore" export interface MemoryStore { put( rec: MemoryRecord, opts?: { readonly embedding?: Float32Array; readonly embeddingModel?: string }, ): Promise get(id: string): Promise search(q: MemoryQuery): Promise update(id: string, patch: Partial): Promise supersede(id: string, bySupersedingId: string): Promise delete(id: string): Promise listCandidates(namespacePrefix: string): Promise browse(q?: BrowseQuery): Promise stats(opts?: { readonly namespacePrefix?: string }): Promise prune(opts: { readonly now: string readonly namespacePrefix?: string readonly cap?: number }): Promise<{ readonly deletedExpired: number; readonly deletedOverCap: number }> } ``` ```ts api-contract="@b4run/memory#./namespace:serializeNamespace" export declare function serializeNamespace(tuple: MemoryScopeTuple): string ``` ```ts api-contract="@b4run/memory#./reconcile:approveWithReconcile" export declare function approveWithReconcile( store: MemoryStore, id: string, opts: { readonly identityKeys: readonly string[]; readonly now: string }, ): Promise ``` #### Behavior contract `memory.namespace.stable-encoding` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/memory/test/namespace.test.ts","testNames":["serializes a scope tuple with stable key order","throws on an empty tuple (fail-closed)","round-trips encoded values containing | = %"]}] */} serializeNamespace emits dimensions in a stable order, escapes reserved delimiters reversibly, and rejects an empty scope. ### Trust boundaries `parseNamespace()` is a parser, not an authorization check: it ignores unknown and malformed parts. Browse cursors detect query mismatches but are not authenticated tokens. At an untrusted boundary, the server must own the namespace and enforce an application limit no larger than `BROWSE_MAX_LIMIT`. Call `validateBrowseQuery(query, { maxLimit: BROWSE_MAX_LIMIT })` at that boundary. Stores do not enforce the 1,000-row ceiling automatically. SQLite stores memory rows—including content, data, source, and tags—as plaintext. A namespace organizes records; it is not a security boundary. Protect the database file and its path with application and infrastructure access controls. #### Behavior contract `memory.browse.pure-subpath` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/memory/test/browse-contract.test.ts","testNames":["reaches nothing outside the pure browse sources"]}] */} The /browse entry reaches only the pure browse modules and no external package, so it does not pull node:sqlite into edge or browser bundles. #### Behavior contract `memory.write-policy` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/memory/test/write-policy.test.ts","testNames":["semantic reconciles","episodic appends","reflection appends (insights accumulate)","procedural still throws a not-yet-wired error","the low-level store accepts a typed procedural record"]},{"kind":"test-assertion","file":"packages/core/test/memory-capability-episodic.test.ts","testNames":["procedural kind returns a not-yet-wired tool error with zero store writes"]}] */} `writePolicyFor()` selects reconciliation for semantic memory and append behavior for episodic and reflection memory; it throws for procedural memory because that reconciliation policy is not implemented. Low-level `MemoryStore` implementations can store typed procedural records, while the generated `remember` tool returns a not-yet-wired rejection without throwing or writing. #### Approval caveats `approveWithReconcile()` uses a non-transactional read-classify-write sequence and scans at most 10,000 active rows. Semantic memory reconciles; episodic and reflection memory append. ## Examples and related guides ```ts import { sqliteMemoryStore } from "@b4run/memory" const store = sqliteMemoryStore({ path: ".b4/memory.sqlite" }) const records = await store.search({ namespace: "workspace=acme", query: "shipping" }) ``` Continue with [Long-term Memory](/docs/memory/long-term), [Recall and Retrieval](/docs/memory/retrieval), [Browse and Manage Memory](/docs/memory/browse), and [Persistence and Tenancy](/docs/persistence). --- ### @b4run/memory-pgvector # @b4run/memory-pgvector ## Use this when Use this package when multiple B4.run application instances need shared long-term memory with Postgres and pgvector. Keep SQLite for local or single-instance use; choose this backend when shared durability and vector retrieval justify operating Postgres. ## Install and import ```bash pnpm add @b4run/memory-pgvector pg ``` ```ts import { pgvectorMemoryStore } from "@b4run/memory-pgvector" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/memory-pgvector` | node-only | not-claimed | application | supported | The store uses `pg`, initializes the pgvector extension and its tables lazily, and requires database credentials with the needed DDL and extension privileges. ## Public exports ### `@b4run/memory-pgvector` | Export | Responsibility | |---|---| | `PgvectorMemoryStore` | Extend `MemoryStore` with pool lifecycle. | | `pgvectorMemoryStore` | Create a Postgres and pgvector memory store. | | `assertIdentifier` | Reject unsafe SQL identifiers. | | `initSchema` | Initialize pgvector storage schema. | | `vectorColumnDef` | Select vector storage and operator class by dimensions. | ## Key contracts ### `PgvectorMemoryStore` ```ts api-contract="@b4run/memory-pgvector#.:PgvectorMemoryStore" export interface PgvectorMemoryStore extends MemoryStore { close(): Promise } ``` An injected pool remains caller-owned: `close()` is a no-op, and the caller owns pool error handling. A store-created pool receives an error listener and is ended by `close()`. ```ts api-contract="@b4run/memory-pgvector#.:pgvectorMemoryStore" export declare function pgvectorMemoryStore(opts: { /** Postgres connection string; used to build an owned pool. */ connectionString?: string /** An existing pool to use instead of building one from `connectionString`. */ pool?: Pool /** Embedding dimensions (≤2000 → `vector`, ≤4000 → `halfvec`). */ dimensions: number /** HNSW index/search tuning; all fields defaulted. */ index?: { m?: number; efConstruction?: number; efSearch?: number } /** Postgres schema to place tables in. */ schema?: string /** Table name prefix (isolates multiple stores in one database). */ tablePrefix?: string /** Recall ranking tuning; all fields defaulted. See @b4run/memory score.ts. */ recall?: RecallRankingOptions /** Store-level hybrid tuning; used when a query omits `vector`. All fields defaulted. */ vector?: VectorRankingOptions }): PgvectorMemoryStore ``` #### Behavior contract `memory-pgvector.schema.identifier-validation` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/memory-pgvector/test/schema.test.ts","testNames":["rejects identifiers with unsafe characters"]}] */} pgvector schema and table-prefix identifiers reject unsafe characters before B4.run interpolates them into DDL. #### Behavior contract `memory-pgvector.dimension-branches` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/memory-pgvector/test/schema.test.ts","testNames":["dims ≤ 2000 → plain vector + vector_cosine_ops","2000 < dims ≤ 4000 → halfvec + halfvec_cosine_ops (text-embedding-3-large)","dims > 4000 → throws a clear error naming the ceiling","non-positive/non-integer dims throw","validates dimensions at construction time"]}] */} The store rejects invalid dimensions at construction: 1–2000 use vector cosine indexes, 2001–4000 use halfvec cosine indexes, and larger, nonpositive, or noninteger values throw. #### Behavior contract `memory-pgvector.update-preserves-embedding` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/memory-pgvector/test/pgvector-integration.test.ts","testNames":["halfvec update preserves the stored embedding without recomputing changed content"]}] */} `update()` preserves the row's stored embedding. Content or data updates do not recompute that embedding, so after changing semantic content, compute a replacement and call `put(updatedRecord, { embedding, embeddingModel })` to avoid stale vector results. ### Initialization and retrieval Initialization is memoized per store instance. `CREATE IF NOT EXISTS` does not migrate an existing vector dimension or HNSW tuning, so treat those as schema decisions. Dimensions up to 2,000 use `vector`; dimensions up to 4,000 use `halfvec`; larger or non-positive dimensions fail during construction. Both `queryEmbedding` and `embedderId` are required to activate hybrid retrieval. Keyword and vector candidates are fused in application code. `schema` and `tablePrefix` organize tables; they are not tenant authorization boundaries. Stored memory is plaintext unless your database and infrastructure provide encryption. ## Examples and related guides ```ts import { pgvectorMemoryStore } from "@b4run/memory-pgvector" const connectionString = process.env.DATABASE_URL if (!connectionString) { throw new Error("DATABASE_URL is required") } const store = pgvectorMemoryStore({ connectionString, dimensions: 1536, tablePrefix: "support_memory", }) try { await store.search({ namespace: "workspace=acme", query: "shipping" }) } finally { await store.close() } ``` Continue with [Long-term Memory](/docs/memory/long-term), [Recall and Retrieval](/docs/memory/retrieval), and [Persistence and Tenancy](/docs/persistence). --- ### @b4run/postgres-storage # @b4run/postgres-storage ## Use this when Use this package when a deployed B4.run application needs shared durable checkpoints, Agent Protocol threads, and permission grants across instances. Each permissions-store instance caches grants and observes another instance's writes after its next `load()`. The root accepts a structural pool for Node or edge hosts; `/node` adds `pg` connection-string convenience. ## Install and import ```bash pnpm add @b4run/postgres-storage pg ``` ```ts import { createPostgresThreadsStore } from "@b4run/postgres-storage" import { postgresCheckpointer } from "@b4run/postgres-storage/node" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/postgres-storage` | edge-safe | not-claimed | application | supported | | `@b4run/postgres-storage/node` | node-only | not-claimed | application | supported | The root's edge-safe classification covers its import graph. Local workerd plus a Neon WebSocket pool is tested; it is not a claim about every edge provider or a live Cloudflare deployment. ## Public exports ### `@b4run/postgres-storage` | Export | Responsibility | |---|---| | `PostgresCheckpointerOptions` | Configure the checkpointer. | | `B4PostgresSaver` | Implement LangGraph checkpoint persistence. | | `postgresCheckpointer` | Create a Postgres checkpointer. | | `PostgresStoreOptions` | Configure shared connection and table naming. | | `PostgresPermissionsStore` | Define the durable permissions store. | | `PostgresPermissionsStoreOptions` | Configure permissions storage and policy. | | `createPostgresPermissionsStore` | Create a permissions store. | | `assertIdentifier` | Reject unsafe SQL identifiers. | | `DEFAULT_SCHEMA` | Expose the default schema. | | `DEFAULT_TABLE_PREFIX` | Expose the default table prefix. | | `SqlClient` | Define the structural SQL client. | | `SqlPool` | Define the structural SQL pool. | | `SqlResult` | Describe a structural query result. | | `CreateThreadInput` | Describe thread creation. | | `PostgresThreadsStore` | Define a durable threads store. | | `PostgresThreadsStoreOptions` | Configure thread storage. | | `Thread` | Describe an Agent Protocol thread. | | `ThreadStatus` | Name thread runtime status. | | `ThreadsStore` | Define the threads-store contract. | | `createPostgresThreadsStore` | Create a threads store. | ### `@b4run/postgres-storage/node` The `/node` entry also re-exports every root export. The repeated rows below establish that subpath's ownership; its three factories use the local connection-string-aware implementations. | Export | Responsibility | |---|---| | `NodePostgresStoreOptions` | Add connection-string convenience. | | `NodePostgresPermissionsStoreOptions` | Add connection-string convenience for permissions. | | `PostgresCheckpointerOptions` | Re-export root checkpointer options. | | `B4PostgresSaver` | Re-export the saver class. | | `postgresCheckpointer` | Create a Node checkpointer. | | `PostgresStoreOptions` | Re-export root store options. | | `PostgresPermissionsStore` | Re-export the permissions store type. | | `PostgresPermissionsStoreOptions` | Re-export permissions options. | | `createPostgresPermissionsStore` | Create a Node permissions store. | | `assertIdentifier` | Re-export identifier validation. | | `DEFAULT_SCHEMA` | Re-export the default schema. | | `DEFAULT_TABLE_PREFIX` | Re-export the default table prefix. | | `SqlClient` | Re-export the SQL client contract. | | `SqlPool` | Re-export the SQL pool contract. | | `SqlResult` | Re-export the SQL result contract. | | `CreateThreadInput` | Re-export thread creation input. | | `PostgresThreadsStore` | Re-export the threads store type. | | `PostgresThreadsStoreOptions` | Re-export thread options. | | `Thread` | Re-export the thread type. | | `ThreadStatus` | Re-export thread status. | | `ThreadsStore` | Re-export the threads-store contract. | | `createPostgresThreadsStore` | Create a Node threads store. | ## Key contracts ### `PostgresStoreOptions` ```ts api-contract="@b4run/postgres-storage#.:PostgresStoreOptions" export interface PostgresStoreOptions { readonly pool?: SqlPool readonly ownsPool?: boolean readonly assumeMigrated?: boolean readonly schema?: string readonly tablePrefix?: string } ``` **Fields: `@b4run/postgres-storage#.:PostgresStoreOptions`** | Field | Type | Required | Description | |---|---|---|---| | `readonly pool` | `SqlPool` | no | Supply the pool used by every store call. | | `readonly ownsPool` | `boolean` | no | End the supplied pool when this store closes. | | `readonly assumeMigrated` | `boolean` | no | Skip this instance's migration pass. | | `readonly schema` | `string` | no | Select the Postgres schema. | | `readonly tablePrefix` | `string` | no | Prefix this application's tables. | Although `pool` is optional in the shared type, root factories fail without one. Use `/node` when `connectionString` or standard `pg` environment defaults should create an owned pool. ```ts api-contract="@b4run/postgres-storage#.:PostgresPermissionsStoreOptions" export interface PostgresPermissionsStoreOptions extends PostgresStoreOptions { readonly config?: PermissionsFile readonly mode?: PermissionMode } ``` ```ts api-contract="@b4run/postgres-storage#.:postgresCheckpointer" export declare function postgresCheckpointer( options?: PostgresCheckpointerOptions, ): B4PostgresSaver ``` ```ts api-contract="@b4run/postgres-storage#.:createPostgresThreadsStore" export declare function createPostgresThreadsStore( options?: PostgresThreadsStoreOptions, ): PostgresThreadsStore ``` ```ts api-contract="@b4run/postgres-storage#.:createPostgresPermissionsStore" export declare function createPostgresPermissionsStore( options?: PostgresPermissionsStoreOptions, ): PostgresPermissionsStore ``` ```ts api-contract="@b4run/postgres-storage#./node:NodePostgresStoreOptions" export interface NodePostgresStoreOptions extends PostgresStoreOptions { readonly connectionString?: string } ``` ```ts api-contract="@b4run/postgres-storage#./node:NodePostgresPermissionsStoreOptions" export interface NodePostgresPermissionsStoreOptions extends PostgresPermissionsStoreOptions { readonly connectionString?: string } ``` ```ts api-contract="@b4run/postgres-storage#./node:postgresCheckpointer" export declare function postgresCheckpointer( options?: NodePostgresStoreOptions, ): B4PostgresSaver ``` ```ts api-contract="@b4run/postgres-storage#./node:createPostgresThreadsStore" export declare function createPostgresThreadsStore( options?: NodePostgresStoreOptions, ): PostgresThreadsStore ``` ```ts api-contract="@b4run/postgres-storage#./node:createPostgresPermissionsStore" export declare function createPostgresPermissionsStore( options?: NodePostgresPermissionsStoreOptions, ): PostgresPermissionsStore ``` #### Behavior contract `postgres-storage.migration.instance-scoped` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/postgres-storage/test/assume-migrated.test.ts","testNames":["skips the threads migration pass entirely","still migrates — under the advisory lock — when unset"]}] */} Migration memoization belongs to each store instance; assumeMigrated skips that instance's migration pass, while an unflagged instance begins a locked transaction. #### Behavior contract `postgres-storage.entry-split` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/postgres-storage/test/edge-bundle.test.ts","testNames":["links the main entry with no node builtins","negative control: the node entry does NOT link"]}] */} The main entry links without Node built-ins for edge pools, while /node deliberately fails an edge bundle because it imports pg for connectionString convenience. ### Migration and pool ownership Migration state is instance-scoped. In a per-request host, migrate an unflagged cold set of all three components, then set `assumeMigrated` only for the same database, schema, prefix, component, and current package schema. A wrong assertion fails on the first query. Checkpoint, thread, and permission migrations are separate. Another process sees a durable permission grant only after that store calls `load()`. When you construct a custom permissions store, pass the resolved `mode` and config explicitly; B4.run does not overlay sibling configuration onto an already-created store. Do not mark every store as owning one shared pool. An injected pool defaults to caller-owned and receives no error listener from B4.run; setting `ownsPool: true` makes that store end it. A `/node` factory-created pool is owned, monitored, and closed by that store. Schemas, table prefixes, and thread IDs organize data; they are not tenant authorization boundaries. Rows are plaintext unless your database and infrastructure provide encryption. ## Examples and related guides ```ts import { Pool } from "pg" import { createPostgresPermissionsStore, createPostgresThreadsStore, postgresCheckpointer, } from "@b4run/postgres-storage" const pool = new Pool({ connectionString: process.env.DATABASE_URL }) pool.on("error", (error) => console.warn("Postgres pool error", error)) const checkpointer = postgresCheckpointer({ pool }) const threadsStore = createPostgresThreadsStore({ pool }) const permissionsStore = createPostgresPermissionsStore({ pool }) await Promise.all([checkpointer.ready(), threadsStore.ready(), permissionsStore.ready()]) // During application shutdown: await Promise.all([checkpointer.close(), threadsStore.close(), permissionsStore.close()]) await pool.end() ``` Continue with [Persistence and Tenancy](/docs/persistence), [Production Topology](/docs/production-topology), and [Edge and Hono](/docs/deployment/edge). --- ### @b4run/testing # @b4run/testing ## Use this when Start here for programmatic tests of B4.run agents, tools, middleware, workspaces, stores, and Agent Protocol behavior. Use [`@b4run/sdk/testing`](/docs/api/sdk#b4runsdktesting) instead when authoring route-scoped scenario files for `b4 test`. ## Install and import ```bash pnpm add -D @b4run/testing vitest ``` ```ts import { createAgentHarness, expectFinalMessage, script } from "@b4run/testing" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/testing` | node-only | not-claimed | testing | supported | `createAgentHarness()` temporarily changes process-wide `OPENAI_BASE_URL` and `OPENAI_API_KEY`, resets runtime caches, and restores them on awaited close. Do not run concurrent harnesses in one process. ## Public exports ### `@b4run/testing` | Export | Responsibility | |---|---| | `Aimock` | Control an aimock server. | | `createAimock` | Start an aimock server. | | `runCheckpointerConformance` | Register checkpointer conformance tests. | | `fakeEmbedder` | Create deterministic test embeddings. | | `AimockFixture` | Describe one model fixture. | | `AimockResponse` | Describe a fixture response. | | `AimockToolCall` | Describe a fixture tool call. | | `FixtureSet` | Collect aimock fixtures. | | `ScriptBuilder` | Build fixtures fluently. | | `script` | Start a fixture script. | | `loadFixtures` | Load fixtures from disk. | | `writeFixtures` | Write fixtures to disk. | | `AgentHarness` | Drive an agent route in-process. | | `AgentHarnessOptions` | Configure an agent harness. | | `createAgentHarness` | Create an agent harness. | | `ThreadAccessCheckSpec` | Describe one thread-access case to exercise. | | `ThreadAccessHarness` | Drive a thread access policy in-process. | | `createThreadAccessHarness` | Create a thread access harness. | | `AgentProtocolInjector` | Drive Agent Protocol requests in-process. | | `createAgentProtocolInjector` | Create an Agent Protocol injector. | | `InjectResult` | Describe an injected response. | | `expectFinalMessage` | Assert final output. | | `expectInterrupt` | Assert an interrupt. | | `expectNoInterrupt` | Assert no interrupt. | | `expectNoToolErrors` | Assert successful tool results. | | `expectOffloaded` | Assert result offloading. | | `expectPlan` | Assert plan state. | | `expectState` | Assert route state. | | `expectStreamedTokens` | Assert token streaming. | | `expectSubagent` | Assert subagent behavior. | | `expectSystemPrompt` | Assert the effective system prompt. | | `expectToolCalled` | Assert a tool call. | | `expectToolSequence` | Assert tool-call order. | | `InterruptInfo` | Describe a collected interrupt. | | `SubagentEvent` | Describe a subagent event. | | `SubagentRun` | Describe a collected subagent run. | | `Todo` | Describe a collected plan item. | | `seedMemory` | Seed a memory store for a test. | | `runMemoryStoreConformance` | Register memory-store conformance tests. | | `createMiddlewareHarness` | Create a middleware harness. | | `MiddlewareHarness` | Drive middleware with real request shapes. | | `PermissionsStoreInit` | Configure permissions conformance setup. | | `runPermissionsStoreConformance` | Register permissions-store conformance tests. | | `RecordOptions` | Configure the standalone recorder. | | `record` | Spawn the aimock recording CLI. | | `AgentRunResult` | Describe one collected agent run. | | `collectRunResult` | Collect a runtime stream. | | `deriveToolResults` | Correlate tool results with calls. | | `ObservedToolCall` | Describe an observed tool call. | | `ObservedToolResult` | Describe an observed tool result. | | `createSubprocessApp` | Start a real B4.run dev subprocess. | | `SubprocessApp` | Control a B4.run subprocess. | | `runThreadsStoreConformance` | Register threads-store conformance tests. | | `createToolHarness` | Create a tool harness. | | `ToolHarness` | Invoke a tool with B4.run context. | | `ToolHarnessOptions` | Configure a tool harness. | | `createWorkspaceHarness` | Create a temporary workspace harness. | | `WorkspaceHarness` | Control a test workspace. | | `WorkspaceHarnessOptions` | Configure workspace permissions. | ## Key contracts ### `AgentHarnessOptions` ```ts api-contract="@b4run/testing#.:AgentHarnessOptions" export interface AgentHarnessOptions { readonly appRoot: string readonly route: string readonly fixtures?: FixtureSet readonly live?: boolean readonly record?: boolean readonly recordUpstream?: string } ``` **Fields: `@b4run/testing#.:AgentHarnessOptions`** | Field | Type | Required | Description | |---|---|---|---| | `readonly appRoot` | `string` | yes | Select the B4.run application. | | `readonly route` | `string` | yes | Select the route key. | | `readonly fixtures` | `FixtureSet` | no | Seed mock responses. | | `readonly live` | `boolean` | no | Proxy to a live OpenAI endpoint. | | `readonly record` | `boolean` | no | Capture upstream traffic for replay. | | `readonly recordUpstream` | `string` | no | Override the record-mode upstream. | ```ts api-contract="@b4run/testing#.:AgentHarness" export interface AgentHarness { readonly baseUrl: string run(opts: { input: string; fixtures?: FixtureSet | ScriptBuilder }): Promise resume(opts: { resume: readonly B4ResumeEntry[] fixtures?: FixtureSet | ScriptBuilder }): Promise reset(): void close(): Promise [Symbol.asyncDispose](): Promise getRecordedFixtures(): FixtureSet } ``` ```ts api-contract="@b4run/testing#.:createAgentHarness" export declare function createAgentHarness(options: AgentHarnessOptions): Promise ``` ```ts api-contract="@b4run/testing#.:ScriptBuilder" export interface ScriptBuilder { user(text: string): ScriptBuilder callsTool(name: string, args: Record, opts?: { id?: string }): ScriptBuilder replies(content: string): ScriptBuilder build(): FixtureSet } ``` ```ts api-contract="@b4run/testing#.:writeFixtures" export declare function writeFixtures(path: string, fixtures: FixtureSet | ScriptBuilder): void ``` ```ts api-contract="@b4run/testing#.:loadFixtures" export declare function loadFixtures(path: string): FixtureSet ``` ```ts api-contract="@b4run/testing#.:runMemoryStoreConformance" export declare function runMemoryStoreConformance(opts: { readonly name: string readonly makeStore: () => Promise | MemoryStore readonly describe: (name: string, fn: () => void) => void readonly close?: (store: MemoryStore) => Promise | void }): void ``` ```ts api-contract="@b4run/testing#.:runCheckpointerConformance" export declare function runCheckpointerConformance(opts: { readonly name: string readonly makeSaver: () => Promise | BaseCheckpointSaver readonly describe: (name: string, fn: () => void) => void readonly close?: (saver: BaseCheckpointSaver) => Promise | void readonly supports?: { /** `list()` hydrates `pendingWrites` (SQLite: no). */ readonly listPendingWrites?: boolean /** `list({ filter })` narrows by metadata (SQLite: no). */ readonly listFilter?: boolean } }): void ``` ```ts api-contract="@b4run/testing#.:runPermissionsStoreConformance" export declare function runPermissionsStoreConformance(opts: { readonly name: string readonly makeStore: (init: PermissionsStoreInit) => Promise | PermissionsStore readonly describe: (name: string, fn: () => void) => void readonly close?: (store: PermissionsStore) => Promise | void }): void ``` ```ts api-contract="@b4run/testing#.:runThreadsStoreConformance" export declare function runThreadsStoreConformance(opts: { readonly name: string readonly makeStore: () => Promise | ThreadsStore readonly describe: (name: string, fn: () => void) => void readonly close?: (store: ThreadsStore) => Promise | void }): void ``` #### Behavior contract `testing.fake-embedder.deterministic` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/testing/test/fake-embedder.test.ts","testNames":["is deterministic and unit-length per text","returns a zero vector when the input has no supported tokens","returns an empty vector when dimensions are zero"]}] */} With positive dimensions, inputs containing supported tokens are unit-length and deterministic. Empty or tokenless inputs produce a zero vector; `fakeEmbedder({ dims: 0 })` produces an empty vector. #### Embedder defaults ```ts api-contract="@b4run/testing#.:fakeEmbedder" export declare function fakeEmbedder(opts?: { readonly dims?: number }): Embedder ``` The default is 64 dimensions. #### Behavior contract `testing.harness-isolation` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/testing/test/harness-fixtures.test.ts","testNames":["reset() isolates fixtures across scenarios — a wildcard fixture does not leak"]},{"kind":"test-assertion","file":"packages/testing/test/harness-construct.test.ts","testNames":["disposes via `await using` (no-throw, idempotent close)"]}] */} AgentHarness reset starts a fresh scenario and clears prior fixtures; close is idempotent and async disposal delegates to it. ### Harness and fixture lifecycle Fixtures accumulate until `reset()`, and the first match wins. `reset()` starts a new harness thread and replaces fixtures; it does not erase every application store. Live and record are separate modes. `getRecordedFixtures()` returns only the latest run. The standalone `record()` helper spawns the aimock CLI and is not harness record mode. All four `run*Conformance` helpers register Vitest suites. Call them inside a test module with a fresh `makeStore`; they do not start Docker or Postgres. `createWorkspaceHarness()` is permissive by default and is not an execution sandbox. `fakeEmbedder()` is test data, not a production semantic model. ## Examples and related guides ```ts import { createAgentHarness, expectFinalMessage, script } from "@b4run/testing" await using harness = await createAgentHarness({ appRoot: process.cwd(), route: "/support#agent", }) const result = await harness.run({ input: "Say hello", fixtures: script().user("Say hello").replies("Hello!") }) expectFinalMessage(result).toContain("Hello") ``` Continue with [Agent Test Harness](/docs/testing-agents), [Fixtures and Recording](/docs/testing-agents/fixtures), and [Scenario Testing](/docs/testing). --- ### @b4run/evals # @b4run/evals ## Use this when Use this package when application behavior needs repeatable datasets, scorers, reports, and release gates. Build deterministic agent runs with `@b4run/testing`, then use `runEval()` to score them. ## Install and import ```bash pnpm add -D @b4run/evals @b4run/testing ``` ```ts import { contains, defineEval, gate, runEval } from "@b4run/evals" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/evals` | node-only | not-claimed | testing | supported | The root resolves JSON and JSONL datasets from disk, so it is a Node testing surface even though individual scorers may be pure. ## Public exports ### `@b4run/evals` | Export | Responsibility | |---|---| | `defineEval` | Validate and preserve an eval definition. | | `gate` | Build report gate policies. | | `resolveGate` | Resolve explicit, threshold, or informational policy. | | `LlmJudgeOptions` | Configure an LLM judge. | | `llmJudge` | Build an LLM-judged scorer. | | `resolveDataset` | Resolve inline, JSON, JSONL, or factory data. | | `RunEvalOptions` | Configure case execution and dataset paths. | | `runEval` | Run and score an eval definition. | | `NormalizedScore` | Describe a normalized verdict. | | `normalizeScore` | Clamp a score into the report shape. | | `contains` | Score final-message substring presence. | | `custom` | Wrap an application scorer. | | `exactMatch` | Score exact final-message equality. | | `jsonEquals` | Score JSON serialization equality. | | `memoryFresh` | Score expected fresh memory text. | | `memoryIsolated` | Score absence of forbidden memory text. | | `memoryRecalled` | Score expected recalled IDs. | | `regex` | Score a final-message regular expression. | | `tokensUnder` | Score a strict collected-stream-delta budget. | | `toolCalled` | Score whether a tool was called. | | `CaseResult` | Describe scores for one case. | | `CaseScore` | Describe one case-scorer result. | | `Dataset` | Name accepted dataset sources. | | `EvalCase` | Describe one dataset row. | | `EvalDefinition` | Configure an evaluation. | | `EvalReport` | Describe the complete report. | | `GatePolicy` | Define report pass policy. | | `GateResult` | Describe a gate decision. | | `Score` | Name accepted scorer output. | | `ScoredReport` | Describe data passed to a gate. | | `Scorer` | Define one scoring function. | | `ScorerAggregate` | Describe one scorer's aggregate. | ## Key contracts ### `EvalDefinition` ```ts api-contract="@b4run/evals#.:EvalDefinition" export interface EvalDefinition { readonly name: string readonly route?: string readonly dataset: Dataset readonly scorers: readonly Scorer[] readonly threshold?: number readonly gate?: GatePolicy } ``` **Fields: `@b4run/evals#.:EvalDefinition`** | Field | Type | Required | Description | |---|---|---|---| | `readonly name` | `string` | yes | Name the report. | | `readonly route` | `string` | no | Select a route key. | | `readonly dataset` | `Dataset` | yes | Supply inline, file, or factory cases. | | `readonly scorers` | `readonly Scorer[]` | yes | Score every case. | | `readonly threshold` | `number` | no | Shorthand for a mean gate. | | `readonly gate` | `GatePolicy` | no | Define explicit report pass policy. | ```ts api-contract="@b4run/evals#.:defineEval" export declare function defineEval(def: EvalDefinition): EvalDefinition ``` ```ts api-contract="@b4run/evals#.:EvalCase" export interface EvalCase { readonly name?: string readonly input: unknown readonly expected?: unknown readonly fixtures?: FixtureSet | ScriptBuilder readonly metadata?: Record } ``` ```ts api-contract="@b4run/evals#.:Scorer" export interface Scorer { readonly name: string readonly threshold?: number readonly score: (run: AgentRunResult, testCase: EvalCase) => Score | Promise } ``` ```ts api-contract="@b4run/evals#.:RunEvalOptions" export interface RunEvalOptions { readonly runCase: (testCase: EvalCase) => Promise readonly baseDir?: string } ``` ```ts api-contract="@b4run/evals#.:EvalReport" export interface EvalReport extends ScoredReport { readonly gated: boolean readonly passed: boolean readonly reason?: string } ``` ```ts api-contract="@b4run/evals#.:runEval" export declare function runEval( def: EvalDefinition, options: RunEvalOptions, ): Promise ``` #### Behavior contract `evals.scorer-errors.zero-score` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/evals/test/run-eval.test.ts","testNames":["a thrown scorer scores 0 with the error in reason and does not abort"]}] */} runEval records a thrown scorer as a zero with its error reason and continues evaluating the report. #### Behavior contract `evals.run-and-gate` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/evals/test/run-eval.test.ts","testNames":["scores every case×scorer, aggregates, and applies the gate","a thrown scorer scores 0 with the error in reason and does not abort","is informational (passes) when no gate or threshold is set"]},{"kind":"test-assertion","file":"packages/evals/test/gate.test.ts","testNames":["perScorer() requires each scorer with a threshold to meet it","perScorer() ignores scorer aggregates without an explicit threshold","resolveGate prefers gate, then threshold sugar, then informational"]}] */} runEval scores every case with every scorer; a scorer exception becomes zero without aborting; an explicit gate wins over threshold, and no gate or threshold is informational and passes. `gate.perScorer()` checks only scorers with explicit thresholds and ignores scorers without one. ### Evaluation semantics Cases and scorers run sequentially. A scorer error is contained, but a `runCase` error is not. An explicit `gate` wins over top-level `threshold`; without either, the report is informational with `gated: false` and `passed: true`. A scorer's own threshold controls its case pass bar and inclusion in `gate.perScorer()`; case pass status otherwise uses the separate default bar of `0.5`. `gate.perScorer()` ignores scorers without an explicit threshold. `defineEval()` rejects an empty inline dataset, but a file or factory may resolve empty. Programmatic `runEval()` resolves relative dataset paths from `baseDir` or the current working directory; the CLI supplies the eval file's context. `jsonEquals()` uses `JSON.stringify()` equality. `tokensUnder()` is strictly less than its budget and counts collected stream chunks or deltas, not model-tokenizer tokens. Memory scorers are behavioral signals—not authorization checks. ## Examples and related guides ```ts import { contains, defineEval, gate } from "@b4run/evals" import { script } from "@b4run/testing" export default defineEval({ name: "support replies", route: "/support#agent", dataset: [{ input: "Where is my order?", fixtures: script().user("Where is my order?").replies("Your order is in transit."), }], scorers: [contains("order", { threshold: 1 })], gate: gate.perScorer(), }) ``` Continue with [Evals](/docs/evals), [Agent Test Harness](/docs/testing-agents), and [Fixtures and Recording](/docs/testing-agents/fixtures). --- ### b4:routes # b4:routes ## Use this when Import from `b4:routes` when application code needs route paths, dynamic parameters, tools, or state keyed by a route path. These are generated types, not runtime values. Run `b4 typegen`, `b4 dev`, or `b4 build` before type-checking code that imports them. ## Install and import The module is generated with your B4.run app; it is not a package to install. ```ts import type { B4RouteParams, B4RoutePath, B4RouteTools, RouteTools } from "b4:routes" ``` Do not hand-edit `.b4/b4.generated.d.ts`. Change routes, tools, or `state.ts`, then regenerate it. ## Compatibility and audience | Surface | Kind | Audience | Stability | |---|---|---|---| | `b4:routes` | generated types | application | supported | The module has no runtime or purity classification because every export is erased by TypeScript. ## Public exports ### `b4:routes` | Generated export | Responsibility | |---|---| | `B4RoutePath` | Union of discovered route pathnames. | | `B4RouteParams` | Map each route to its dynamic path parameters. | | `B4RouteTools` | Map each route to its generated async tool call signatures. | | `RouteTools` | Select generated tools for one `B4RoutePath`. | | `B4RouteState` | Conditionally map routes that declare state to their state fields. | | `RouteState` | Conditionally select state for one `B4RoutePath`. | `B4RoutePath`, `B4RouteParams`, `B4RouteTools`, and `RouteTools` are always generated. `B4RouteState` and `RouteState` are generated only when at least one discovered route declares `state.ts` and contributes a route-state entry. ## Key contracts ### Route paths and parameters With no routes, `B4RoutePath` is `never` and the parameter map is empty. A dynamic segment becomes `string`, a catch-all becomes `string[]`, and an optional catch-all becomes an optional `string[]` property. `B4RouteParams` tells callers which dynamic-segment fields to include in invocation input; B4.run derives the names, not the values, from the route path. For ordinary `[param]` agent routes, B4.run separates those fields from message input and exposes them to middleware and tool context instead of merging them into `B4RouteState`. ### Tools `B4RouteTools` includes routes with discovered or capability-contributed tools and omits routes with none. Members are `readonly` and return `Promise`. A no-input tool renders as `() => Promise`, not `(input: void) => Promise`. #### Behavior contract `generated-routes.tool-signatures` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/core/test/render-tool-types.test.ts","testNames":["omits zero-tool routes and renders void-input tools as zero-argument promises"]}] */} B4RouteTools omits routes with no tools and renders void-input tools as zero-argument functions returning promises. ### State State types come only from fields discovered in a route's `state.ts`. When state exports are present, `RouteState

` indexes `B4RouteState[P]` for a route in that map; it does not merge dynamic route parameters into state. #### Behavior contract `generated-routes.state-conditional` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/core/test/render-route-types.test.ts","testNames":["adds only the state exports when generated route state is present","does NOT include B4RouteState when stateTypes is omitted"]}] */} B4RouteState and RouteState are generated when route state is present and omitted when state types are not supplied. ## Examples and related guides ```ts import type { B4RouteParams, RouteTools } from "b4:routes" type TenantParams = B4RouteParams["/support/[tenant]"] type SupportTools = RouteTools<"/support/[tenant]"> ``` Continue with [State](/docs/state), [Tools](/docs/tools), [Routes](/docs/routes), and [CLI Reference](/docs/cli). --- ### @b4run/permissions # @b4run/permissions ## Use this when Use this package to evaluate B4.run permission patterns or inject a `PermissionsStore` into an integration. The root is the portable matching and contract surface. Import `/node` only when the application itself owns the local `.b4/permissions.json` store. ## Install and import ```bash pnpm add @b4run/permissions ``` ```ts import { matchPermission, type PermissionsStore } from "@b4run/permissions" import { createPermissionsStore } from "@b4run/permissions/node" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/permissions` | edge-safe | not-claimed | integration | supported | | `@b4run/permissions/node` | node-only | not-claimed | integration | supported | The root keeps Node filesystem APIs out of its import graph. The `/node` store reads and writes application-local files and therefore requires Node. ## Public exports ### `@b4run/permissions` | Export | Responsibility | |---|---| | `matchPermission` | Match one candidate against allow and deny pattern maps. | | `subagentPermissionPattern` | Serialize a parent-route and subagent-name identity. | | `suggestedCommandPattern` | Suggest the first two command tokens. | | `suggestedMemoryPattern` | Suggest a terminated workspace-and-route namespace prefix. | | `suggestedPathPattern` | Suggest a path's parent directory prefix. | | `CommandDetail` | Describe a command permission request. | | `MemoryDetail` | Describe a memory supersede request. | | `PathDetail` | Describe a filesystem permission request. | | `PermissionDecision` | Name an interrupt resume decision. | | `PermissionMode` | Name the active permission mode. | | `PermissionRequest` | Describe the discriminated interrupt payload. | | `PermissionsFile` | Describe config and runtime permission maps. | | `PermissionsStore` | Define permission loading, matching, and persistence. | | `SubagentDetail` | Describe a subagent dispatch request. | | `ToolDetail` | Describe an arbitrary tool request. | ### `@b4run/permissions/node` The `/node` entry does not re-export the root. Import shared types and matching helpers from `@b4run/permissions` independently. | Export | Responsibility | |---|---| | `createPermissionsStore` | Create the application-local disk-backed store. | ## Key contracts ```ts api-contract="@b4run/permissions#.:PermissionMode" export type PermissionMode = "interactive" | "non-interactive" | "bypass" ``` ```ts api-contract="@b4run/permissions#.:PermissionDecision" export type PermissionDecision = "once" | "always" | "deny" ``` ```ts api-contract="@b4run/permissions#.:PermissionsFile" export interface PermissionsFile { readonly version: 1 readonly allow: Readonly> readonly deny: Readonly> } ``` **Fields: `@b4run/permissions#.:PermissionsFile`** | Field | Type | Required | Description | |---|---|---|---| | `readonly version` | `1` | yes | Identify the current file format. | | `readonly allow` | `Readonly>` | yes | Map operation keys to allow patterns. | | `readonly deny` | `Readonly>` | yes | Map operation keys to deny patterns. | ```ts export type PermissionRequest = PermissionRequestBase & ( | { readonly kind: "command"; readonly detail: CommandDetail } | { readonly kind: "path"; readonly detail: PathDetail } | { readonly kind: "tool"; readonly detail: ToolDetail } | { readonly kind: "memory"; readonly detail: MemoryDetail } | { readonly kind: "subagent"; readonly detail: SubagentDetail } ) ``` ```ts api-contract="@b4run/permissions#.:PermissionsStore" export interface PermissionsStore { load(): Promise match(tool: string, candidate: string): "allow" | "deny" | "unknown" addAllow(tool: string, pattern: string): Promise readonly mode: PermissionMode } ``` ```ts export declare function matchPermission( tool: string, candidate: string, allow: Readonly>, deny: Readonly>, ): "allow" | "deny" | "unknown" ``` ```ts export declare function createPermissionsStore(opts: { readonly appRoot: string readonly config: PermissionsFile | undefined readonly mode: PermissionMode }): PermissionsStore ``` The inline input object is public; `CreateOptions` is not exported. #### Behavior contract `permissions.match.prefix` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/permissions/test/pattern-matching.test.ts","testNames":["commands keep prefix matching","treats path candidates with absolute prefixes","allows deeper namespaces under the route","deny wins over allow when both match","deny wins over allow for the memory key"]}] */} Non-reserved command, path, and memory candidates use prefix matching, and deny wins over allow. #### Behavior contract `permissions.tool.exact` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/permissions/test/pattern-matching.test.ts","testNames":["does not prefix-match tool names","matches an exact tool name"]}] */} Reserved tool names match exactly rather than by prefix. #### Behavior contract `permissions.subagent.exact` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/permissions/test/pattern-matching.test.ts","testNames":["matches an exact parent route and subagent name tuple","does not prefix-match a serialized tuple identity"]}] */} Reserved subagent identities match exactly rather than by prefix. #### Pattern boundaries Reserved `tool` and `subagent` keys use exact matching. Other keys use `candidate.startsWith(pattern)`, so callers must choose boundary-safe patterns: paths normally end in `/`, and memory namespaces use the terminating `|` returned by `suggestedMemoryPattern()`. #### Behavior contract `permissions.store.noninteractive` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/permissions/test/permissions-store.test.ts","testNames":["ignores the runtime file in non-interactive mode"]}] */} Non-interactive mode ignores the runtime permissions file. ### Store lifecycle and trust boundary Call and await `store.load()` before any store use, especially before `addAllow()` or other persistence. Calling `addAllow()` first can overwrite grants already present in the runtime file. In interactive mode `load()` reads `.b4/permissions.json`; non-interactive mode uses config maps but intentionally ignores that runtime file; bypass mode returns `unknown` for every match. A malformed interactive runtime file rejects `load()`. Only `addAllow()` persists a runtime decision. It serializes concurrent writes, creates `.b4/permissions.json`, updates the in-memory allow map, and adds `.b4/` to `.gitignore`. The store has no deny-write or close method. Configuration ownership remains with the caller: pass the resolved `config` and `mode` when constructing it. Patterns are policy inputs, not authentication or filesystem containment. Prefixes can be broad, and permission-map keys are open strings except for the reserved exact-match behavior described above. ## Examples and related guides ```ts import { createPermissionsStore } from "@b4run/permissions/node" const store = createPermissionsStore({ appRoot: process.cwd(), config: { version: 1, allow: { bash: ["pnpm test"] }, deny: {} }, mode: "non-interactive", }) await store.load() if (store.match("bash", "pnpm test packages/sdk") !== "allow") { throw new Error("Command is not allowed") } ``` Continue with [Permissions](/docs/permissions), [Access Control](/docs/access-control), and [Workspace API](/docs/api/workspace). --- ### @b4run/workspace # @b4run/workspace ## Use this when Use this package to implement or wrap filesystem, command, and execution-sandbox backends for a B4.run application. The root owns the portable backend and sandbox contracts, middleware composition, and logging wrappers. Import `/node` only for the local filesystem and shell implementations. ## Install and import ```bash pnpm add @b4run/workspace ``` ```ts import { compose, withExecLogging, type SandboxProvider } from "@b4run/workspace" import { localExec, localFilesystem } from "@b4run/workspace/node" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/workspace` | edge-safe | dependency-free | application | supported | | `@b4run/workspace/node` | node-only | not-claimed | application | supported | The root's emitted subpath graph has no runtime package or Node built-in dependencies. Its `LocalExecOptions` and `LocalFilesystemOptions` exports are types only. The `/node` factories use Node child-process, filesystem, path, and utility APIs. ## Public exports ### `@b4run/workspace` | Export | Responsibility | |---|---| | `compose` | Compose backend middleware right-to-left. | | `LocalExecOptions` | Configure the local command backend without importing its runtime. | | `LocalFilesystemOptions` | Configure the local filesystem backend without importing its runtime. | | `SandboxConfig` | Configure provider selection and lifecycle policy. | | `SandboxHandle` | Describe one acquired sandbox. | | `SandboxPolicy` | Describe per-thread network, environment, resource, and security policy. | | `SandboxProvider` | Define sandbox acquisition, release, and destruction. | | `SandboxSecurityPolicy` | Describe provider-agnostic hardening intent. | | `BackendContext` | Carry cancellation and the active workspace root. | | `ExecBackend` | Define shell-command execution. | | `ExecMiddleware` | Wrap an execution backend. | | `FilesystemBackend` | Define text, binary, directory, canonicalization, and optional file operations. | | `FilesystemMiddleware` | Wrap a filesystem backend. | | `LoggingOptions` | Configure backend log delivery. | | `withExecLogging` | Log command calls around an execution backend. | | `withFilesystemLogging` | Log public filesystem calls while preserving optional capabilities. | ### `@b4run/workspace/node` The `/node` entry independently owns the following four exports. The two option types are also available from the dependency-free root for type-only consumers; the factories are not. | Export | Responsibility | |---|---| | `LocalExecOptions` | Configure timeout and command allowlisting. | | `localExec` | Create the Node shell-command backend. | | `LocalFilesystemOptions` | Configure the default file-size limit. | | `localFilesystem` | Create the Node filesystem backend. | ## Key contracts ```ts api-contract="@b4run/workspace#.:BackendContext" export interface BackendContext { readonly signal: AbortSignal readonly workspaceRoot: string } ``` **Fields: `@b4run/workspace#.:BackendContext`** | Field | Type | Required | Description | |---|---|---|---| | `readonly signal` | `AbortSignal` | yes | Abort work when the parent run is cancelled. | | `readonly workspaceRoot` | `string` | yes | Name the active route workspace's absolute root. | ```ts api-contract="@b4run/workspace#.:FilesystemBackend" export interface FilesystemBackend { readFile( path: string, ctx: BackendContext, opts?: { readonly maxBytes?: number }, ): Promise readBinaryFile?( path: string, ctx: BackendContext, opts?: { readonly maxBytes?: number }, ): Promise writeFile( path: string, content: string, ctx: BackendContext, ): Promise<{ readonly bytesWritten: number }> listDir(path: string, ctx: BackendContext): Promise realPath(path: string, ctx: BackendContext): Promise statFile?( path: string, ctx: BackendContext, ): Promise<{ readonly size: number; readonly mtimeMs: number }> removeFile?(path: string, ctx: BackendContext): Promise touchFile?(path: string, ctx: BackendContext): Promise mkdir?(path: string, ctx: BackendContext): Promise } ``` ```ts api-contract="@b4run/workspace#.:ExecBackend" export interface ExecBackend { runCommand( args: { readonly command: string readonly cwd?: string readonly env?: Readonly> }, ctx: BackendContext, ): Promise<{ readonly stdout: string readonly stderr: string readonly exitCode: number }> } ``` ```ts api-contract="@b4run/workspace#.:compose" export declare function compose( ...middlewares: ReadonlyArray<(next: T) => T> ): (base: T) => T ``` #### Behavior contract `workspace.compose.order` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/workspace/test/compose.test.ts","testNames":["applies middlewares right-to-left (outermost first)"]}] */} Backend middleware composes right-to-left, with the first listed middleware outermost. #### Sandbox contracts ```ts api-contract="@b4run/workspace#.:SandboxPolicy" export interface SandboxPolicy { readonly network: | { readonly mode: "allow"; readonly denylist?: readonly string[] } | { readonly mode: "deny"; readonly allowlist?: readonly string[] } readonly env?: Readonly> readonly resources?: { readonly memoryMb?: number readonly cpus?: number readonly timeoutMs?: number readonly diskGb?: number } readonly security?: SandboxSecurityPolicy } ``` **Fields: `@b4run/workspace#.:SandboxPolicy`** | Field | Type | Required | Description | |---|---|---|---| | `readonly network` | `\| { readonly mode: "allow"; readonly denylist?: readonly string[] } \| { readonly mode: "deny"; readonly allowlist?: readonly string[] }` | yes | Set the provider's network policy intent. | | `readonly env` | `Readonly>` | no | Supply the sandbox environment explicitly. | | `readonly resources` | `{ readonly memoryMb?: number; readonly cpus?: number; readonly timeoutMs?: number; readonly diskGb?: number }` | no | Request provider resource limits. | | `readonly security` | `SandboxSecurityPolicy` | no | Override provider hardening intent. | ```ts api-contract="@b4run/workspace#.:SandboxSecurityPolicy" export interface SandboxSecurityPolicy { readonly dropAllCapabilities?: boolean readonly noNewPrivileges?: boolean readonly readOnlyRootFilesystem?: boolean readonly runAsNonRoot?: boolean | { readonly uid: number; readonly gid: number } readonly pidsLimit?: number } ``` **Fields: `@b4run/workspace#.:SandboxSecurityPolicy`** | Field | Type | Required | Description | |---|---|---|---| | `readonly dropAllCapabilities` | `boolean` | no | Request dropping every Linux capability. | | `readonly noNewPrivileges` | `boolean` | no | Request blocking setuid/setgid escalation. | | `readonly readOnlyRootFilesystem` | `boolean` | no | Request an immutable root filesystem. | | `readonly runAsNonRoot` | `boolean \| { readonly uid: number; readonly gid: number }` | no | Request non-root execution or an explicit identity. | | `readonly pidsLimit` | `number` | no | Request a process-count limit. | ```ts api-contract="@b4run/workspace#.:SandboxHandle" export interface SandboxHandle { readonly threadId: string readonly filesystem: FilesystemBackend readonly exec: ExecBackend readonly workspaceRoot: string } ``` **Fields: `@b4run/workspace#.:SandboxHandle`** | Field | Type | Required | Description | |---|---|---|---| | `readonly threadId` | `string` | yes | Identify the owning conversation thread. | | `readonly filesystem` | `FilesystemBackend` | yes | Route file operations into the sandbox. | | `readonly exec` | `ExecBackend` | yes | Route command execution into the sandbox. | | `readonly workspaceRoot` | `string` | yes | Name the absolute root inside the sandbox. | ```ts api-contract="@b4run/workspace#.:SandboxProvider" export interface SandboxProvider { readonly name: string acquire(input: { readonly threadId: string readonly policy: SandboxPolicy readonly signal: AbortSignal }): Promise release(threadId: string): Promise destroy(threadId: string): Promise preflight?(): Promise<{ readonly ok: boolean readonly detail?: string readonly warnings?: readonly string[] }> } ``` **Fields: `@b4run/workspace#.:SandboxProvider`** | Field | Type | Required | Description | |---|---|---|---| | `readonly name` | `string` | yes | Identify the provider. | ```ts api-contract="@b4run/workspace#.:SandboxConfig" export interface SandboxConfig { readonly provider: SandboxProvider readonly network?: SandboxPolicy["network"] readonly env?: SandboxPolicy["env"] readonly resources?: SandboxPolicy["resources"] readonly security?: SandboxSecurityPolicy readonly idleTimeoutMs?: number } ``` **Fields: `@b4run/workspace#.:SandboxConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly provider` | `SandboxProvider` | yes | Supply the sandbox provider. | | `readonly network` | `SandboxPolicy["network"]` | no | Set the default network policy. | | `readonly env` | `SandboxPolicy["env"]` | no | Set the default explicit environment. | | `readonly resources` | `SandboxPolicy["resources"]` | no | Set default resource requests. | | `readonly security` | `SandboxSecurityPolicy` | no | Set default hardening intent. | | `readonly idleTimeoutMs` | `number` | no | Set the manager idle-reap window; default 600,000 ms. | ```ts api-contract="@b4run/workspace#./node:LocalExecOptions" export interface LocalExecOptions { readonly timeout?: number readonly allowedCommands?: readonly RegExp[] } ``` ```ts api-contract="@b4run/workspace#./node:localExec" export declare function localExec(opts?: LocalExecOptions): ExecBackend ``` #### Behavior contract `workspace.exec.timeout` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/workspace/test/local-exec.test.ts","testNames":["runCommand enforces timeout"]}] */} The local exec backend enforces its configured timeout. ```ts api-contract="@b4run/workspace#./node:LocalFilesystemOptions" export interface LocalFilesystemOptions { readonly maxFileBytes?: number } ``` ```ts api-contract="@b4run/workspace#./node:localFilesystem" export declare function localFilesystem(opts?: LocalFilesystemOptions): FilesystemBackend ``` #### Behavior contract `workspace.filesystem.symlink` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/workspace/test/local-filesystem.test.ts","testNames":["realPath resolves an escaping symlink to the outside real path"]},{"kind":"test-assertion","file":"packages/core/test/capabilities/workspace-fs.test.ts","testNames":["gates a symlink that escapes the workspace (caught, not silently allowed)"]}] */} `localFilesystem.realPath` resolves an escaping symlink to its outside real path; Core owns any path-jail enforcement. ### Lifecycle, failure, and trust boundaries Backend methods receive absolute paths and a cancellation signal from their caller. Core owns the path jail and canonical-root enforcement. `localFilesystem` canonicalizes existing ancestors, including escaping symlinks, and does not enforce that boundary. Its reads default to a 256 KiB cap, allow a per-call override, and reject missing or oversized files. Writes create parent directories. `localExec` defaults to a 30-second timeout. A non-empty `allowedCommands` list rejects a command unless a regular expression matches. When `args.env` is omitted, `localExec` inherits `process.env`; when supplied, it replaces that environment. By contrast, `SandboxPolicy.env` is the explicit environment injected into a sandbox and does not inherit the host environment. Sandbox `release()` and `destroy()` express distinct lifecycle responsibilities, but exact persistence and cleanup behavior belongs to each provider. Security fields express provider-agnostic intent; an unset field does not by itself prove a particular provider's enforcement. Logging middleware defaults to `console.error`. A custom destination receives `{ method, args }`. Filesystem logging does not serialize binary content and passes through `realPath`, `statFile`, `removeFile`, `touchFile`, and `mkdir`; treat logged paths, command text, working directories, and text write contents as potentially sensitive. ## Examples and related guides ```ts import { compose, withExecLogging, withFilesystemLogging } from "@b4run/workspace" import { localExec, localFilesystem } from "@b4run/workspace/node" const filesystem = compose(withFilesystemLogging())( localFilesystem({ maxFileBytes: 512 * 1024 }), ) const exec = compose(withExecLogging())( localExec({ timeout: 10_000, allowedCommands: [/^pnpm test\b/] }), ) ``` Continue with [Workspace Filesystem](/docs/workspace), [Execution Sandbox](/docs/sandbox), and [Permissions API](/docs/api/permissions). --- ### @b4run/sandbox # @b4run/sandbox ## Use this when Use this Node-only package when a B4.run application needs the reference Docker or Kubernetes `SandboxProvider`, or when a provider author needs the shared conformance suite. The portable sandbox interfaces are re-exported for discovery, but their canonical field owner is [Workspace](/docs/api/workspace). ## Install and import ```bash pnpm add @b4run/sandbox pnpm add -D vitest ``` ```ts import { dockerSandbox, kubernetesSandbox } from "@b4run/sandbox" import { fakeSandbox, runProviderConformance } from "@b4run/sandbox/testing" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/sandbox` | node-only | not-claimed | application | supported | | `@b4run/sandbox/testing` | node-only | not-claimed | testing | supported | The root uses Node APIs, Docker process execution, and the Kubernetes client. `/testing` imports Vitest and is test-facing. Ordinary package tests use fakes; real-provider evidence lives in gated Docker and Kubernetes integration lanes. ## Public exports ### `@b4run/sandbox` | Export | Responsibility | |---|---| | `SandboxConfig` | Re-export the portable application sandbox configuration from Workspace. | | `SandboxHandle` | Re-export the portable acquired-handle contract from Workspace. | | `SandboxPolicy` | Re-export portable network, environment, resource, and security intent from Workspace. | | `SandboxProvider` | Re-export the portable provider lifecycle contract from Workspace. | | `DockerSandboxOptions` | Configure the Docker image and optional injected Docker seam. | | `dockerSandbox` | Create the Docker provider. | | `KubeClient` | Describe the public Kubernetes client seam; helper request shapes remain implementation details. | | `KubePermission` | Describe one Kubernetes authorization check used by the public client seam. | | `KubeAuthorizationReviewError` | Preserve API-versus-transport failure classification from custom Kubernetes clients. | | `KubernetesSandboxOptions` | Configure the Kubernetes image, namespace, storage class, timeout, and client seam. | | `kubernetesSandbox` | Create the Kubernetes provider. | ### `@b4run/sandbox/testing` | Export | Responsibility | |---|---| | `fakeSandbox` | Create an in-memory provider for unit and wiring tests. | | `runProviderConformance` | Register Vitest cases for persistence, isolation, and execution behavior. | ## Key contracts ```ts api-contract="@b4run/sandbox#.:KubernetesSandboxOptions" export interface KubernetesSandboxOptions { readonly image: string readonly namespace?: string readonly storageClass?: string readonly startupTimeoutMs?: number readonly client?: KubeClient } ``` ```ts api-contract="@b4run/sandbox#.:KubeAuthorizationReviewError" export declare class KubeAuthorizationReviewError extends Error { readonly kind: "api" | "transport" constructor(kind: "api" | "transport", message: string, options?: ErrorOptions) } ``` Custom `KubeClient.canI` implementations should throw this error when an authorization review cannot complete. Use `api` when the Kubernetes API answers but rejects or cannot process the review, and `transport` when the API cannot be reached. ```ts api-contract="@b4run/sandbox#.:dockerSandbox" export declare function dockerSandbox(opts: DockerSandboxOptions): SandboxProvider ``` ```ts api-contract="@b4run/sandbox#.:kubernetesSandbox" export declare function kubernetesSandbox(opts: KubernetesSandboxOptions): SandboxProvider ``` ```ts api-contract="@b4run/sandbox#./testing:runProviderConformance" export declare function runProviderConformance(opts: { readonly name: string readonly makeProvider: () => SandboxProvider readonly describe: (name: string, fn: () => void) => void }): void ``` #### Behavior contract `sandbox.docker.release` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sandbox/test/docker-sandbox.unit.test.ts","testNames":["release removes container but not volume; destroy removes both"]}] */} Docker release removes the container but retains its volume; destroy removes both. #### Behavior contract `sandbox.kubernetes.release` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sandbox/test/kube-sandbox.unit.test.ts","testNames":["release deletes the pod but keeps the PVC; destroy removes both"]}] */} Kubernetes release deletes the Pod but retains the PVC; destroy removes both. #### Operational cleanup caveat These are successful-path lifecycle contracts. The cleanup calls swallow provider deletion errors, and the Kubernetes PVC wait stops after 30 seconds; operators must detect and reap leftovers. #### Behavior contract `sandbox.kubernetes.allow-network` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sandbox/test/kube-sandbox.unit.test.ts","testNames":["network:allow with no allowlist emits no NetworkPolicy"]}] */} Kubernetes `network:allow` without an allowlist emits no NetworkPolicy. #### Network enforcement caveat The absence of a provider-created policy does not promise unrestricted egress: cluster defaults, infrastructure policies, and CNI behavior still apply. Preflight checks Kubernetes API reachability and the complete runtime permission set, reporting authorization denials, review failures, and transport failures separately. Unknown CNI enforcement produces a warning; preflight does not prove NetworkPolicy enforcement. #### Behavior contract `sandbox.error.create` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sandbox/test/sandbox-error-code.test.ts","testNames":["a failed container creation throws an error tagged B4_E2001"]}] */} A failed sandbox container creation is tagged `B4_E2001`. #### Provider identity and reattachment caveat Provider names sanitize caller-supplied thread IDs and can collide; applications must supply provider-name-safe unique IDs. A running Kubernetes Pod is reattached without applying changed image, environment, resources, or security, and a prior deny policy is not removed by a later allow acquire. Release before changing provider options or policy. ## Examples and related guides ```ts import { dockerSandbox } from "@b4run/sandbox" const provider = dockerSandbox({ image: "node:24-slim" }) const status = await provider.preflight?.() if (!status?.ok) throw new Error(status?.detail ?? "Sandbox unavailable") ``` The image must provide the shell and utilities the backends invoke, including `sleep`, `timeout`, `realpath`, `stat`, and core file utilities. Continue with [Execution Sandbox](/docs/sandbox), [Kubernetes Sandbox](/docs/sandbox/kubernetes), and [Workspace API](/docs/api/workspace). --- ### @b4run/langgraph # @b4run/langgraph ## Use this when Use this integration package when a B4.run runtime or framework extension must normalize `graph` and `workflow` route modules or execute those entries through B4.run's backend-adapter contract. Application route authors normally use the [SDK route contracts](/docs/api/sdk) and let the CLI choose these adapters. ## Install and import ```bash pnpm add @b4run/langgraph ``` ```ts import { defineEntry, graphAdapter, normalizeRouteModule } from "@b4run/langgraph" import type { RouteModule } from "@b4run/langgraph/route-module" ``` Use `@b4run/langgraph/define-entry` for the smallest entry-validation import and `@b4run/langgraph/route-module` for normalization contracts without the adapters. ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/langgraph` | edge-safe | dependency-free | integration | supported | | `@b4run/langgraph/define-entry` | edge-safe | dependency-free | integration | supported | | `@b4run/langgraph/route-module` | edge-safe | dependency-free | integration | supported | All three runtime surfaces bundle without third-party runtime dependencies or Node globals. This classification covers importing and executing these B4.run adapters; it does not classify a graph, workflow, or tool supplied by an application. ## Public exports ### `@b4run/langgraph` | Export | Responsibility | |---|---| | `defineEntry` | Validate and return a graph-or-workflow route module unchanged. | | `graphAdapter` | Execute callable graphs or objects with `invoke(input)`. | | `workflowAdapter` | Execute callable workflow entries. | | `GraphRouteModule` | Describe a module with a `graph` entry. | | `NormalizedRouteModule` | Describe the normalized kind, entry, and config. | | `normalizeRouteModule` | Validate and normalize either route-module form. | | `RouteConfig` | Re-export the [SDK-owned route configuration](/docs/api/sdk#routeconfig). | | `RouteKind` | Re-export the SDK-owned `agent`, `chain`, `graph`, or `workflow` kind. | | `RouteModule` | Unite graph and workflow route-module forms. | | `WorkflowRouteModule` | Describe a module with a `workflow` entry. | | `RuntimeContext` | Re-export the [SDK-owned runtime context](/docs/api/sdk). | | `RuntimeTool` | Re-export the SDK-owned callable tool contract. | `ToolRegistry` exists in a private implementation module but is not exported from this package surface. ### `@b4run/langgraph/define-entry` | Export | Responsibility | |---|---| | `defineEntry` | Validate and preserve one route-module object. | ### `@b4run/langgraph/route-module` | Export | Responsibility | |---|---| | `GraphRouteModule` | Describe a module with a `graph` entry. | | `NormalizedRouteModule` | Describe the normalized kind, entry, and config. | | `normalizeRouteModule` | Produce a normalized route module. | | `assertExactlyOneEntry` | Assert the runtime graph/workflow exclusivity rule. | | `RouteConfig` | Re-export the SDK-owned route configuration. | | `RouteKind` | Re-export the SDK-owned route kind. | | `RouteModule` | Unite graph and workflow module forms. | | `WorkflowRouteModule` | Describe a module with a `workflow` entry. | The route-module subpath exposes only its published normalization and route-module contracts. The source-derived inventory pins these eight names independently from the root and `/define-entry` surfaces. ## Key contracts ```ts api-contract="@b4run/langgraph#./define-entry:defineEntry" export declare function defineEntry>( module: TModule, ): TModule ``` ```ts api-contract="@b4run/langgraph#./route-module:GraphRouteModule" export interface GraphRouteModule { readonly graph: TEntry readonly workflow?: never readonly config?: RouteConfig } ``` **Fields: `@b4run/langgraph#./route-module:GraphRouteModule`** | Field | Type | Required | Description | |---|---|---|---| | `readonly graph` | `TEntry` | yes | Supply the graph entry. | | `readonly workflow` | `never` | no | Exclude the workflow alternative. | | `readonly config` | `RouteConfig` | no | Configure the route; normalization defaults it to `{}`. | ```ts api-contract="@b4run/langgraph#./route-module:WorkflowRouteModule" export interface WorkflowRouteModule { readonly workflow: TEntry readonly graph?: never readonly config?: RouteConfig } ``` **Fields: `@b4run/langgraph#./route-module:WorkflowRouteModule`** | Field | Type | Required | Description | |---|---|---|---| | `readonly workflow` | `TEntry` | yes | Supply the workflow entry. | | `readonly graph` | `never` | no | Exclude the graph alternative. | | `readonly config` | `RouteConfig` | no | Configure the route; normalization defaults it to `{}`. | ```ts api-contract="@b4run/langgraph#./route-module:RouteModule" export type RouteModule = GraphRouteModule | WorkflowRouteModule ``` ```ts api-contract="@b4run/langgraph#./route-module:NormalizedRouteModule" export interface NormalizedRouteModule { readonly kind: RouteKind readonly entry: TEntry readonly config: RouteConfig } ``` **Fields: `@b4run/langgraph#./route-module:NormalizedRouteModule`** | Field | Type | Required | Description | |---|---|---|---| | `readonly kind` | `RouteKind` | yes | Identify the selected route entry kind. | | `readonly entry` | `TEntry` | yes | Hold the selected graph or workflow. | | `readonly config` | `RouteConfig` | yes | Hold the supplied config or `{}`. | ```ts api-contract="@b4run/langgraph#./route-module:normalizeRouteModule" export declare function normalizeRouteModule( module: RouteModule | (GraphRouteModule & WorkflowRouteModule), ): NormalizedRouteModule ``` ```ts api-contract="@b4run/langgraph#./route-module:assertExactlyOneEntry" export declare function assertExactlyOneEntry( module: RouteModule | (GraphRouteModule & WorkflowRouteModule), ): asserts module is RouteModule ``` #### Behavior contract `langgraph.entry.exclusive` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/langgraph/test/define-entry.test.ts","testNames":["rejects modules that provide both graph and workflow","rejects modules that provide neither graph nor workflow"]}] */} A route module must provide exactly one of graph or workflow. #### Entry validation caveats An explicitly `undefined` key counts as absent. `defineEntry()` returns the original object, while `normalizeRouteModule()` fills an omitted `config` with a new empty object. Invalid modules throw synchronously before an entry executes. #### Behavior contract `langgraph.route-module.surface` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/langgraph/test/route-module.test.ts","testNames":["exposes publishable exports and types on the package surface"]}] */} The route-module subpath exposes only its published normalization and route-module contracts. #### Adapter lifecycle and failure boundaries The manifest assertion establishes the published declaration target; the source-derived ownership inventory above establishes the exact eight-symbol surface. `graphAdapter` calls a function directly or calls an object's `invoke`; `workflowAdapter` accepts only a function. Both pass `{ signal }`, await the result, and implement `stream()` as a single yielded execute result. They do not provide native multi-chunk graph streaming. The `runtime`, `streaming`, and `tags` route-config fields are reserved contracts but these adapters do not branch on them today. ## Examples and related guides ```ts import { defineEntry, normalizeRouteModule } from "@b4run/langgraph" const route = defineEntry({ workflow: async (input: { name: string }, { signal }: { signal: AbortSignal }) => { signal.throwIfAborted() return { greeting: `Hello, ${input.name}` } }, config: { runtime: "node", streaming: false }, // reserved; no adapter runtime effect }) const normalized = normalizeRouteModule(route) console.log(normalized.kind) // workflow ``` Continue with [Routes](/docs/routes), [SDK API](/docs/api/sdk), and [CLI API](/docs/api/cli). --- ### @b4run/langchain # @b4run/langchain ## Use this when Use this integration package when framework code must materialize B4.run agent descriptors, adapt LangChain runnables, convert B4.run tools, configure provider loading, or compose the package's retry, summarization, offloading, and subagent helpers. Most application code declares agents through [`agent()`](/docs/api/sdk#agent-and-agentconfig) and lets the B4.run runtime call this layer. ## Install and import ```bash pnpm add @b4run/langchain @langchain/core @langchain/langgraph-checkpoint ``` ```ts import { chainAdapter, openaiEmbedder, resolveProvider, withRetry, } from "@b4run/langchain" ``` Install the optional LangChain provider package selected by your agents. `@langchain/openai` is included; Anthropic, Google, Mistral, Groq, Ollama, xAI, and OpenRouter integrations are optional peer dependencies. ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/langchain` | edge-safe | not-claimed | integration | supported | | `@b4run/langchain/package.json` | package metadata | n/a | tooling | supported | The runtime root is edge-safe for B4.run's emitted Hono/workerd target. Compatibility evidence includes the emitted Hono app round trip on Node and a gated real-workerd lane without `nodejs_compat`; guards bundle the exact surface with the model layer externalized. This is not a dependency-free claim or a promise of generic browser portability: provider integrations, application tools, stores, and dynamically selected imports retain their own requirements. `./package.json` is data for tooling, not TypeScript inventory or runtime code. ## Public exports ### `@b4run/langchain` | Export | Responsibility | |---|---| | `AgentStreamChunk` | Describe streamed tokens, tool activity, interrupts, completion, and extensions. | | `AgentTurnResult` | Describe a settled agent turn's output and whether it parked on an interrupt. | | `B4ToolDefinition` | Describe a B4.run tool accepted by agent materialization. | | `__resetMaterializedAgentsForTests` | Reset the process-global materialized-agent cache for tests. | | `executeAgent` | Consume an agent stream and return its completed output. | | `executeAgentTurn` | Execute one materialized-agent turn and return its structured result. | | `materializeAgentGraph` | Compile a B4.run agent descriptor into a LangGraph graph. | | `streamAgent` | Stream normalized agent chunks. | | `chainAdapter` | Adapt a LangChain runnable to B4.run's chain backend contract. | | `BuiltInModelProviderId` | Re-export the SDK-owned built-in provider union. | | `ModelProviderId` | Re-export the SDK-owned provider identifier. | | `createChatModel` | Load and construct a selected provider chat model. | | `providerPackages` | Map built-in provider IDs to integration packages. | | `seedModelImporter` | Install the process-global fallback provider importer. | | `inferProvider` | Re-export SDK provider inference. | | `resolveProvider` | Resolve an explicit provider or infer one from a model ID. | | `RetryOptions` | Configure retry attempts, backoff, cap, and cancellation. | | `isRetryableError` | Classify known transient error messages. | | `withRetry` | Retry transient async failures with jittered exponential backoff. | | `openaiEmbedder` | Construct the OpenAI-backed memory embedder. | | `OffloadStoreOptions` | Configure an offload store and its cleanup limits. | | `buildOffloadFileName` | Produce a sanitized deterministic output filename. | | `OffloadStore` | Persist tool output and perform throttled best-effort cleanup. | | `OffloadToolOutputCtx` | Configure one conditional tool-output offload. | | `offloadToolOutput` | Replace oversized output with a saved-file preview stub. | | `buildStub` | Format a human-readable offloaded-output stub. | | `OffloadFn` | Describe the tool converter's output-offload callback. | | `convertToolToLangChain` | Convert a B4.run tool into a LangChain structured tool. | | `executeWithToolLoop` | Invoke a chain and execute bounded tool-call rounds. | | `UnwrappedToolResult` | Describe agent-visible content and optional state updates. | | `unwrapToolResult` | Decode B4.run's strict `{ result, state? }` tool-return wrapper. | | `Command` | Re-export LangGraph's resume/state-update command. | | `materializeStateSchema` | Build a LangGraph annotation root from resolved state fields. | | `ResolvedSubagentGraph` | Describe a resolved child route graph. | | `SubagentResolver` | Resolve an allowed task request to a child graph or rejection. | | `convertSubagentTaskToLangChain` | Convert B4.run's task placeholder to a resumable LangChain tool. | | `RunningSummary` | Track summary text and covered message count. | | `TokenCounter` | Count text tokens synchronously or asynchronously. | | `SummarizeFn` | Describe the injected summarization operation. | | `ResolvedSummarizationConfig` | Hold resolved thresholds and summarization dependencies. | | `PreModelHookState` | Describe messages and the optional running summary. | | `PreModelHookResult` | Return a model-only message view and updated summary. | | `buildSummarizationHook` | Build the non-destructive pre-model summary hook. | | `splitForSummary` | Split aged messages from recent turns. | | `defaultSummarize` | Summarize messages with the selected chat model. | | `countMessagesTokens` | Count tokens across LangChain messages. | | `defaultTokenCounter` | Count tokens with the package tokenizer. | `__resetMaterializedAgentsForTests` is public so the testing harness can reach it, but its name and contract are testing-only; it mutates process-wide cache state and is not an application lifecycle API. Helpers exported by implementation modules but omitted from the root barrel—such as `composePromptMessages`, `AgentOptions`, `ResolvedStateField`, `ExecuteWithToolLoopOptions`, `BuildStubArgs`, `jsonSchemaToZod`, warning/message helpers, and default importers—are not owned exports here. ### `@b4run/langchain/package.json` This subpath exposes package metadata as JSON for tooling. It has no TypeScript export inventory, runtime compatibility classification, or purity claim; do not execute it as application code. ## Key contracts ```ts api-contract="@b4run/langchain#.:AgentStreamChunk" export interface AgentStreamChunk { readonly type: "token" | "tool_call" | "tool_result" | "interrupt" | "done" | (string & {}) readonly data: unknown } ``` **Fields: `@b4run/langchain#.:AgentStreamChunk`** | Field | Type | Required | Description | |---|---|---|---| | `readonly type` | `"token" \| "tool_call" \| "tool_result" \| "interrupt" \| "done" \| (string & {})` | yes | Identify the chunk kind. | | `readonly data` | `unknown` | yes | Carry the chunk payload. | ```ts api-contract="@b4run/langchain#.:RetryOptions" export interface RetryOptions { readonly maxAttempts?: number readonly baseDelayMs?: number readonly maxDelayMs?: number readonly signal?: AbortSignal } ``` **Fields: `@b4run/langchain#.:RetryOptions`** | Field | Type | Required | Description | |---|---|---|---| | `readonly maxAttempts` | `number` | no | Set total attempts; defaults to `3`. | | `readonly baseDelayMs` | `number` | no | Set the first backoff; defaults to `1000`. | | `readonly maxDelayMs` | `number` | no | Cap backoff; defaults to `10000`. | | `readonly signal` | `AbortSignal` | no | Cancel the retry loop. | ```ts api-contract="@b4run/langchain#.:resolveProvider" export declare function resolveProvider(options: { readonly model: string readonly provider?: ModelProviderId }): BuiltInModelProviderId ``` ```ts api-contract="@b4run/langchain#.:withRetry" export declare function withRetry( fn: () => Promise, options?: RetryOptions, ): Promise ``` ```ts api-contract="@b4run/langchain#.:OffloadToolOutputCtx" export interface OffloadToolOutputCtx { readonly toolName: string readonly thresholdChars: number readonly previewLines: number readonly store: Pick readonly signal?: AbortSignal readonly toolCallId?: string } ``` **Fields: `@b4run/langchain#.:OffloadToolOutputCtx`** | Field | Type | Required | Description | |---|---|---|---| | `readonly toolName` | `string` | yes | Name the output's tool. | | `readonly thresholdChars` | `number` | yes | Set the character threshold. | | `readonly previewLines` | `number` | yes | Set the retained preview length. | | `readonly store` | `Pick` | yes | Persist full output. | | `readonly signal` | `AbortSignal` | no | Cancel the write. | | `readonly toolCallId` | `string` | no | Key the filename; content hashing is the fallback. | ```ts api-contract="@b4run/langchain#.:UnwrappedToolResult" export interface UnwrappedToolResult { readonly content: string readonly stateUpdates: Record | undefined } ``` **Fields: `@b4run/langchain#.:UnwrappedToolResult`** | Field | Type | Required | Description | |---|---|---|---| | `readonly content` | `string` | yes | Carry agent-visible content. | | `readonly stateUpdates` | `Record \| undefined` | yes | Carry optional channel updates. | Several callable exports intentionally expose non-barrel helper shapes in their declarations. Read these shapes inline; they are not independently importable exports: ```ts type Importer = (specifier: string) => Promise> type ResolvedStateField = { readonly name: string readonly reducer: "append" | "replace" | ((current: unknown, incoming: unknown) => unknown) readonly default: unknown } type ToolExecutor = { readonly name: string readonly run: ( input: unknown, context: { readonly middleware?: Readonly> readonly signal: AbortSignal }, ) => Promise | unknown } type ExecuteWithToolLoopOptions = { readonly chain: { readonly invoke: (input: unknown) => Promise } readonly input: unknown readonly middlewareContext?: Readonly> readonly tools: readonly ToolExecutor[] readonly signal: AbortSignal readonly maxIterations?: number } type BuildStubArgs = { readonly content: string readonly relPath: string readonly previewLines: number readonly thresholdChars: number } ``` The larger inline `AgentOptions` shape used by `executeAgent` and `streamAgent` requires `checkpointer`, `entry`, `input`, `routeParamNames`, `signal`, and `tools`; it also accepts middleware, retry, state, prompt, offload, summarization, subagent, thread, sandbox, and cache-bypass controls. The subagent converter accepts a private `{ name, description?, schema? }` placeholder, and the tool converter accepts the same basic tool definition plus a `run` callback. `defaultSummarize` accepts messages, model, optional previous summary, and signal. Materialized graphs are cached by descriptor plus checkpointer. Sandbox-bound tools, subagents, stream transformers, and explicit bypass requests skip reuse. A checkpointer is mandatory at runtime; `threadId` is also required for an interrupted run to resume. Generated edge assembly must seed its static provider importer before model construction. #### Behavior contract `langchain.provider.explicit` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/langchain/test/model-provider-resolver.test.ts","testNames":["explicit provider bypasses inference"]}] */} An explicit model provider bypasses provider inference. #### Provider lifecycle and failure boundaries Unknown explicit providers throw with the supported list. If no provider is explicit and inference fails, resolution throws before model construction. `seedModelImporter()` is last-call-wins process state; seed it during runtime assembly, not per request. #### Behavior contract `langchain.retry.exhaustion` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/langchain/test/retry.test.ts","testNames":["throws after max attempts exhausted"]}] */} Retry throws after the configured maximum attempts are exhausted. #### Retry failure boundaries Only recognized transient messages retry. Non-transient failures throw immediately, cancellation throws `Operation aborted`, and retry delays include up to 500 ms of jitter capped by `maxDelayMs`. #### Behavior contract `langchain.tool-loop.limit` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/langchain/test/tool-loop.test.ts","testNames":["limits tool loop iterations to prevent infinite loops"]}] */} The tool loop limits iterations to prevent an infinite loop. #### Tool-loop failure boundaries Unknown tools and thrown tool failures become `ToolMessage` error content. Reaching the default 10 rounds, or an explicit lower limit, throws rather than returning a partial result. #### Behavior contract `langchain.chain.stream-fallback` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/langchain/test/chain-adapter.test.ts","testNames":["stream falls back to invoke when no stream method"]}] */} A chain stream falls back to invoke when the entry has no stream method. #### Chain, offload, and summarization boundaries The fallback yields the invocation result once. Both execute and stream reject entries without `invoke`; native `stream()` results may be returned directly or through a promise. Offload thresholds count characters, and the full tool output is written into the workspace; both stored content and preview stubs may contain sensitive data. Filenames sanitize tool names and call IDs; without a call ID they use a content hash. Cleanup is throttled and best-effort, while `offloadToolOutput()` returns original content if persistence fails. Tool wrapper recognition is strict: extra keys, arrays, missing `result`, or `result: undefined` use plain-return handling. Summarization changes only the model's input view; on summarizer failure it falls back to full history for that turn. It is context compression, not a trust or security boundary. ## Examples and related guides ```ts import { chainAdapter, resolveProvider, withRetry } from "@b4run/langchain" const provider = resolveProvider({ model: "gpt-5-mini" }) const runnable = { invoke: async (input: { prompt: string }) => ({ provider, answer: input.prompt }), } const result = await withRetry( () => chainAdapter.execute(runnable, { prompt: "hello" }, { signal: new AbortController().signal, }), { maxAttempts: 3 }, ) ``` Continue with [Agents](/docs/agents), [Context Management](/docs/context-management), [Retry](/docs/retry), and [SDK API](/docs/api/sdk). --- ### @b4run/sqlite-storage # @b4run/sqlite-storage ## Use this when Use this Node-only package for local, single-process checkpoint and Agent Protocol thread persistence. It is not encrypted storage and is not shared multi-replica state. ## Install and import ```bash pnpm add @b4run/sqlite-storage ``` ```ts import { createThreadsStore, sqliteCheckpointer } from "@b4run/sqlite-storage" ``` ## Compatibility and audience | Surface | Runtime | Purity | Audience | Stability | |---|---|---|---|---| | `@b4run/sqlite-storage` | node-only | not-claimed | application | supported | The package uses Node's `node:sqlite` `DatabaseSync` and LangGraph checkpoint contracts. ## Public exports ### `@b4run/sqlite-storage` | Export | Responsibility | |---|---| | `SqliteCheckpointerOptions` | Select the checkpoint database path. | | `B4SqliteSaver` | Implement the LangGraph checkpoint saver methods. | | `sqliteCheckpointer` | Open, migrate, and return a checkpoint saver. | | `CreateThreadInput` | Supply an optional thread ID and metadata. | | `Thread` | Describe persisted thread identity, timestamps, metadata, and status. | | `ThreadStatus` | Restrict status to `idle`, `busy`, or `interrupted`. | | `ThreadsStore` | Define thread create/read/delete/list/update operations. | | `ThreadsStoreOptions` | Select the thread database path. | | `createThreadsStore` | Open, migrate, and return a thread store. | ## Key contracts ```ts api-contract="@b4run/sqlite-storage#.:SqliteCheckpointerOptions" export interface SqliteCheckpointerOptions { readonly path: string } ``` ```ts api-contract="@b4run/sqlite-storage#.:sqliteCheckpointer" export declare function sqliteCheckpointer(options: SqliteCheckpointerOptions): B4SqliteSaver ``` ```ts api-contract="@b4run/sqlite-storage#.:ThreadStatus" export type ThreadStatus = "idle" | "busy" | "interrupted" ``` ```ts api-contract="@b4run/sqlite-storage#.:Thread" export interface Thread { readonly thread_id: string readonly created_at: string readonly updated_at: string readonly metadata: Record readonly status: ThreadStatus } ``` ```ts api-contract="@b4run/sqlite-storage#.:CreateThreadInput" export interface CreateThreadInput { readonly thread_id?: string readonly metadata?: Record } ``` ```ts api-contract="@b4run/sqlite-storage#.:ThreadsStore" export interface ThreadsStore { createThread(input: CreateThreadInput): Promise getThread(threadId: string): Promise deleteThread(threadId: string): Promise listThreads(): Promise updateStatus(threadId: string, status: ThreadStatus): Promise updateMetadata(threadId: string, patch: Record): Promise } ``` ```ts api-contract="@b4run/sqlite-storage#.:ThreadsStoreOptions" export interface ThreadsStoreOptions { readonly path: string } ``` ```ts api-contract="@b4run/sqlite-storage#.:createThreadsStore" export declare function createThreadsStore( options: ThreadsStoreOptions, ): import("./store.js").ThreadsStore ``` #### Behavior contract `sqlite.checkpointer.persistence` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sqlite-storage/test/checkpointer.test.ts","testNames":["persists across saver instances (file-backed)"]}] */} A file-backed SQLite checkpoint persists across saver instances. #### Behavior contract `sqlite.threads.order` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sqlite-storage/test/threads.test.ts","testNames":["listThreads returns most-recently-updated first"]}] */} `listThreads` returns most-recently-updated threads first. #### Thread ordering caveat Ordering is `updated_at DESC` and does not define a tie-break for equal timestamps. #### Behavior contract `sqlite.db.pragmas` {/* api-behavior-authorities: [{"kind":"test-assertion","file":"packages/sqlite-storage/test/db.test.ts","testNames":["opens a database with WAL journal_mode, foreign_keys ON, and synchronous=NORMAL"]}] */} SQLite opens with WAL mode, foreign keys enabled, and synchronous NORMAL. #### Database mode caveat WAL applies only to file-backed databases; `:memory:` skips the WAL statement. #### Behavior contract `sqlite.public.no-close` {/* api-behavior-authorities: [{"kind":"source-ast","file":"packages/sqlite-storage/src/checkpointer/saver.ts","selector":"B4SqliteSaver.publicMembers"},{"kind":"source-ast","file":"packages/sqlite-storage/src/threads/store.ts","selector":"ThreadsStore.publicMembers"}] */} The public SQLite saver and thread store expose no explicit close method. #### Database lifecycle and query caveats Each factory opens and retains a `DatabaseSync` handle. The public surface does not close automatically and provides no cleanup hook, so treat these as process-lifetime stores under the current API. Use separate files—such as `checkpoints.sqlite` and `threads.sqlite`—because both components use a global `schema_version` table and must not share one path. Saver `list()` orders lexically by checkpoint ID, ignores the `filter` option, and returns lightweight tuples without pending writes. Fetch a specific tuple for full pending-write hydration. Thread status and metadata updates for a missing ID are no-ops; metadata updates shallow-merge. ## Examples and related guides ```ts import { createThreadsStore, sqliteCheckpointer } from "@b4run/sqlite-storage" export const checkpointer = sqliteCheckpointer({ path: ".b4/checkpoints.sqlite" }) export const threadsStore = createThreadsStore({ path: ".b4/threads.sqlite" }) ``` Continue with [Persistence and Tenancy](/docs/persistence) and [CLI API](/docs/api/cli). --- ### Error Codes {/* GENERATED by scripts/generate-error-docs.mjs — do not edit by hand. */} # Error Codes Every user-facing B4.run failure carries a stable `B4_Exxxx` code. The code is printed on the surface where the failure appears — CLI stderr, HTTP/SSE error bodies, and permission-denial tool results — so a failure is searchable and linkable regardless of how you hit it. Codes are grouped by range: `E1xxx` config / `b4 check`, `E2xxx` sandbox, `E3xxx` permissions, `E4xxx` model / provider, and `E5xxx` runtime / import. A code may omit a docs link and still be a valid, searchable identifier. | Code | Meaning | Docs | | --- | --- | --- | | `B4_E1001` | Invalid tool scope | [/docs/tools#scoping-a-routes-tools](/docs/tools#scoping-a-routes-tools) | | `B4_E1002` | Invalid sandbox config | [/docs/configuration#sandbox](/docs/configuration#sandbox) | | `B4_E1003` | Unknown build target | [/docs/deployment](/docs/deployment) | | `B4_E1004` | Invalid delegation policy | [/docs/subagents#delegation-policy](/docs/subagents#delegation-policy) | | `B4_E1005` | Feature unsupported by the build target or runtime | [/docs/deployment](/docs/deployment) | | `B4_E2001` | Sandbox unavailable | [/docs/sandbox#what-it-is--and-isnt](/docs/sandbox#what-it-is--and-isnt) | | `B4_E2002` | Sandbox preflight failed | [/docs/sandbox#quickstart](/docs/sandbox#quickstart) | | `B4_E3001` | Permission denied | [/docs/permissions](/docs/permissions) | | `B4_E3002` | Subagent dispatch denied | [/docs/subagents#delegation-policy](/docs/subagents#delegation-policy) | | `B4_E3003` | Thread access policy failed to load | [/docs/thread-access#load-failures](/docs/thread-access#load-failures) | | `B4_E3004` | Middleware failed to load | [/docs/middleware#when-middleware-fails-to-load](/docs/middleware#when-middleware-fails-to-load) | | `B4_E4001` | Model provider package missing | [/docs/configuration](/docs/configuration) | | `B4_E4002` | Unknown model id | [/docs/configuration](/docs/configuration) | | `B4_E5001` | Import or export mismatch | — | | `B4_E5002` | Tool file has the wrong shape | [/docs/tools](/docs/tools) | | `B4_E5003` | Subagent unavailable or dispatch failed | [/docs/subagents#dispatch-failures](/docs/subagents#dispatch-failures) | | `B4_E5101` | Node version below the supported floor | — | | `B4_E5201` | Inspector server failed | [/docs/inspector](/docs/inspector) | | `B4_E5301` | Runtime store not provided | [/docs/deployment](/docs/deployment) | --- ### FAQ # FAQ The questions developers ask before adopting B4.run. Short, opinionated answers — verdict first, one piece of context, link to the deep page when there is one. This is not a troubleshooting guide and not a roadmap. ## Adopting B4.run ### Do I have to use LangGraph? Yes. B4.run is a meta-framework for LangGraph the way Next.js is a meta-framework for React. LangGraph runs the graph; B4.run writes the conventions around it — file-system routing, type inference, the dev server, the build step. If you do not want LangGraph, B4.run is the wrong tool. See [Mental Model](/docs/mental-model). ### Can I bring my own model provider? Yes. The built-in `agent()` route materializes to a LangChain chat model. B4.run infers providers for known model families and lazy-loads the matching LangChain integration package. Set `provider` explicitly to one of the supported built-in provider ids for aliases, ambiguous model names, local models, or provider-router model ids. Raw `graph` and `chain` routes can still instantiate any provider directly. See [API Reference](/docs/api). ### How does B4.run compare to Mastra / CopilotKit / Vercel AI SDK? They solve different problems. - **Vercel AI SDK** is a client-and-server toolkit for chat UIs and provider-agnostic LLM calls. B4.run does not compete with it — it sits one layer up, organizing whole agent projects, and you can use the AI SDK inside a B4.run route. - **CopilotKit** is a frontend-first framework for embedding copilots in existing apps. B4.run is backend-first; the deliverable is a deployable agent runtime, not in-app UI. They compose through B4.run's AG-UI endpoint: both `b4 dev` and `b4 start` serve `POST /agui/{routeId}` with the same route-execution middleware behavior, and the chat example wires a CopilotKit `HttpAgent` to it. - **Mastra** is a general-purpose agent framework with its own runtime and abstractions. B4.run is narrower: it does not replace LangGraph, it deletes the boilerplate around it. If you are already on LangGraph, B4.run fits. If you are not, pick the framework whose runtime you want to live in. ### Does B4.run support Python? No. B4.run is TypeScript-only and the type inference is the whole point — tool parameter types are read from function signatures at build time and emitted into `.b4/b4.generated.d.ts`. A Python port would lose that. Use `langgraph` directly in Python; LangSmith deploys both. ### Is B4.run production-ready? B4.run produces deployment artifacts; the framework itself is pre-1.0 and the API surface is still moving. `b4 build` emits a runnable `node` target (`server.mjs` plus a hardened `Dockerfile`) and a `langsmith` target (`langgraph.json`) by default, plus an opt-in `hono` target for Cloudflare Workers and other edge runtimes; `b4 start` serves the `node` target in production on `0.0.0.0`. Lock to a pinned version and read release notes between upgrades. See [Deployment](/docs/deployment). ## Working in B4.run ### Can I drop down to raw LangGraph when I need to? Yes — that is the point. A route's `index.ts` can default-export an `agent()` descriptor or named-export a `workflow`, `graph`, or `chain`; the `graph` shape hands you a raw `StateGraph` with no B4.run abstractions in the way. Mix shapes across routes in one project. See [Routes](/docs/routes). ### What does `b4 build` actually do? It walks your routes, runs typegen, then writes deployment artifacts for each configured `build.targets` (`node` and `langsmith` by default). The `node` target emits `.b4/build/server.mjs` — which boots the real B4.run runtime (Agent Protocol, AG-UI, and the sandbox if configured) — plus a hardened `Dockerfile`; run it with `b4 start` or `docker build`/`docker run`. The `langsmith` target emits `.b4/build/langgraph.json` plus a per-route entry file under `.b4/build/.ts`; each agent route's entry imports your default `agent()` descriptor, materializes it as a LangGraph graph, and wires in discovered tools. Each `assistant_id` is `#` — for example `/research#agent`. The opt-in `hono` target emits `.b4/build/app.mjs` plus a `wrangler.toml`, for edge runtimes. See [Deployment](/docs/deployment). ### Why a meta-framework instead of a library? A library is something you call. A meta-framework is something you write inside. The conventions B4.run enforces — folder-as-route, co-located tools, typed state per route — only pay off when the whole project follows them, the same way Next.js routing only pays off because every page lives under `app/`. A library version would surface all the same boilerplate B4.run is built to delete. See [Mental Model](/docs/mental-model). ## Operating B4.run ### Can I deploy outside LangSmith? Yes. `b4 build`'s `node` target emits a `server.mjs` that boots the real B4.run runtime plus a hardened `Dockerfile` — build the image and run it wherever you run containers, or serve it directly with `b4 start`. This is the only deployment path that engages the execution sandbox. If you'd rather containerize the `langgraph.json` output instead, `b4 build`'s `langsmith` target still emits it; feed `.b4/build/` to any container that runs the LangGraph runtime, but note that path does not run the B4.run runtime or the sandbox. See [Deployment](/docs/deployment). ### Can B4.run run on Cloudflare Workers? Yes, for apps that fit the edge subset. Add `"hono"` to `build.targets` and `b4 build` emits an `app.mjs` plus a `wrangler.toml` you deploy with `wrangler deploy`; durable state goes to Postgres over a `@neondatabase/serverless` WebSocket pool, set through a `DATABASE_URL` binding. No `nodejs_compat` flag is needed. The sandbox, workspace file and shell tools, tool-output offloading, and long-term memory are unavailable there, and the build fails naming them rather than degrading quietly — as does the runtime, per request, for anything that reached a filesystem-less runtime without passing that build gate. Route skills, `plan.md`, and `memory.md` are bundled into the manifest at build time and served from it at request time. Read [Edge and Hono](/docs/deployment/edge) before you commit to this path. The evidence behind that "yes" is a gated CI lane serving real AG-UI turns under **local** workerd — the same binary Cloudflare runs — and not a deploy to Cloudflare itself, which nobody has performed yet. [What is proven, and what is not](/docs/deployment/edge#what-is-proven-and-what-is-not) lists the gaps that leaves, bundle size and production connection limits among them. ### Can B4.run use Postgres for long-term memory? Yes. SQLite is the default local store, but `@b4run/memory-pgvector` provides a Postgres + pgvector backend for production and multi-instance deployments. Add `pgvectorMemoryStore({ connectionString, dimensions })` to `memory.store`, and add `openaiEmbedder()` when you want hybrid keyword + vector recall. See [Recall and Retrieval](/docs/memory/retrieval#postgres-backend-pgvector). ### How do I gradually migrate an existing LangGraph project? Move one graph at a time. Create a B4.run route, named-export your existing `StateGraph` from its `index.ts` as `graph`, and point the new `assistant_id` at it. Tools, prompts, and model providers come along unchanged — B4.run surrounds your code, it does not rewrite it. Once the route is live, peel state and tools out into the B4.run shapes if the ergonomics are worth it, or leave the graph as-is. ## Related --- ## Task-Specific Prompts ### Scaffold a new B4.run app Help me scaffold a new B4.run app from the default research starter. B4.run is a TypeScript-first meta-framework for building graph-based AI agents with file-system routing, shared and route-local tools, and inferred types. 1. Run the scaffold: ``` npm create b4-app@latest my-agent cd my-agent npm install ``` 2. Walk me through the generated project structure. Explain: - The two-package npm workspace: `server/` is the B4.run app and `web/` is the B4.run Workbench browser client. Every path below is relative to `server/`, and the root `package.json` scripts delegate into whichever package owns them. - How routes are directories containing an `index.ts` that exports exactly one of: default `agent(...)`, named `workflow` (async function), named `graph` (LangGraph graph), or named `chain` (LangChain LCEL Runnable). - `state.ts` — the optional Zod route state schema. - `src/tools/*.ts` — shared tools available across routes. The default research scaffold puts `searchCorpus` and `readDoc` here. - `src/app//tools/*.ts` — optional route-local tools. They are visible only to that route and shadow same-named shared tools. - `plan.md` — route-local planning seed that adds todo state and `writeTodos`. - `subagents//index.ts` — immediate child agent routes exposed through `task({ subagent, input })`; children receive shared tools and their own local tools, not the parent's local tools. - `skills//SKILL.md` — route-local instructions loaded on demand through `readSkill`. - `memory.md` — stable prompt memory for one route, and `memory.ts` — a typed long-term collection that contributes `recall` and `remember`. - `workspace/` — corpus, reports, and scripts; `workspace/AGENTS.md` is app-level prompt guidance shared by consuming agent routes and subagents. - Optional `sandbox` config — routes workspace filesystem and shell calls through a provider such as the Docker reference implementation. - Route groups like `(public)` — excluded from pathname when a template uses them. - Dynamic segments like `[tenant]` — preserved in the route id; provide values in JSON input when invoking the route. The optional `--template basic` scaffold uses `/hello/[tenant]`. - `.b4/b4.generated.d.ts` — auto-generated ambient types from the TypeScript compiler API. 3. Start with type generation, validation, typechecking, the offline deterministic agent harness tests, and the replay-backed eval. These need no model-provider key: ``` npm run typegen npm run check npm run typecheck npm test npm run eval ``` 4. Only then opt into a live model run. Copy the server package's environment template, require the user to add a real `OPENAI_API_KEY`, run the preflight, and start the tested dev script. Never invent or commit a key: ``` cp server/.env.example server/.env # Add a real OPENAI_API_KEY to server/.env npm run verify npm run dev:server ``` The generated dev script serves `http://127.0.0.1:3002`. 5. In a second terminal, start the B4.run Workbench — the generated `web/` package, an AG-UI/CopilotKit client with a thread rail, streaming transcript, plan and subagent activity cards, permission prompts, and memory review: ``` npm run dev:web ``` It serves `http://localhost:3010` and reaches the agent server through a same-origin proxy. No model key belongs in this package. 6. Show the Agent Protocol shape for the same route: ``` THREAD_ID=$(curl -s -X POST http://127.0.0.1:3002/threads -H 'content-type: application/json' -d '{}' | jq -r .thread_id) curl -s -X POST http://127.0.0.1:3002/threads/$THREAD_ID/runs/wait \ -H 'content-type: application/json' \ -d '{"route":"/research#agent","input":{"messages":[{"role":"user","content":"What are common agent architectures?"}]}}' ``` For streaming, use the same body with `POST /threads/$THREAD_ID/runs/stream` and consume the SSE events. 7. Summarize what I can build next: add a tool, add a new route, write an agent harness test, add a replay/live eval, or opt into sandboxed execution. Key packages: `@b4run/sdk` (authoring contract), `@b4run/langgraph` (graphs/workflows), `@b4run/langchain` (LCEL and provider-aware agent materialization), `@b4run/cli` (CLI). Reference: https://b4.run/llms.txt --- ### Add a tool Help me add a new tool to an existing B4.run app. B4.run discovers shared tools in `src/tools/*.ts` and route-local tools in `src/app//tools/*.ts`; their types are generated from TypeScript — no Zod schemas or manual type wiring. 1. Choose the tool's scope before creating it: - Put tools reused by multiple routes in `src/tools/`. This is where the default research scaffold keeps `searchCorpus` and `readDoc`. - Put a route-specific tool in `src/app//tools/`. A route-local tool is available only to that route and shadows a shared tool with the same name. 2. Add a TypeScript file with a default export that is an async function. This shared example is the default for the research scaffold: ```ts // src/tools/.ts export default async (input: { readonly /* fields */ }) => { // do work return { /* output */ } } ``` 3. The input parameter type and the return type are both inferred. B4.run extracts them at build time and writes them into `.b4/b4.generated.d.ts`. The tool becomes available on typed `ctx.tools` for eligible workflows and callable graph functions, and `b4 build` wires it into generated agent entries. 4. Run `b4 typegen` to regenerate types after adding the tool (or leave `b4 dev` running — it does this on every file save). 5. For a `workflow`, or a callable `graph` function that explicitly receives B4.run `RuntimeContext`, update `index.ts` to call the tool via `ctx.tools.({ ... })`. A precompiled raw LangGraph object's `.invoke()` treats its second argument as LangGraph `RunnableConfig`, not B4.run's typed `RuntimeContext`; it keeps the tools its implementation already owns or imports rather than expecting workflow-style `ctx.tools`. For an `agent` route, leave `index.ts` as the default `agent(...)` descriptor; B4.run materializes the agent with its eligible tools. 6. Re-run the route with `b4 run` and confirm the new tool is invoked end-to-end. Constraints: - The default export must be a function (arrow or async function declaration). - Input and output types must be serializable as JSON. - `readonly` is recommended on input fields; B4.run preserves it through type generation. Reference: https://b4.run/llms.txt --- ### Write a route Help me add a new route to an existing B4.run app. Routes are directories under `src/app/` where each directory maps to a URL-style pathname (minus route groups). 1. Create the route directory. For a route under a dynamic `[topic]` segment: ``` src/app//[topic]/ ``` 2. Optionally create `state.ts` — the route's Zod state schema: ```ts import { z } from "zod" export default z.object({ topic: z.string().default(""), question: z.string().default(""), }) ``` 3. Create `index.ts` — the route entry. Export exactly ONE of: **Workflow** (async function, most common): ```ts import state from "./state.js" export async function workflow(input: unknown) { const parsed = state.parse(input) return { ...parsed, result: parsed.topic } } ``` **Graph** (LangGraph graph/workflow): ```ts export const graph = /* langgraph graph instance */ ``` **Chain** (LangChain LCEL Runnable): ```ts export const chain = /* LCEL runnable */ ``` **Agent** (default descriptor): ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You are a helpful assistant.", }) ``` 4. If the route needs tools, add them at the appropriate scope: use `src/tools/*.ts` for tools shared across routes, or `src/app//[topic]/tools/*.ts` for route-local tools. A route-local tool shadows a same-named shared tool for that route. Then add a typed `RuntimeContext` parameter and call the tool through `ctx.tools`; otherwise keep the workflow tool-free. 5. Run `b4 routes` to confirm B4.run discovered the new route and what pathname it computed. Then `b4 run ''` with the required state via stdin. Constraints: - Exactly one of default `agent(...)`, named `workflow`, named `graph`, or named `chain` may be exported from `index.ts`. - Route groups in parentheses `(public)` are NOT part of the pathname. - Dynamic segment values, such as `topic`, come from the JSON input when invoking the parameterized route id. - The `RouteTools<"/path">` type is generated from the shared and route-local tools available to that route. Reference: https://b4.run/llms.txt --- ### Write a test Help me write tests for a B4.run route. Pick the right style for the route kind: 1. For an agent route like the default `/research#agent`, write a Vitest test with `createAgentHarness`, `script()` fixtures, and agent matchers: ```ts import { fileURLToPath } from "node:url" import { afterAll, it } from "vitest" import { createAgentHarness, expectFinalMessage, expectToolCalled, script } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const h = await createAgentHarness({ appRoot, route: "/research#agent" }) afterAll(async () => { await h.close() }) it("searches the corpus and writes a cited answer", async () => { h.reset() const run = await h.run({ input: "What are common agent architectures?", fixtures: script() .user("What are common agent architectures?") .callsTool("searchCorpus", { query: "agent architectures" }) .callsTool("readDoc", { path: "corpus/agent-architectures.md" }) .replies("ReAct and plan-and-execute are common. [corpus/agent-architectures.md]"), }) expectToolCalled(run, "searchCorpus") expectToolCalled(run, "readDoc") expectFinalMessage(run).toContain("[corpus/") }, 60_000) ``` 2. For deterministic `workflow`, `graph`, or `chain` routes, use a colocated `run.test.ts` scenario file: ```ts import { scenarios } from "@b4run/sdk/testing" export default scenarios("/hello/[tenant]") .scenario("returns a greeting", (s) => s .input({ tenant: "acme" }) .expectPassed() .expectOutput({ tenant: "acme", greeting: "Hello, acme!" }), ) ``` 3. In the route-scoped builder, `.input()` sets the route state and `.expectOutput()` matches the returned state. Set `.expectPassed()` or `.expectFailed()` explicitly. Keep output expectations for deterministic route results, not LLM text exact matches. 4. For an in-process scenario, replace only the external or nondeterministic application tool and assert its calls. Tool names, inputs, and awaited outputs come from the generated route types: ```ts import { scenarios } from "@b4run/sdk/testing" export default scenarios("/research").scenario("uses a controlled corpus result", (s) => s .input({ messages: [{ role: "user", content: "Research B4.run" }] }) .mockTool("searchCorpus", async ({ query }) => [ { path: "corpus/b4.md", score: 1, snippet: query }, ]) .expectPassed() .expectTool("searchCorpus", (call) => call.calledOnce().withArgs({ query: "B4.run" }), ), ) ``` 5. To exercise the live B4.run HTTP boundary instead, use a separate server-backed scenario. Server-backed scenarios cannot use tool mocks: ```ts import { scenarios } from "@b4run/sdk/testing" export default scenarios("/hello/[tenant]").scenario( "returns a greeting via the dev server", (s) => s .input({ tenant: "acme" }) .server("http://127.0.0.1:3001") .expectPassed() .expectOutput({ tenant: "acme", greeting: "Hello, acme!" }), ) ``` There is no command-level `--url` flag on `b4 test`. 6. Run agent Vitest files with the package's test runner (for the scaffold, `npm test`). Run route scenario suites with: ``` b4 test ``` Constraints: - Agent tests should use fixtures or live mode; do not exact-match raw assistant message arrays with `.expectOutput()`. - `run.test.ts` must live in the deterministic route's directory, default-export `scenarios("/route").scenario(...)`, and avoid `describe()` / `test()` wrappers. - In-process scenarios can replace selected application tools with `.mockTool()` and assert calls with `.expectTool()`; server-backed scenarios cannot use tool mocks. Reference: https://b4.run/llms.txt --- ### Choose a deployment target Help me choose and deploy the right B4.run build target. B4.run can emit a self-hosted Node server, an opt-in edge app, generated LangGraph entries, or any combination named in `build.targets`. 1. Verify the app before deployment: ``` b4 verify b4 test ``` `b4 verify` covers the app contract, route discovery, typegen, dependency/environment advisories, and runtime readiness. `b4 test` runs scenario tests. A configured `hono` target is capability-validated by both `b4 check` and `b4 build`. 2. Optionally catch B4.run HTTP protocol-shape issues before a Node or Hono deploy. Add `.server("http://127.0.0.1:3001")` to selected `scenarios(...)` builder chains, then run: ``` b4 dev --port 3001 & b4 test ``` This exercises the Agent Protocol thread lifecycle locally. LangSmith uses a distinct `assistant_id` request envelope, so test that platform boundary separately. 3. Make the target decision explicit in `b4.config.ts`. Naming targets replaces the defaults, so include every artifact this app needs: **B4.run Node runtime — full self-hosted surface** ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["node"] } }) ``` This emits `.b4/build/server.mjs`, a static module manifest, and a hardened Node 24 Dockerfile. Serve the B4.run runtime directly with `b4 start` or build the emitted Dockerfile. Ensure `@b4run/cli` is in `dependencies`, not `devDependencies`. Supply runtime secrets in the process/container environment: `b4 start` does not load the file named by `config.env`. The Node runtime serves Agent Protocol, AG-UI, middleware, and the configured sandbox. Its default local stores and in-process run/cancel registry require one replica unless thread-keyed stickiness or distributed coordination is guaranteed. **Hono edge app — compatible subset only** ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["hono"] } }) ``` This emits `app.mjs`, `modules.edge.mjs`, a per-request Postgres store factory, and `wrangler.toml`. It serves Agent Protocol, AG-UI, and middleware, with Postgres-backed checkpoints, threads, and permissions. It cannot serve sandbox, filesystem/shell workspace capabilities, tool-output offloading, route skills, or typed long-term memory; custom store handles are rejected by the capability gate. `memory.md` and `plan.md` do not activate without a filesystem marker provider. Configure `DATABASE_URL` and the generated runtime dependencies, then deploy only after the capability validation passes. Run/cancel coordination remains isolate-local, so the same stickiness/distributed-coordination rule applies. **LangSmith entries — platform-owned transport** ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["langsmith"] } }) ``` This emits `.b4/build/langgraph.json` and per-route entries keyed by `#`, such as `/research#agent`. These are generated graphs, not the B4.run HTTP server: B4.run middleware, AG-UI, and the sandbox manager are absent. The generated config currently sets `node_version: "22"`, while B4.run packages require Node >=24. Treat that as an unresolved compatibility mismatch and confirm the platform can run the required Node version before deployment. 4. Build the selected target only after verification and tests pass: ``` b4 build --clean ``` 5. Show me the exact files the build emitted, the command that starts or deploys them, the required runtime environment and storage, and one target-boundary smoke test. Refer to https://b4.run/docs/deployment for the full service and limitation matrix rather than reproducing it. Reference: https://b4.run/llms.txt --- ## Agent Config Templates ### AGENTS.md template # B4.run App — Coding Agent Instructions This project uses **B4.run**, a TypeScript-first meta-framework for building graph-based AI agents with the ergonomics of Next.js. When working in this project, follow the B4.run conventions below. ## Project Shape - **`b4.config.ts`** at the B4.run app root. Every path below is relative to that root — the project root in a single-package app, and the `server/` package in an app scaffolded from the default research template. Supported keys include: - `appDir` — route directory root; defaults to `src/app`. - `backends` — custom filesystem and exec backends for workspace tools. - `permissions` — mode plus allow/deny maps for tool and workspace gates. - `checkpointer` and `threadsStore` — durable thread/checkpoint overrides. - `env` — local env file for `b4 dev` and `b4 verify`; defaults to `./.env`. - `toolOutput` — offload large tool results into `workspace/tool-outputs/`. - `summarization` — opt-in conversation summary hook for long threads. - `sandbox` — execution sandbox configuration. - `memory` — long-term memory store, write governance, indexing, and recall tuning. - **`src/app/`** — all routes live here. A route is a directory containing `index.ts`. - **`src/app/**/index.ts`** — route entry. MUST export exactly ONE of: - `agent` — a `B4Agent` descriptor from `@b4run/sdk`, typically the `default` export. Preferred for LLM-driven routes; tools are wired into the generated graph at build time. - `workflow` (async function — explicit code-driven orchestration) - `graph` (LangGraph graph instance) - `chain` (LangChain LCEL Runnable) - **`src/app/**/state.ts`** — optional route state schema (default-exported Zod or Standard Schema value). Imported by `index.ts` when the route needs typed state. - **`src/app/**/tools/*.ts`** — co-located tools. Each file has a default export that is an async function. Types are inferred and written to `.b4/b4.generated.d.ts`. - **`src/tools/*.ts`** — shared tools (optional). Discovered alongside route-local tools and merged into every route's tool registry. Route-local tools override shared tools with the same name. - **`src/middleware.ts`** — optional. Default-exports a function returned by `defineMiddleware(...)`. Runs before every local `/threads/:thread_id/runs/wait`, `/threads/:thread_id/runs/stream`, and `/threads/:thread_id/resume` request handled by `b4 dev`. - **`src/app/**/run.test.ts`** — colocated scenario tests. Default-export a route-scoped suite built with `scenarios("/route").scenario(...)` from `@b4run/sdk/testing`. Each scenario uses `.input()` and an explicit `.expectPassed()` or `.expectFailed()`, followed by expectations such as `.expectOutput()`, `.expectMeta()`, or `.expectError()`. In-process scenarios can use `.mockTool()` and `.expectTool()`; server-backed scenarios use `.server(url)` and cannot use tool mocks. - **`.b4/b4.generated.d.ts`** — auto-generated. Do NOT edit by hand. - **`b4:routes`** — virtual module backed by `.b4/b4.generated.d.ts`. If `RouteTools` does not resolve, run `b4 typegen`. ## Pathname Rules - Directory segments become URL pathname segments. - Segments in parentheses `(public)` are route groups — excluded from the pathname. - Segments in brackets `[tenant]` are dynamic — callers pass the matching values in JSON input when invoking the parameterized route id. Examples: - Default research scaffold: `src/app/research/index.ts` → route id `/research`; agent route key `/research#agent`. - Optional basic scaffold (`pnpm create b4-app my-app -- --template basic`): `src/app/(public)/hello/[tenant]/index.ts` → route id `/hello/[tenant]`; callers pass `tenant` in JSON input. ## Defining an Agent Route ```ts // src/app/research/index.ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You are a research coordinator. Search the local corpus, dispatch specialists when useful, and cite every claim.", // Optional retry policy: // retry: { maxAttempts: 3, baseDelay: 250 }, }) ``` - `model` is a `KnownModelId` (autocomplete for listed ids, plus any custom string). - `provider?: ModelProviderId` is optional. B4.run infers providers for known model families; set it explicitly to one of the supported built-in provider ids for aliases, ambiguous model names, local models, or provider-router model ids. Raw graph/chain routes can still instantiate any provider directly. - `retry?: { maxAttempts?: number, baseDelay?: number }` — applied per agent call. - Tools in the same route's `tools/` directory (and shared tools in `src/tools/`) are automatically wired into the generated agent graph at `b4 build` time. ## Tool Authoring ```ts // src/app/research/tools/searchCorpus.ts export default async ( input: { readonly query: string }, ctx: { signal: AbortSignal; middleware?: Readonly> }, ) => { return [ { path: "corpus/agent-architectures.md", score: 2, snippet: "ReAct and plan-and-execute are common agent architectures.", }, ] } ``` - Input type is inferred from the parameter annotation; output type from the return. - The second parameter is optional but recommended: - `ctx.signal` — `AbortSignal` for cooperative cancellation. Pass it to `fetch()` and any awaited operations. - `ctx.middleware` — readonly bag populated by `allow({ ... })` in `src/middleware.ts`. Request-scoped context (auth, tenancy, etc.) flows through here. - Use `readonly` on input fields; B4.run preserves it. - Input and output must be JSON-serializable (no `Date`, `Map`, classes, functions). - Tools may live in either route-local `tools/` (preferred default) OR shared `src/tools/`. Route-local names override shared names. ## Middleware ```ts // src/middleware.ts import { allow, defineMiddleware, reject } from "@b4run/sdk" export default defineMiddleware(async (req) => { if (!req.headers["x-tenant-id"]) { return reject(401, { error: "missing x-tenant-id" }) } return allow({ tenantId: req.headers["x-tenant-id"] }) }) ``` - `MiddlewareRequest`: `{ assistantId, headers, method, params, routeId, url }`. - Return `reject(status, body?)` to short-circuit the request, or `allow(context?)` to continue. - The `context` passed to `allow(...)` is forwarded to every tool as `ctx.middleware`. ## Route Entry — workflow form (alternative to agent) ```ts // src/app/research/index.ts import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" import type { z } from "zod" import type state from "./state.js" type ResearchState = z.infer export async function workflow( state: ResearchState, ctx: RuntimeContext>, ) { // ctx.signal is the request-scoped AbortSignal. // ctx.tools.searchCorpus is fully typed from the route's tools/ directory. const matches = await ctx.tools.searchCorpus({ query: state.context }) return { ...state, context: matches.map((match) => `${match.path}: ${match.snippet}`).join("\n"), } } ``` The `RouteTools<"/research">` lookup uses the route's pathname as the key — these keys are populated by `b4 typegen`. Run `b4 typegen` if `b4:routes` does not resolve. ## Commands (run via `pnpm exec`) - `b4 add [name]` — add B4.run-authored templates or components. - `b4 build` — write `.b4/build/langgraph.json` and per-route entry files for LangSmith deployment. Generated route keys are `#` (e.g. `/research#agent`). - `b4 check` — validate app structure/config (lightweight). - `b4 dev` — local Agent Protocol runtime server. - `b4 docs [topic]` — print local documentation snippets. - `b4 eval [path]` — run eval definitions. - `b4 memory [subcommand] [args...]` — inspect and manage long-term memory. - `b4 routes` — list discovered routes. - `b4 run ` — execute a route once with JSON stdin/stdout. - `b4 test [path]` — run colocated scenario tests. - `b4 typegen` — regenerate `.b4/b4.generated.d.ts` and per-route `tools.json` / `state.json`. - `b4 verify` — full integrity check across app, routes, typegen, deps. Preferred CI gate. - `echo '{"messages":[{"role":"user","content":"What are common agent architectures?"}]}' | b4 run /research` — execute the default scaffold route. ## Agent Protocol `b4 dev` exposes thread-scoped Agent Protocol endpoints: - `GET /healthz` - `POST /threads` - `GET /threads/:thread_id` - `DELETE /threads/:thread_id` - `POST /threads/:thread_id/runs/wait` - `POST /threads/:thread_id/runs/stream` - `GET /threads/:thread_id/state` - `POST /threads/:thread_id/resume` Run and stream bodies require a route key and optional input: ```json { "route": "/research#agent", "input": { "messages": [{ "role": "user", "content": "What are common agent architectures?" }] } } ``` Resume resolves a parked human-in-the-loop interrupt and streams the continuation: ```json { "resume": [ { "interruptId": "", "status": "resolved", "payload": "once" } ], "route": "/research#agent" } ``` The `resume` array must address every currently pending interrupt exactly once. A resolved `payload` must be `once`, `always`, or `deny`; a `cancelled` entry omits `payload` and maps to denial. The complete envelope and `route` are required. ## Packages - `@b4run/sdk` — authoring contract: `agent`, `defineMiddleware`, `allow`, `reject`, types (`RuntimeContext` carries `signal: AbortSignal`, `AgentConfig`, `ReasoningConfig`, `RetryConfig`, `MiddlewareRequest`, etc.). - `@b4run/langgraph` — adapter for LangGraph graphs and workflows. - `@b4run/langchain` — adapter for LangChain LCEL chains. - `@b4run/cli` — the `b4` CLI. Test helpers live at `@b4run/sdk/testing`. ## Do Not - Do NOT edit `.b4/b4.generated.d.ts` or files under `.b4/`. - Do NOT add Zod schemas for tool input/output — types are inferred from TypeScript source. - Do NOT export more than one of `agent`/`workflow`/`graph`/`chain` from a single `index.ts`. - Do NOT rely on concrete paths like `/hello/acme` for dynamic segments. Invoke the parameterized route id, such as `/hello/[tenant]` in the optional basic template, and pass values in JSON input. - Do NOT edit `.b4/build/langgraph.json` by hand. To deploy, run `b4 build` and hand `.b4/build/` to LangSmith. ## Reference - Full agent-consumable reference: https://b4.run/llms-full.txt - Compact summary: https://b4.run/llms.txt - Human docs: https://b4.run/docs/getting-started --- ## Historical Blog Archive The posts below are historical, non-normative snapshots. Current contracts are the Documentation, Task-Specific Prompts, and Agent Config Templates above. ## Eve validates the shape. Now pick your runtime. --- title: Eve validates the shape. Now pick your runtime. description: Vercel's eve and B4.run both structure agents as directories; compare their runtimes, deployment models, channels, sandboxing, and tooling. date: 2026-06-18 tags: [philosophy, agents] type: post author: brian --- Yesterday Vercel shipped [eve](https://vercel.com/changelog/introducing-eve-an-open-source-agent-framework), an open-source framework for building agents. I have spent a fair amount of time with the launch material, and I want to say the obvious thing first: it looks great. An agent in eve is a directory of files. Instructions live in `instructions.md`. The model lives in `agent.ts`. Tools, skills, subagents, channels, and schedules are files and folders you add as you grow. The launch demo even runs on Claude Opus 4.8. If you have read anything I have written about B4.run, you can probably guess my reaction. This is not a threat. This is validation. ## Why this is good news I built B4.run on one bet: an agent application needs application *structure*, not just a runtime. That bet only pays off if it is actually correct. It is easy to convince yourself that the shape in your head is the right one. It is much more convincing when an independent team, at Vercel's level, ships the same idea on the same day you would have argued for it. Two teams, working separately, landed on agents-as-directories. When that happens, the shape is not a matter of taste anymore. It is a signal. So I am glad eve exists. Competition is good. It moves the whole space toward conventions, and conventions are what let the rest of us stop hand-wiring the same registries over and over. ## Where eve and B4.run agree The overlap is striking. Here is the shared thesis, as plainly as I can state it: - An agent, or a route, is a folder. - Tools are files next to the thing that uses them. - Instructions and skills are markdown. - Subagents, planning, and human-in-the-loop compose through the tree, not through hidden setup code. - You can open the file tree and get a useful read on the application. Both frameworks believe the structure should do work. You can move a folder, delete it, review it in a pull request, and answer "what can this agent do?" from disk. That is the part that matters, and eve clearly gets it. ## An honest side-by-side Where the two diverge is worth being precise about, including where eve is ahead. This began as a dated comparison on June 18, 2026. **Update note (July 2026):** the B4.run side of the sandboxing, testing, and eval rows below now reflects current main and current docs. Opt-in sandboxing and the fuller testing/eval story shipped or expanded after the original June 18 comparison, so treat this as a current addendum rather than the exact launch-day snapshot. | Capability | eve | B4.run | | --- | --- | --- | | Agent unit | Directory of files | Route folder under `src/app/` | | Instructions | `instructions.md` | `systemPrompt` + `AGENTS.md` memory | | Tools | Files in the agent directory | Route-local `tools/`, input types inferred from TypeScript | | Planning / skills | Markdown files | `plan.md` + `skills//SKILL.md` | | Subagents | Built in | Built in (`subagents/` or descriptor) | | Human-in-the-loop | Built-in approvals | Permission gate + Agent Protocol resume | | Durable execution | Built in | SQLite checkpointer; threads survive restart | | Sandboxed compute | First-class adapter (Vercel Sandbox / Docker / microsandbox / just-bash) | Opt-in `sandbox` config with a provider contract and Docker reference implementation | | Channels | Slack, Discord, GitHub | None built in; you wire your own over the HTTP API | | Observability | OpenTelemetry tracing | LangSmith tracing (automatic via LangGraph) | | Evals / testing | Built in | `b4 eval` replay/live modes plus `@b4run/testing` harnesses and matchers | | Runtime | Vercel | LangGraph.js | | Deploy | `vercel deploy`, unchanged project | `b4 build` → `langgraph.json` → LangSmith | | License | Apache 2.0 | MIT | A table flattens nuance, so let me expand the rows that actually matter. **File conventions.** Nearly the same philosophy, with small shape differences. eve uses `agent.ts` plus `instructions.md` at the root of an agent directory. B4.run uses a route folder under `src/app/` with an `index.ts` descriptor, route-local `tools/`, `state.ts`, and markdown for memory, planning, and skills. If you are comfortable in one, you will be comfortable in the other. The one B4.run detail I would point to is that a tool's input type comes from the TypeScript function signature, not a hand-written schema, so the type is the contract the model sees. **Sandboxed compute.** eve ships sandboxed compute as a first-class capability, with an adapter that runs on Vercel Sandbox when deployed and on Docker, microsandbox, or just-bash locally. As of the July 2026 update, B4.run ships the isolation layer too: add `sandbox` to `b4.config.ts` and thread workspace file and shell operations route through a provider-agnostic `SandboxProvider`; `@b4run/sandbox` includes a Docker reference implementation. The honest limitation is scope, not existence: Docker `network: { mode: "deny" }` is exact, while allow-mode host denylists are best-effort, and hostile-grade multi-tenant isolation should use a stronger custom provider such as a microVM or hosted sandbox. If sandboxing is load-bearing, compare the concrete provider you plan to run, not just the framework checkbox. **Channels.** eve ships Slack, Discord, and GitHub as built-in surfaces. B4.run does not. B4.run is a headless runtime that exposes the Agent Protocol over HTTP, and you bring the surface: a chat UI, a Slack bot, a cron job, whatever points at the API. That is more wiring up front and more freedom after. If you want a Slack agent this afternoon, eve is the shorter path. **Durable execution, tests, and evals.** Both ship these, and I think they are close. B4.run persists threads through a SQLite checkpointer, so a conversation survives a process restart. As of the July 2026 update, `@b4run/testing` ships `createAgentHarness`, fixture replay, tool/final-message matchers, and route/tool/workspace harnesses for CI-safe scenario checks. For quality gates, `b4 eval` runs co-located evals in deterministic replay mode, with `--live` and `--record` for local real-model measurement and fixture capture. eve ships durable execution and a built-in evals system in the same spirit. This is convergence again, not a gap. **Runtime and deploy.** eve is a Vercel project. Its best trick is that `vercel deploy` ships the agent to production unchanged, exactly as it ran on your machine. B4.run is built on the open [Agent Protocol](/docs/dev-server), the runtime contract LangGraph.js implements. `b4 dev` serves the Agent Protocol locally, and `b4 build` emits a `langgraph.json` you deploy to LangSmith. **Ecosystem.** eve sits in the Vercel and AI SDK orbit. B4.run sits in the LangChain and LangGraph.js orbit. Neither is wrong. They are different centers of gravity, and which one fits you is mostly a question of where the rest of your stack already lives. ## When each one is the better choice I would rather give you a decision than a sales pitch. Here is how I would choose. **Reach for eve when:** - You are already deploying on Vercel and want `vercel deploy` to carry the agent too. - You want eve's hosted/channel-integrated sandbox adapters out of the box. - You want Slack, Discord, or GitHub as a built-in channel with little wiring. - You value a single vendor owning the whole path from dev to production. **Reach for B4.run when:** - You are already working with LangChain and LangGraph.js, or you deploy to LangSmith. - You want the runtime contract to be an open protocol you can host yourself. - You want your agent code to outlive any one vendor's product plan. - You want tool input types inferred from TypeScript rather than written twice. Neither list is a knock on the other tool. They are honestly different bets, and the right answer depends on where your stack already lives. ## The real difference is the bet eve's superpower is that an agent is an ordinary Vercel project. That is genuinely great, and it is also the gravity well. The thing that makes deploy effortless is the thing that ties the agent to one platform. B4.run makes a different bet. The runtime contract is the Agent Protocol, an open HTTP shape you can host yourself, point any Agent Protocol client at, and deploy to LangSmith. There is no single cloud you have to be native to. That bet is not free. It means B4.run leans on the LangGraph.js runtime and the LangSmith deployment target instead of owning the whole path end to end. From my experience, that is the right trade for teams who want their agent code to outlive any one vendor's product plan. Your mileage may vary, and if you are already all-in on Vercel, eve removes friction B4.run cannot. ## Try the shape that fits you Here is the honest summary. Two independent teams converged on the same idea, which means the idea is sound: agent applications should be directories of files, and the file tree should be the source of truth. So pick the runtime that fits where you already are. If you are building on Vercel, eve is a great choice and you should try it. If you love this shape but you are not in the Vercel ecosystem, or you are already working with LangChain and LangGraph.js, B4.run gives you the same file-based conventions on an open runtime: ```bash pnpm create b4-app my-agents cd my-agents pnpm dev ``` For the deeper read, start with [Mental Model](/docs/mental-model), [Routes](/docs/routes), and the [Dev Server](/docs/dev-server). Competition like this is the best thing that can happen to an idea. It is good for eve, it is good for B4.run, and it is good for anyone trying to build agents that read like real applications. **Sources:** [Vercel changelog](https://vercel.com/changelog/introducing-eve-an-open-source-agent-framework) · [eve docs](https://vercel.com/docs/eve) · [The New Stack](https://thenewstack.io/vercel-launches-eve-an-open-source-framework-that-treats-agents-as-directories/) · [MarkTechPost](https://www.marktechpost.com/2026/06/17/vercel-releases-eve/) ## The App Router for AI Agents --- title: The App Router for AI Agents description: File-system routes, type-safe tools, and the capability layer B4.run adds around real LangGraph.js agent applications. date: 2026-05-19 tags: [philosophy, typescript, agents] type: post author: brian --- The Next.js App Router changed how many of us think about application structure. Not because a file-system router is a new idea. The important part was that it gave common web application concepts a place to live. A page is a file. A layout is a file. A route group is a folder. You can open the tree and get a useful read on the application. I want the same thing for agent applications. Agent codebases need more than a runtime. They need a project shape that can hold tools, state, tests, memory, planning, skills, and subagents without turning into a pile of registries. That is the App Router idea behind B4.run. ## Goals Here is the practical version: - A route should be a folder under `src/app/`. - The route path should be the agent endpoint. - Tools should live next to the route that uses them. - Tool types should come from TypeScript, not duplicated schemas. - Tests should live beside the route they protect. - Agent behavior should compose through files and descriptors, not hidden setup code. If a developer can open the file tree and understand where to add the next thing, the framework is doing useful work. ## What the route tree tells you Here is a small B4.run application: ```text src/ app/ support/ [tenant]/ index.ts state.ts plan.md run.test.ts tools/ lookupOrder.ts escalate.ts skills/ refund-policy/ SKILL.md subagents/ research/ index.ts tools/ searchDocs.ts ``` Read that tree out loud and you already know quite a bit. There is a parameterized route at `/support/[tenant]`. It has its own route entry in `index.ts`, its own state schema, a seeded plan, a scenario test, two route-local tools, one route-local skill, and a research subagent. That is not just aesthetic. It changes how the codebase behaves. You can move the route. You can delete it. You can review it in a pull request. You can ask, "what tools does this agent have?" and answer the question from the folder. The structure is doing work. ## Routes A B4.run route is a folder under `src/app/`. The folder path becomes the route id. Route groups are ignored: ```text src/app/(public)/hello/[tenant]/index.ts ``` becomes: ```text /hello/[tenant] ``` The route entry is `index.ts`. It exports exactly one route shape: - `agent` for an LLM-driven route - `workflow` for a deterministic async function - `graph` for a raw LangGraph graph - `chain` for a LangChain runnable The default scaffold uses an agent: ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-4o-mini", systemPrompt: "You are a helpful assistant for the {tenant} organization.", }) ``` If you need full LangGraph control, export a named `graph`. If you need a deterministic flow, export a `workflow`. The route folder is the stable boundary either way. ## Tools In B4.run, a tool is a TypeScript file in `tools/`. ```ts // src/app/support/[tenant]/tools/lookupOrder.ts export const description = "Look up an order by id." export default async ( input: { readonly orderId: string }, ctx: { readonly signal: AbortSignal }, ) => { const res = await fetch(`https://api.example.com/orders/${input.orderId}`, { signal: ctx.signal, }) return (await res.json()) as { readonly status: string } } ``` There is no `tool()` wrapper in the route. There is no duplicate input schema. B4.run reads the function signature during type generation and build. The input type becomes the JSON schema exposed to the model, and the generated `b4:routes` module gives route code typed access to `ctx.tools.lookupOrder`. This is the part I care about most: the TypeScript type is the contract. ## State and tests Routes can also define `state.ts`. State is the JSON shape that flows through the route runtime. The scaffold uses Zod, but B4.run accepts Zod or any Standard Schema value. ```ts // src/app/support/[tenant]/state.ts import { z } from "zod" export default z.object({ tenant: z.string(), orderId: z.string().optional(), }) ``` B4.run generates route state types from this file and the route path. If you rename a dynamic segment, or change the state shape, TypeScript can catch the places that still assume the old shape. Tests live next to the route too: ```text src/app/support/[tenant]/run.test.ts ``` That sounds small, but it matters. The test changes in the same pull request as the route. It does not live in a distant test directory that slowly stops explaining the feature it was written for. ## Agent behavior The route tree gives B4.run a place to add higher-level agent behavior. Here is what we have built to date. ### Memory If `workspace/AGENTS.md` exists, B4.run injects it into the agent prompt under `# Memory` on every model turn. The file is the memory. The agent can update it, and the next turn sees the updated content. ### Planning If a route has `plan.md`, B4.run adds a planning prompt, a `writeTodos` tool, a `todos` state channel, and a `plan_update` stream event. The seed file uses normal markdown checklist syntax: ```md - [ ] Review the customer request - [ ] Check order history - [ ] Write a concise response ``` The useful part is that planning is not just a prompt. It is prompt, tool, state, and stream behavior composed together. ### Skills If a route has `skills//SKILL.md`, B4.run lists the available skills in the prompt and gives the agent a `readSkill({ name })` tool. That lets the agent load longer instructions only when it needs them. ### Subagents A route can expose subagents through child routes under `subagents/` or through the `subagents` field on `agent({...})`. B4.run adds a `task({ subagent, input })` tool and a `# Subagents` prompt section. The parent agent can delegate to a specialist without the specialist becoming a random helper function hidden in another folder. The boundary stays visible on disk. ### Reasoning effort For OpenAI-backed agent routes, the descriptor can include: ```ts reasoning: { effort: "high" } ``` Non-reasoning models ignore it. For models that support the setting, the option stays close to the route that needs it. ## What this buys you The benefit is not the first route. The first route is always easy. The benefit shows up when the app has ten routes, forty tools, a few specialists, and enough state that a hand-written registry starts to feel like another product you have to maintain. At that point, the App Router idea earns its place: - file moves are meaningful - type generation has one source of truth - tests have a home - generated deployment artifacts are predictable - memory, planning, skills, and subagents compose around the route instead of around a hidden framework object This does not make B4.run necessary for every project. If you have one graph and two tools, raw LangGraph.js may be exactly right. But if your agent application is starting to look like an application, it needs application structure. That is what B4.run is trying to provide. ## Getting started The fastest way to try the shape is still the scaffold: ```bash pnpm create b4-app my-agents cd my-agents pnpm dev ``` Then run the example route: ```bash echo '{"tenant":"acme"}' | pnpm exec b4 run '/hello/[tenant]' ``` For the deeper read, start with [Mental Model](/docs/mental-model), [Routes](/docs/routes), [Memory](/docs/memory), [Planning](/docs/planning), [Skills](/docs/skills), [Subagents](/docs/subagents), and [Reasoning Effort](/docs/reasoning-effort). ## Why we built B4.run --- title: Why we built B4.run description: B4.run is a TypeScript-first framework for building LangGraph.js agents with file-system routes, route-local tools, generated types, and a local dev loop. date: 2026-05-12 tags: [philosophy] type: post author: brian --- I built B4.run because agent codebases were starting to feel harder to maintain than they needed to be. Not because LangGraph.js is the wrong runtime. In fact, I like the runtime. I like the graph model. I like that LangSmith gives teams a real deployment target. The problem I kept running into was the code around the graph: the project layout, the tool wiring, the generated artifacts, the local feedback loop, and the small conventions every team has to invent before they can build the thing they actually care about. This is the problem B4.run is trying to solve. ## The short version B4.run is a TypeScript-first framework for building LangGraph.js agents. The goal is not to replace LangGraph.js. The goal is to make the application around LangGraph.js easier to build, test, and deploy. In practice, B4.run gives you: - file-system routes under `src/app/` - route-local tools with input and output types inferred from TypeScript - optional route state through `state.ts` - a local `b4 dev` runtime with `/runs/wait` and `/runs/stream` - `b4 typegen` for `.b4/b4.generated.d.ts` - `b4 build` for `.b4/build/langgraph.json` and per-route LangGraph entry files - built-in agent behavior for memory, planning, skills, and subagents That list is intentionally practical. It is the stuff you otherwise end up writing by hand. ## The problem I kept seeing The first version of B4.run was not a framework. It was a folder of helpers that kept showing up in LangGraph.js projects. One project had four graphs, eleven tools, a hand-written `langgraph.json`, and a registry file that existed mostly to explain the rest of the files to each other. Graphs lived in one directory. Tools lived in another. State definitions lived somewhere else. Tests drifted from the route they were meant to protect. Every new feature touched too many places. At some point I started writing internal documentation that explained where new code was supposed to go. That was the signal. If I need a memo to explain the file layout, the framework is missing a piece. ## What I wanted instead I wanted the same thing I like about good web application frameworks: a boring answer to "where does this code go?" A route should be a folder. A tool should live with the route that uses it. State should have one file. The dev server should notice changes. The build step should produce the artifact the runtime expects. Types should follow the code instead of asking the developer to copy a shape from one file into another. None of that is especially magical. That is the point. The best framework conventions usually feel obvious after you use them for a week. ## What a route looks like A B4.run route is just a folder under `src/app/`. For example: ```text src/ app/ (public)/ hello/ [tenant]/ index.ts state.ts tools/ greet.ts ``` The route id is `/hello/[tenant]`. The route group `(public)` is ignored, the same way route groups work in modern web frameworks. The route entry can be an `agent()` descriptor: ```ts // src/app/(public)/hello/[tenant]/index.ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-4o-mini", systemPrompt: "You are a helpful assistant for the {tenant} organization.", }) ``` And a tool is a plain TypeScript function: ```ts // src/app/(public)/hello/[tenant]/tools/greet.ts export const description = "Greet a user by name." export default async ({ name }: { readonly name: string }) => { return `Hello, ${name}!` } ``` There is no `tool()` wrapper here. There is no hand-written Zod schema for the tool input. B4.run reads the function signature, generates the tool schema for the model, and writes the typed route registry to `.b4/b4.generated.d.ts`. The same discovered tool is wired into generated agent deployment entries when you run `b4 build`. That is the core bet: your TypeScript source should be the contract. ## What happens at build time `b4 build` does three things that I used to do by hand: 1. It discovers the routes in `src/app/`. 2. It runs type generation for route state and route-local tools. 3. It writes `.b4/build/langgraph.json` plus per-route entry files. For an agent route, the generated entry imports your default `agent()` descriptor, materializes it as a LangGraph graph, wires in the route-local tools, and maps the assistant id to the generated graph export. So a route like `/hello/[tenant]` becomes an assistant id like: ```text /hello/[tenant]#agent ``` This matters because the B4.run project structure stays a TypeScript authoring experience, while the output is still a LangGraph-compatible deployment package. ## What we have built to date The foundation of B4.run is routing, tools, types, a dev server, and build output. That proves the basic shape, but real agent applications need more than a single tool-calling loop. Here is what we have built to date around that route structure. ### Memory If `workspace/AGENTS.md` exists, B4.run injects it into the agent prompt under a `# Memory` heading on every model turn. This is intentionally simple. The file is the memory. The model sees the current contents. If your app updates the file, the next turn sees the updated memory. There is a size limit and there are no hidden databases involved. That is a tradeoff I like for this layer. ### Skills A route can include skills under: ```text src/app//skills//SKILL.md ``` B4.run lists the available skills in the prompt and exposes a `readSkill({ name })` tool so the agent can load the full instructions when it needs them. This keeps long-form instructions out of the prompt until they are useful. ### Planning A route with a `plan.md` file opts into the planning capability. B4.run gives the agent a `writeTodos` tool, a `todos` state channel, and a `plan_update` stream event. The agent can maintain a real plan during multi-step work, and the runtime can stream those updates to a UI. The important part is not the todo list itself. The important part is that a capability can add tools, prompt fragments, state, and stream behavior as one unit. ### Subagents Subagents are becoming a first-class composition boundary. A parent route can expose child agents through the `subagents` field on `agent({...})`, or by placing child routes under a `subagents/` directory. B4.run adds a `task({ subagent, input })` tool and a prompt section that tells the parent what specialists are available. That makes a subagent feel less like an imported helper function and more like a route with its own prompt, tools, and state. ### Reasoning effort `agent({...})` also accepts: ```ts reasoning: { effort: "high" } ``` For OpenAI-backed agent routes, B4.run maps that to the model's reasoning effort parameter when the model supports it. Non-reasoning models ignore it. That is the kind of option I want in the descriptor: close to the route, explicit, and easy to review in code. ## What B4.run is not It is important to be precise here. B4.run is not a new model provider abstraction. B4.run is not trying to hide LangGraph.js. B4.run is not necessary for every agent project. If you have one graph, two tools, and no need for a framework, raw LangGraph.js may be the better choice. But if your project is starting to need route conventions, generated types, a dev server, tests beside the routes, generated deployment artifacts, and reusable agent behavior, then the framework starts to earn its place. ## Try it The fastest way to get a feel for the framework is still the scaffold: ```bash pnpm create b4-app my-agents cd my-agents pnpm dev ``` Then run the example route: ```bash echo '{"tenant":"acme"}' | pnpm exec b4 run '/hello/[tenant]' ``` If you want the mental model first, start with [Mental Model](/docs/mental-model). If you want the file conventions, start with [Routes](/docs/routes). If you have an existing LangGraph.js project, the [migration guide](/docs/migrating-from-langgraph) walks through the move construct by construct. ## Conclusion B4.run exists because I wanted agent code to have a shape that editors, tests, dev servers, and deployment tools could all understand. The runtime is still LangGraph.js. The difference is the application structure around it. Routes are folders. Tools are local. Types are generated. Agent behavior composes around the route. The build output is explicit. That is the ordinary work of a framework. And for agent applications, I think that ordinary work matters.