# 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, converted to plain Markdown. 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 Source: https://b4.run/docs/getting-started Learn how to build your first agent with B4.run. In B4.run, an agent is a folder. The folder is the route, and the files in its `tools/` folder are the functions the agent can call. B4.run reads your TypeScript types and writes the tool schemas for you. Copy-ready prompt: https://b4.run/prompts/scaffold Prefer to build with a coding agent? Copy the prompt above and paste it into Claude Code, Cursor or your agent of choice. #### What will we build? We'll start with a small agent that greets people by name. Then we'll give it a second tool that tells the time in any city. It takes about five minutes. You need Node.js 24 or later and npm 11. The last step calls a real model, so you also need an OpenAI API key. #### Create the app First, scaffold the app and install its dependencies: ```bash npm create b4-app@latest my-agent cd my-agent npm install ``` #### Look at the agent The agent lives in `src/app/hello/`. Let's look at the two files that matter: `src/app/hello/index.ts`: ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You are a friendly assistant. Use greet to greet people by name.", }) ``` `src/app/hello/tools/greet.ts`: ```ts /** Greet someone by name. */ export default async (input: { readonly name: string }) => { return { message: `Hello, ${input.name}!` } } ``` A few things to note: - The folder name is the route. This agent answers at `/hello`. - `index.ts` default-exports the agent. We choose a model and write a system prompt. - Each file in `tools/` is one tool. The file name is the tool name. - The comment above the function is the tool description the model reads. - The `input` type is the tool's schema. We never write a JSON schema by hand. #### Add a tool Now, let's give the agent a second tool. Create `src/app/hello/tools/getLocalTime.ts`: `src/app/hello/tools/getLocalTime.ts`: ```ts /** Get the current local time in an IANA time zone, such as "Asia/Tokyo". */ export default async (input: { readonly timeZone: string }) => { const time = new Date().toLocaleTimeString("en-US", { timeZone: input.timeZone }) return { timeZone: input.timeZone, time } } ``` That's it. We don't register the tool anywhere. The agent picks it up because it lives in the route's `tools/` folder. #### Check the app Next, check the app: ```bash npx b4 check ``` `check` validates the routes, tools and configuration: ``` B4.run app is valid: 1 routes discovered. - /hello (agent) ``` We don't generate anything by hand. `b4 run`, `npm test` and the dev server write the tool schemas and types to `.b4/` before they run. #### Run the agent Set your API key and send the agent a message: ```bash export OPENAI_API_KEY=sk-... echo '{"messages":[{"role":"user","content":"Say hello to Ada and tell her the time in Tokyo."}]}' | npx b4 run /hello ``` `b4 run` prints the whole run as JSON. The messages show the model calling `greet` with `{ "name": "Ada" }` and `getLocalTime` with `{ "timeZone": "Asia/Tokyo" }`. The last message is the answer: ``` Hello, Ada! The time in Tokyo is 9:19:32 AM. ``` #### Test it offline The scaffold includes a test in `test/agent.test.ts`. It replays a recorded model response, so it runs without an API key: ```bash npm test ``` Tests like this one keep your agent working as you change it. See [Agent Test Harness](https://b4.run/docs/testing-agents) to write more. #### Where to go next When you want a dev server, run `npm run dev`. It serves your agents over [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol) and [AG-UI](https://b4.run/docs/ag-ui), and it reloads as you edit. Related: - [Build a Flight Planner](https://b4.run/docs/recipes/flight-planner): weather and performance subagents, planning, memory and a map Workbench - [Tools](https://b4.run/docs/tools): everything a tool can do - [Routes](https://b4.run/docs/routes): how folders become routes --- ### Mental Model Source: https://b4.run/docs/mental-model B4.run is a meta-framework for LangGraph. LangGraph runs the graph well. But every team we've seen build agents on LangGraph ends up writing the same things by hand: project structure, type wiring, dev tooling and deployment scripts. B4.run writes those conventions once, so you can focus on 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 the shared `src/tools/` or in a route's own `tools/` directory. B4.run infers their parameter types at build time. - *State* is the JSON a route takes in and returns. - Agent routes can turn on built-in features such as memory, planning, skills, subagents, and reasoning effort. - *Middleware* is an optional request gate for the dev server and the built HTTP runtimes. - LangGraph runs the graph. B4.run handles the rest. #### The pieces A *route* is a folder under `src/app/`. The folder path becomes the endpoint, so `src/app/navlog/` answers to `/navlog`. Everything a route needs lives in that folder. A *route entry* is what the route runs. Each route has one `index.ts` that exports an `agent`, a `workflow`, a `graph`, or a `chain`. Pick the shape that fits the problem, route by route. Export an `agent` when the model should decide what to do. It picks tools as it goes and can pause for a person. Export a `workflow` when you want to write the steps yourself as a plain async function. Both get the same routing, types and dev loop. You can drop down to `graph` or `chain` for raw LangGraph whenever you like. See [Agents](https://b4.run/docs/agents) and [Routes](https://b4.run/docs/routes) for each shape. *Tools* are default-exported async functions in the shared `src/tools/` or a route's own `tools/` directory. B4.run reads each tool's parameter type at build time and turns it into a JSON schema. Every route can use the shared tools, and a route-local tool with the same name replaces the shared one for that route. In an agent, the model decides when to call each tool. *State* is an optional typed shape in a `state.ts` file next to the route. Dynamic segments (`[tenant]`, `[...rest]`) stay in the route id and the generated route metadata. Callers pass their values in the JSON input. Agent features turn on when B4.run finds their files: - `workspace/AGENTS.md` adds prompt guidance on every turn. - `memory.ts` defines typed memory that lasts across threads, and episodic records keep run history for later recall. - `plan.md` turns on planning, `skills//SKILL.md` adds skills, and `subagents/` holds child agents. - The `reasoning` option tunes OpenAI-backed agent routes. *Middleware* runs before the route in `b4 dev` and in the built Node and Hono HTTP runtimes. It lives at `src/middleware.ts` (or a root `middleware.ts`). It can reject a request or attach context for the tools to read. ##### Access control: four controls Four separate controls decide what a running route can do: - [Tool scoping](https://b4.run/docs/tools#scoping-a-routes-tools) decides *which* tools the model can call. - [Permissions](https://b4.run/docs/permissions) decide *whether* a given call runs (human-in-the-loop approval). - The [execution sandbox](https://b4.run/docs/sandbox) decides *what* an allowed, approved call can touch. - [Guarded delegation](https://b4.run/docs/subagents#delegation-policy) decides *whether* a subagent gets the delegated input at all. You need all four, since each covers something the others don't. They all sit behind your service's own authentication and [thread access](https://b4.run/docs/thread-access) policy. See [Access Control](https://b4.run/docs/access-control) for how they fit together. #### The runtime A typical request through B4.run looks like this: ```text POST /threads//runs/wait { "route": "/navlog#agent", "input": { "messages": [...] } } │ ▼ thread lookup → .b4/threads.sqlite (created on POST /threads) │ ▼ route id match → src/app/navlog/index.ts │ ▼ input state → { messages: [{ role: "user", content: "Plan a VFR flight from KSTP to KRST at 4500 feet." }] } │ ▼ middleware → src/middleware.ts (auth, request context, ...) │ ▼ route entry → index.ts (agent | workflow | graph | chain) │ ▼ tool calls → readDoc({ path: "poh/cruise-performance.md" }) → { content: "..." } computeNavlog({ waypoints: [...], altitudeFt: 4500, ... }) → { legs: [...], totals: { distanceNm: 66, fuelGal: 5.5, ... } } │ ▼ typed output → "66 nm, 33 minutes, 5.5 gal burned. [poh/cruise-performance.md, Figure 5-7]" ``` Let's walk through each step: 1. **Thread lookup.** The Agent Protocol endpoint finds the thread in `.b4/threads.sqlite`. You create a thread with `POST /threads` and pass its id in later run requests. 2. **Route id match.** B4.run matches the route id against the file tree. Route groups (`(public)/`) drop out of the path, and the `#agent` suffix picks the agent entry. 3. **Input state.** B4.run passes the JSON input to the route. Agent routes fill in defaults from `state.ts`. Workflow, graph, and chain routes get the input as sent (a workflow can parse it with its own `state.ts`). 4. **Middleware.** The app's middleware runs, if there is one. It can stop the request or let it through with context for the tools. 5. **Route entry.** The exported `agent`, `workflow`, `graph`, or `chain` runs. Agent routes get their tools wired in, and `workflow(input, ctx)` gets the typed `RuntimeContext`. Raw graph and chain routes run the way LangGraph and LangChain run them, with the dependencies they import. 6. **Tool calls.** B4.run checks each tool call against the schemas it generated at build time. Here, `readDoc` reads a POH table under `poh/` and `computeNavlog` computes the legs, time and fuel in code. The model decides when to call each. 7. **Typed output.** The route returns a typed value. B4.run returns it in one response (`runs/wait`) or streams it (`runs/stream`). Runs happen on a thread. Between turns, B4.run saves the LangGraph state to SQLite in `.b4/checkpoints.sqlite`. > **Info** > > **Durable by default.** Every B4.run app ships a working checkpointer and thread store with no > setup. Threads survive a `b4 dev` restart, and an `agent` route that pauses for human input > resumes where it left off. LangGraph defines the checkpoint interface, and B4.run ships the > default implementation (`@b4run/sqlite-storage`). #### Build vs runtime `b4 build` and `b4 typegen` walk your routes, infer tool parameter types from function signatures, and write `.b4/b4.generated.d.ts`. That file holds the route registry, the tool registry, and the typed `RouteTools

` map. Once it exists, your editor autocompletes `ctx.tools.computeNavlog` and flags a caller that no longer matches a tool's signature. At *runtime*, `b4 run`, `b4 dev`, and the built HTTP entries run your routes. For agent and workflow routes, you never write a `tools: [...]` array or register a route. B4.run wires that up from the files. #### Where B4.run ends and LangGraph begins B4.run builds on LangGraph and packages LangGraph apps 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 aren't available. | | LangSmith target | Generated graph entries and `langgraph.json` for LangSmith to deploy. LangSmith provides hosting and observability. | The default `@b4run/sqlite-storage` package backs both the checkpointer (`.b4/checkpoints.sqlite`) and the thread store (`.b4/threads.sqlite`). You can swap in your own. LangSmith is a deployment and observability platform, separate from LangGraph. B4.run generates the files LangSmith deploys (`langgraph.json` and one entry file per route). You bring your own LangSmith project and tracing setup. You also bring your own LangGraph graphs, LCEL chains and model providers. B4.run leaves them as they are and puts conventions around them. #### Related - [Getting Started](https://b4.run/docs/getting-started): build something now - [Routes](https://b4.run/docs/routes): the folder-as-route convention - [Agents](https://b4.run/docs/agents): the default route entry - [Tools](https://b4.run/docs/tools): co-located tools and type inference - [State](https://b4.run/docs/state): typed JSON input and output - [Memory](https://b4.run/docs/memory): workspace-level prompt context - [Planning](https://b4.run/docs/planning): route-local todo state and stream events - [Skills](https://b4.run/docs/skills): load long instructions on demand - [Subagents](https://b4.run/docs/subagents): delegate to child agent routes - [Middleware](https://b4.run/docs/middleware): gate requests before a route runs - [CLI Reference](https://b4.run/docs/cli): every b4 command - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): the runtime protocol - [Recipes Overview](https://b4.run/docs/recipes): task-oriented how-tos - [FAQ](https://b4.run/docs/faq): one-line answers to the recurring questions --- ### Migrating from LangGraph Source: https://b4.run/docs/migrating-from-langgraph Learn what it takes to move a working LangGraph project to B4.run. B4.run runs on LangGraph, so your graph's nodes, edges, imported tools and state definitions can stay where they are. What changes is the code around the graph: the project layout and the deploy config. Most of the migration is moving files around, plus checking how the graph is invoked once it's deployed. B4.run's conventions (tools in a route's `tools/` folder, state in a sibling `state.ts`) are for agent and workflow routes. A raw graph keeps its own definitions. #### 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. Agent and workflow routes can use tool files next to the route. - Your raw graph keeps its channel and state definitions. Dynamic segment values come from the caller's JSON input. - `b4 build` replaces your hand-written `langgraph.json`. It still outputs a `langgraph.json`. - Model providers and LangChain packages can stay. Check the checkpointer configuration on each deploy target. - LangSmith deploys the output of `b4 build`. #### The shape of the move Here is 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 ``` Here is 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 ``` Directories grouped by kind (`graphs/`, `tools/`) become folders named by endpoint. Tools you convert can live next to their route, and an imported graph keeps its tool imports. B4.run builds the route registry (which graph answers which path) from the file tree, so you don't maintain it by hand. #### Construct by construct ##### StateGraph → route The graph's nodes and edges stay the same. A small route module that re-exports the compiled graph replaces the hand-written `assistant_id` entry. Before: `src/graphs/support.ts`: ```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 compiled graph as-is: `src/app/support/index.ts`: ```ts export { support as graph } from "../../graphs/support.js" ``` The folder `src/app/support/` becomes the endpoint `/support`. The only change is the export name, `graph`. The generated `assistant_id` is `/support#graph`. If the graph is all you want to migrate, the route is done. Tools and state can keep their old import paths. Check one thing before you cut over. The B4.run local HTTP runtime does not translate its Agent Protocol thread id into a precompiled raw graph's `configurable.thread_id`. If the graph's checkpointer needs that id, add a wrapper or configuration adaptation, and validate that target boundary before cutover. A checkpointer that worked behind another server won't necessarily get the same config from B4.run. ##### Raw graph state stays with the graph Keep the graph's TypeScript state type, channels, reducers and defaults with the compiled graph. Callers send the same JSON the graph already expects. A dynamic folder names a route id with a parameter. It doesn't fill in the value. For `/support/[tenant]`, the caller includes `{ "tenant": "acme", ... }` in the JSON input, with the field named after the segment. B4.run applies a sibling `state.ts` (its defaults and `reducers/`) automatically only in agent routes. A workflow route can import the same schema and call `state.parse(input)`. A raw graph ignores it. See [State](https://b4.run/docs/state) when you convert a graph to an agent or workflow. ##### LangChain tools → B4.run agent and workflow tools When you convert a graph to an agent or workflow route, its tools become default-exported async functions in the route's `tools/` directory. B4.run infers the types at build time, so the hand-written schemas go away. A raw graph can keep its LangChain tools and imports. Before: `src/tools/lookupOrder.ts`: ```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: `src/app/support/tools/lookupOrder.ts`: ```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 } } ``` Let's quickly review: - The file name is the tool name. - B4.run reads the input type from the parameter and the output type from the return type. - `b4 typegen` writes both to `.b4/b4.generated.d.ts`, and `b4 build` uses the generated schemas for the deploy entries. In an `agent` route, the model decides when to call the tool. A `workflow(input, ctx)` gets a `RuntimeContext`, so it can call `ctx.tools.lookupOrder({ orderId })` with full IntelliSense. A raw `graph` has no `ctx.tools`, so it keeps calling the tools it imports. ##### Conditional edges and routing → middleware + dispatch LangGraph has two separate mechanisms here, and so does B4.run. *Graph-level conditional edges stay where they are.* `addConditionalEdges` belongs to the graph, and B4.run leaves it alone. `inside a graph route (unchanged)`: ```ts .addConditionalEdges("triage", (state) => { if (state.priority === "p0") return "escalate" return "respond" }) ``` *Request-level branching* (auth, tenant gating, picking an assistant) moves to `middleware.ts`. The middleware decides whether a request runs and what context reaches the tools. Before, the branching lives in the server entry point: `src/server.ts (sketch)`: ```ts 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: `src/middleware.ts`: ```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" }) }) ``` To pick between assistants, make them two routes: `/support/internal` and `/support/public`, each with its own graph. The caller picks one by route id. ##### `langgraph.json` → `b4 build` The hand-written config becomes a build output. Before: `langgraph.json`: ```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` instead: `b4.config.ts`: ```ts export default { appDir: "src/app", } ``` `b4 build` walks `src/app/`, runs typegen, and writes `.b4/build/langgraph.json` plus one entry file per route. Each route's `assistant_id` is `#`, such as `/support#graph` or `/support/[tenant]#agent`. LangSmith deploys the `.b4/build/` directory. The same step writes `.b4/b4.generated.d.ts`, with the route registry, the tool registry and the typed `RouteTools

` map. The starter template ignores `.b4/`, so you regenerate it in development and CI (unless you choose to commit it). #### What can stay - **LangSmith.** B4.run leaves tracing, evaluations and datasets to LangSmith. Set `LANGSMITH_API_KEY` and traces flow. `b4 dev` sets `LANGCHAIN_TRACING_V2=true` for you when that key is present. - **Checkpointer and persistence.** The checkpointer you pass to `.compile({ checkpointer })` stays attached to the raw graph. B4.run's local Agent Protocol thread id isn't passed as `configurable.thread_id`, though, so adapt and test any graph that relies on it. - **Model providers.** Raw `graph` and `chain` routes keep whatever LangChain providers you create yourself. The built-in `agent()` route becomes a LangChain chat model. B4.run infers the provider for known model families and loads the matching LangChain package on demand. Set `provider` yourself for aliases, ambiguous model names, local models, or provider-router model ids. - **LangChain ecosystem packages.** `@langchain/core`, `@langchain/openai`, retrievers and document loaders all work inside a route. - **LangSmith deploy.** The target is the same. `b4 build` emits the generated `langgraph.json` and entry files LangSmith consumes. #### Migration order I recommend moving one graph at a time. 1. **Scaffold a B4.run project next to the existing one.** Run `npm create b4-app@latest my-agents-b4`. Don't merge the two repos yet. The old project keeps shipping while the new one takes shape. 2. **Move one graph at a time.** Start with the lowest-risk graph. Create `src/app//index.ts` and export the existing `StateGraph` as `graph`. Tools, state and prompts keep their old import paths. Once it runs the same in staging, move 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 works the same in B4.run, switch production to the B4.run `assistant_id`s and retire the old project. #### Related - [Getting Started](https://b4.run/docs/getting-started): scaffold a B4.run project now - [Mental Model](https://b4.run/docs/mental-model): where B4.run ends and LangGraph begins - [Agents](https://b4.run/docs/agents): the agent() route entry - [State](https://b4.run/docs/state): state.ts for agent and workflow routes - [Middleware](https://b4.run/docs/middleware): auth and request gating - [Routes](https://b4.run/docs/routes): the folder-as-route convention and the four entry shapes - [Tools](https://b4.run/docs/tools): co-located tools and build-time type inference - [FAQ](https://b4.run/docs/faq): short answers to common questions --- ### Routes Source: https://b4.run/docs/routes A route is a folder under `src/app/`, and its path becomes the endpoint. The route's `index.ts` exports one entry, in one of four shapes: `agent`, `workflow`, `graph`, or `chain`. #### Route entry Here are the four shapes: - **`agent`**: a model-driven loop that calls the route's tools. The scaffold uses this one. See [Agents](https://b4.run/docs/agents). - **`workflow`**: a plain async function that you write step by step. See [workflow](#workflow) below. - **`graph`**: a LangGraph graph, for branching, looping and conditional edges. - **`chain`**: a LangChain LCEL `Runnable`, for simple linear pipelines. #### Pathname rules Routes follow the Next.js App Router conventions: - `(group)/`: a route group, left out of the path - `[segment]/`: a dynamic segment, kept in the route id and in the generated route params - `[...rest]/`: a catch-all - `[[...optional]]/`: an optional catch-all Here is an 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 left out of the path, so this route is `/hello/[tenant]`. #### `workflow` A `workflow` is an async function. The first argument is the caller's JSON input, and the second is a `RuntimeContext` with the route's tools: `src/app/(public)/hello/[tenant]/index.ts`: ```ts 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 r = await ctx.tools.greet({ tenant: parsed.tenant }) return { ...parsed, greeting: r.greeting } } ``` Let's quickly review: - B4.run passes the input to a workflow as-is. It applies `state.ts` automatically only for agent routes, so the workflow imports the schema and calls `state.parse(input)` itself. That gives you typed state with its defaults filled in. - `RouteTools<"/hello/[tenant]">` types `ctx.tools` with the tools in this route's `tools/` directory (plus the shared `src/tools/`). - B4.run returns whatever the workflow returns. #### `graph` Export a compiled LangGraph graph as `graph`: `src/app/(public)/hello/[tenant]/index.ts`: ```ts import { Annotation, StateGraph, START, END } from "@langchain/langgraph" const HelloState = Annotation.Root({ name: Annotation, greeting: Annotation, }) export const graph = new StateGraph(HelloState) .addNode("greet", async (state) => ({ greeting: `Hello, ${state.name}!` })) .addEdge(START, "greet") .addEdge("greet", END) .compile() ``` The graph gets the caller's JSON input and runs in B4.run's runtime. A graph declares its own state with LangGraph's `Annotation`, and `state.ts` doesn't apply to it. #### `chain` Export a LangChain LCEL `Runnable` as `chain`: `src/app/(public)/hello/[tenant]/index.ts`: ```ts import { RunnableSequence } from "@langchain/core/runnables" export const chain = RunnableSequence.from([ // chain steps ]) ``` #### Running a route Run a route by its path: ```bash $ echo '{"tenant":"acme"}' | b4 run '/hello/[tenant]' $ b4 dev # HMR + dev server ``` To call a route from code, use the `runs/wait` and `runs/stream` endpoints described in [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol). #### Related - [Agents](https://b4.run/docs/agents): the default scaffold export - [Tools](https://b4.run/docs/tools): co-located tool files, type inference - [State](https://b4.run/docs/state): typed JSON input and output - [Subagents](https://b4.run/docs/subagents): child routes under subagents/ - [Middleware](https://b4.run/docs/middleware): auth, logging, request gates - [Retry](https://b4.run/docs/retry): retry strategy for agent model calls - [Dispatch from a Route](https://b4.run/docs/recipes/dispatch-from-route): recipe calling one route from another over /runs/wait --- ### Agents Source: https://b4.run/docs/agents An agent is a route where the model decides which tools to call. It's the route the scaffold creates, and I recommend it whenever you want the model to decide what to do. When you want to control the steps yourself, use a `workflow`, `graph` or `chain`. #### A minimal agent A route's `index.ts` default-exports `agent({ model, systemPrompt })`. B4.run finds the tools in the route's `tools/` directory and wires them into the generated graph: `src/app/(public)/research/index.ts`: ```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.", }) ``` `src/app/(public)/research/tools/search.ts`: ```ts export default async (input: { readonly query: string }) => { return { results: [`Result for: ${input.query}`] } } ``` `index.ts` is the agent, and every TypeScript file in `tools/` becomes one of its tools. B4.run infers the parameter types from each function signature when you run `b4 build`. #### Model providers An `agent()` route runs on a LangChain chat model. B4.run infers the provider from known model names and loads the matching LangChain package on demand. The built-in provider ids are `openai`, `anthropic`, `google`, `mistral`, `groq`, `ollama`, `xai`, and `openrouter` (see [`ModelProviderId`](https://b4.run/docs/api/sdk#b4runsdk-1)). Raw `graph` and `chain` routes can create any provider themselves. Set `provider` yourself when the model name is an alias, ambiguous, local, or routed through a gateway: `src/app/(public)/research/index.ts`: ```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. Install the other LangChain provider packages as you need them: ```bash npm install @langchain/anthropic # anthropic npm install @langchain/google-genai # google npm install @langchain/mistralai # mistral npm install @langchain/groq # groq npm install @langchain/ollama # ollama npm install @langchain/xai # xai npm install @langchain/openrouter # openrouter ``` `b4 check` and `b4 verify` warn, with did-you-mean suggestions, when a model id isn't in B4.run's known list for its provider. Only `openai`, `google`, `anthropic` and `xai` have lists. The runtime prints the same warning once when it creates the model. It's only a warning. New, proxy and gateway model ids run fine as long as your provider accepts them. #### When to pick an agent A route's `index.ts` exports one of four entry shapes, and different routes in a project can use different shapes: - **Agent**: the model picks the tools. Use it for chat and open-ended research. - **Workflow**: a plain async function. Use it when you want to write the steps yourself. - **Graph**: the full LangGraph DSL with branching, looping and conditional edges. Use it when the flow has structure the model shouldn't decide. - **Chain**: a LangChain LCEL `Runnable`. Use it for simple linear pipelines. #### Tool auto-binding You don't write a `tools: [...]` array. At build time, B4.run wires every file in the route's `tools/` directory into the generated graph: `src/app/(public)/research/tools/search.ts`: ```ts export default async (input: { readonly query: string }) => { return { results: [`Result for: ${input.query}`] } } ``` B4.run reads the parameter type and turns it into a JSON schema for the model. When the model decides it needs a search, it calls `search` with a `query`. See [Tools](https://b4.run/docs/tools) for the full input/output rules and the generated declarations. #### Retry Agents take a `retry` option for temporary model and provider failures: `src/app/.../index.ts`: ```ts export default agent({ model: "gpt-5-mini", systemPrompt: "...", retry: { maxAttempts: 3, baseDelay: 250 }, }) ``` See [Retry](https://b4.run/docs/retry) for the backoff strategy, what is retried, and the streaming caveat. #### Built-in agent features You turn on more agent features by adding files or options: - [Memory](https://b4.run/docs/memory) loads `workspace/AGENTS.md` into the prompt. - [Planning](https://b4.run/docs/planning) adds `plan.md`, `writeTodos`, `todos`, and `plan_update`. - [Skills](https://b4.run/docs/skills) adds `skills//SKILL.md` and `readSkill`. - [Subagents](https://b4.run/docs/subagents) adds child routes and `task`. - [Reasoning](https://b4.run/docs/reasoning-effort) tunes effort and streams reasoning on `agent()` routes, per provider. - [Workspace](https://b4.run/docs/workspace) activates workspace tools (`listDir`, `readFile`, `writeFile`, `editFile`, `runBash`) when a `workspace/` directory exists. #### Streaming The dev server streams a run's output from `runs/stream`. See [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol) for the details. #### Related - [Routes](https://b4.run/docs/routes): pathname rules and route entry shapes - [Tools](https://b4.run/docs/tools): tool input/output rules, type inference - [State](https://b4.run/docs/state): when an agent reads or returns structured state - [Memory](https://b4.run/docs/memory): workspace-level prompt context - [Planning](https://b4.run/docs/planning): route-local todo state - [Skills](https://b4.run/docs/skills): long instructions on demand - [Subagents](https://b4.run/docs/subagents): delegate to child agent routes - [Reasoning](https://b4.run/docs/reasoning-effort): route-level model tuning - [Retry](https://b4.run/docs/retry): retry config and backoff --- ### Tools Source: https://b4.run/docs/tools A tool is a function a route can call. Tools live in a route's `tools/` directory (or the shared `src/tools/`), and B4.run finds them for you. B4.run **infers each tool's input and output types from the TypeScript source**, so you don't write Zod schemas for tools. (Route state in `state.ts` does use Zod. See [State](https://b4.run/docs/state).) #### A minimal tool A tool is one file with a default-exported function. The JSDoc comment above the export becomes the description the model reads when it decides whether to call the tool: `src/app/(public)/hello/[tenant]/tools/greet.ts`: ```ts /** Greet the tenant organization by name. */ export default async (input: { readonly tenant: string }) => { return { greeting: `Hello, ${input.tenant}!` } } ``` That's it. At build time, B4.run reads the input and output types with the TypeScript compiler API and writes them to `.b4/b4.generated.d.ts`. In an `agent` route, `b4 build` wires the tool in for the model to call. In a `workflow`, or a callable `graph` function that receives B4.run's `RuntimeContext`, you call it as `ctx.tools.greet`. A precompiled raw LangGraph object works differently (see [Invoking a tool](#invoking-a-tool)). > **Info: No registration needed** > > B4.run discovers every `.ts` file in a route's `tools/` directory. Add a file, save it, and the type appears in `ctx.tools` on the next `b4 typegen` or `b4 dev` reload. #### Shared tools Tools come from two places. Each route has its own `tools/` directory, and the shared `src/tools/` directory holds tools that several routes use: ```text src/ ├── tools/ ← shared across every route │ ├── lookupOrder.ts │ └── escalate.ts └── app/ └── support/ ├── index.ts └── tools/ ← route-local to /support └── escalate.ts ``` B4.run merges both sets, for agent routes and for the typed `ctx.tools` that workflow and callable-graph functions get. When both define the same tool name, **the route-local tool wins**. 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 only that route uses it, or when it overrides a shared tool. Top-level agents and subagents see different sets: | 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 A top-level agent sees every tool available to it by default: its own `tools/*.ts`, the shared `src/tools/`, and capability tools such as `writeFile`, `runBash`, `writeTodos`, `readSkill`, and `remember`/`recall`. To narrow that set for one route, pass `tools: { allow, deny }` to `agent()`: - **`deny`** removes a tool, so the model never sees it. - **`allow`** adds a withheld capability tool back. - **`deny` wins** when a name appears in both. - **Denying `writeFile` also withholds `editFile`**, since both write files. Name `editFile` in `allow` to keep it: `deny: ["writeFile"], allow: ["editFile"]` lets the model change existing files but not create new ones. It doesn't work the other way: an `allow` list that names `writeFile` doesn't grant `editFile`, so list each tool you want. - Leave out `tools` to keep the default set above. Here's a route that gets everything except the shell: `src/app/navlog/index.ts`: ```ts // src/app/navlog/index.ts: top route, everything except shell export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } }) ``` B4.run applies the scope when it composes the route. A withheld tool never makes it into the generated entry, so the model can't call it. `b4 check` validates the names you list, and a name that isn't in the route's available set is a build-time error. ##### Subagents and capability tools A subagent gets the shared `src/tools/*` plus its own route-local `tools/*.ts`. The parent's local tools stay with the parent. Capability tools such as `writeFile`, `runBash`, `writeTodos`, and `remember`/`recall` are withheld unless you name them in `allow`. Since shared tools reach children by default, deny any sensitive one a child shouldn't have: `src/app/navlog/subagents/performance/index.ts`: ```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 B4.run adds a `task` tool when a route has at least one child it can dispatch to. `task` is internal, so tool scoping doesn't apply to it. A nested subagent with its own child gets `task` too, without any entry in `tools.allow`. Leave `task` out of `tools.allow`, `tools.deny`, `tools.approve`, and `tools.constrain`. Any of those reports `B4_E1004` during `b4 check` and route preparation. To control dispatch, use the parent's [`delegation`](https://b4.run/docs/subagents#delegation-policy) policy. > **Warning: This scopes the surface, not execution** > > Tool scoping controls which tools the model can *call*. What an allowed tool *does* once it runs is up to its implementation, so a granted `writeFile` can write anywhere its code permits. To limit that, use the [execution sandbox](https://b4.run/docs/sandbox). ##### Requiring approval per call `approve` is the third `tools` option. A tool named in `approve` (your own or a capability tool) asks a person before each call: `src/app/ops/index.ts`: ```ts export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"], approve: ["deployProd", "sendEmail"] }, }) ``` The prompt shows the call's arguments as a JSON preview, but the decision applies to the tool name: - **Once**: this call runs. The next call to the same tool asks again. - **Always**: saves the tool name to the permissions store, so later calls run without asking. The default Node store is `.b4/permissions.json`. Custom stores, such as Postgres, save it elsewhere. - **Deny**: blocks the call. The model gets the denial reason as the tool result. To ask on every call with no **Always** answer, write the entry as `{ tool: "sendEmail", allowAlways: false }`. The prompt then offers only **Once** and **Deny**, and an allow rule for the tool is ignored. See [Approve every call](https://b4.run/docs/permissions#approve-every-call). See [Permissions](https://b4.run/docs/permissions#per-tool-approval) for the full interrupt payload and resume flow. > **Info: Don't list workspace tools in approve** > > `runBash`, `readFile`, `writeFile`, `editFile`, and `listDir` already have their own pattern-based allow/deny gates (see [Permissions](https://b4.run/docs/permissions)). Adding them to `approve` would prompt twice, and `b4 check` warns if you do. ##### Constraining arguments `constrain` is the fourth option. It's a function per tool that checks the model's arguments on each call. Return `true` to allow the call, a string to deny it (the model gets the string as the tool result), or `{ approve: true }` to ask a person through the [approval prompt](https://b4.run/docs/permissions#per-tool-approval): `src/app/ops/index.ts`: ```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 function receives the parsed `args` and a read-only `ctx` (`{ toolName, routeId, threadId?, signal, params? }`), and it can be async. If it throws or returns anything else, the call **fails closed** and is denied. `b4 check` validates the tool names but not the function bodies. Don't list a tool in both `approve` and `constrain`. `constrain` wins (it can escalate with `{ approve }`), and `b4 check` warns. #### The runtime signature A tool's full signature is `(input, ctx) => ...`, where `ctx` is a `B4ToolContext` from `@b4run/sdk`. It carries `signal`, `threadId?`, `middleware?` and `fs`: `src/app/(public)/hello/[tenant]/tools/greet.ts`: ```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. Add it when a tool calls the network, runs a long time, needs middleware context, or reads and writes workspace files. See [Middleware](https://b4.run/docs/middleware) for how `defineMiddleware` and `allow(context)` fill in `ctx.middleware`, and [Workspace Filesystem](https://b4.run/docs/workspace) for `ctx.fs`. `ctx.threadId` is the current thread's ID. The runtime sets it (the model can't), and it's absent outside a thread, so handle that case. Of course, a thread ID only identifies a thread. It says nothing about who the user is or what they may access, so keep your own access checks and sandbox isolation. #### Tool descriptions In an `agent` route, the model reads each tool's description to decide when to call it. The description comes from the JSDoc comment directly above the default export: `src/app/(public)/hello/[tenant]/tools/greet.ts`: ```ts /** Greet the tenant organization by name. */ export default async (input: { readonly tenant: string }) => { return { greeting: `Hello, ${input.tenant}!` } } ``` `b4 typegen` copies the comment into the route's `tools.json` manifest. To set it in code, export a `description` string. It takes priority over the JSDoc comment: `src/app/(public)/hello/[tenant]/tools/greet.ts`: ```ts export const description = "Greet the tenant organization by name." export default async (input: { readonly tenant: string }) => { return { greeting: `Hello, ${input.tenant}!` } } ``` A tool without a description still works, but the model only sees its name. I recommend writing the one-liner. #### Describing a call to people The model reads `description`; a person reads the step a call becomes in the chat. Export `display` to say how a call reads while it runs and once it returns: `src/tools/searchCorpus.ts`: ```ts import type { ToolDisplay } from "@b4run/sdk" export const display = { icon: "search", running: ({ query }) => `Searching the corpus for “${query}”`, done: ({ query }) => `Searched the corpus for “${query}”`, sources: (hits) => hits.map((hit) => ({ title: hit.path })), } satisfies ToolDisplay<{ query: string }, { path: string }[]> export default async (input: { readonly query: string }) => { /* … */ } ``` Every field is optional. `icon` is one of `search`, `read`, `write`, `run`, `web`, `memory`, `plan`, `agent`, `think` or `tool`. `output` in `done` and `sources` is the value the tool returned, exactly as returned. The runtime evaluates `running` as the call starts and `done` and `sources` as it returns, truncates labels at 120 characters, and streams the result to AG-UI clients as a `b4.step` event (see [AG-UI](https://b4.run/docs/ag-ui#step-events)). A label that throws, or returns something other than a string, number or boolean, falls back to the default and is logged once. A call blocked by [`tools.approve`](#requiring-approval-per-call) or [`tools.constrain`](#constraining-arguments) never reaches `done` or `sources`: the model gets the denial reason as the result, and the step settles as `denied` with the icon alone. Without `display`, no `b4.step` is sent for the call; B4.run's own chat components then fall back to "Using searchCorpus…" and "Used searchCorpus". The built-in workspace, memory, skill, plan and subagent tools ship their own labels. `b4 check` rejects a `display` with an unknown icon, a non-function label or an unknown key (`B4_E5002`). #### Ending the run from a tool By default a tool's result goes back to the model for another turn, and the run ends only when the model answers without calling a tool. When a tool's result *is* the answer, that extra turn buys nothing: the model spends tokens and seconds saying what the tool already did. Export `returnDirect` to end the run on the tool's successful result instead: `src/app/(public)/assistant/tools/render.ts`: ```ts export const returnDirect = true const COMPONENTS = new Set(["Table", "Chart", "Summary"]) /** Show the composed answer to the user. Call exactly once, last. */ export default async (input: { readonly ui: readonly string[] }) => { const unknown = input.ui.filter((name) => !COMPONENTS.has(name)) if (unknown.length > 0) throw new Error(`invalid_ui: unknown components ${unknown.join(", ")}`) return { rendered: true } } ``` The run's final state then carries no closing assistant message: the last message is the tool result, the AG-UI stream ends after `TOOL_CALL_RESULT` with `RUN_FINISHED`, and a middleware `after` hook runs with an empty final message. Reserve it for a tool the model is instructed to call last; a `returnDirect` tool called mid-plan ends the run there. Only a successful call ends the run. When a `returnDirect` tool throws, or the model's arguments fail the tool's schema, the error goes back to the model like any other tool error, so the model can correct the call and try again; the first call that succeeds ends the run. That makes `returnDirect` a good fit for a tool that validates the model's output, like the renderer above. #### Invoking a tool In a `workflow`, call a tool through `ctx.tools.(...)`: `src/app/(public)/hello/[tenant]/index.ts`: ```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 for its input and return shapes. A callable `graph` function can accept the same B4.run `RuntimeContext` and use `ctx.tools`. A precompiled raw LangGraph object works differently. Its `.invoke()` treats the second argument as a LangGraph `RunnableConfig`, so it never sees `ctx.tools`. That graph uses the tools its code already owns or imports. In an `agent` route, the model calls the tools. `b4 build` wires the route's tools into the generated LangGraph entry. #### Input and output rules ##### Step 1: Use a typed, object-shaped input B4.run reads input types written inline, declared as local aliases or interfaces, or imported from another module. Keep the root input an object so B4.run can build the tool schema the model sees. Imports resolve through your app's `tsconfig.json` (the nearest one above the app root, with its `extends` chain, `paths`, and `baseUrl`). So an input type behind a path alias such as `@acme/contracts` gets the same schema as a relative import. If B4.run can't resolve a declared input type (the alias is missing from `tsconfig.json`, or the module has no such export), `b4 typegen` fails and names the tool file and the type. A tool that takes no input can omit the parameter or declare it as `{}`. ```ts export default async (input: { readonly tenant: string; readonly limit?: number }) => { ... } ``` ##### Step 2: Mark fields readonly Type generation keeps `readonly`. Use it on every field, since tool inputs are plain data. ##### Step 3: Keep inputs and outputs JSON-serializable B4.run serializes tool inputs and outputs, so classes, Dates, Maps and functions get lost. Use primitives, plain objects and arrays. ##### Step 4: Return serializable data Output types can be inferred, annotated inline, declared as local aliases or interfaces, or imported from another module. Stick to primitives, arrays and plain objects. A tool may return media. Return an array of content parts — `{ type: "text", text }` and `{ type: "image" | "audio" | "video" | "document", source }`, where `source` is `{ type: "data", value, mimeType }` (base64), `{ type: "url", value, mimeType? }` or `{ type: "file", value, provider? }` — or `{ result: parts, state }` to update state alongside. The type is `B4ContentPart` from `@b4run/sdk`. A `document` part must be a PDF (`mimeType: "application/pdf"`); a document sent by URL must carry that `mimeType` or it is dropped as `document_not_pdf`. Over AG-UI the parts travel as `TOOL_CALL_RESULT.content`; the model sees the media only when its profile and provider admit media in tool messages — on OpenAI it never does, though the UI still shows every part — and the result's text is what tool-output offloading bounds. See [AG-UI](https://b4.run/docs/ag-ui#multimodal-content). #### The generated declarations `b4 typegen` writes two artifacts: - **`.b4/b4.generated.d.ts`**: the type module behind `import type { RouteTools } from "b4:routes"`. It maps each route pathname to that route's tool signatures, inferred from source. - **`.b4/routes//tools.json`**: one tool-schema manifest per route. `b4 build` reads these when it writes the LangGraph entries. To see what B4.run generates for your app, run `b4 typegen` and open `.b4/b4.generated.d.ts`. > **Warning: Don't edit these files** > > B4.run regenerates `.b4/b4.generated.d.ts` and the `tools.json` files under `.b4/routes/` on every `b4 typegen`, when the dev server sees a save, before `b4 run` and `b4 test` run routes, and when `b4 build` writes its output. Your edits will be wiped. To regenerate them manually, run: ``` b4 typegen ``` #### Common patterns - **External API wrappers**: one tool per endpoint. The input mirrors the endpoint's parameters, and the output is the parsed response. - **Database reads**: the input holds the filters, and the output is the records. Keep queries cheap. - **LLM calls**: the input is the prompt variables, and the output is the parsed model response. Leave orchestration to the route entry. - **Pure transformations**: map one shape to another with no side effects. These are easy to test. - **Wrapping an existing LangChain tool**: create the community tool at module scope, then expose it through a plain function with a typed input. B4.run tools are plain functions (rather than `tool()` wrappers), which is how B4.run infers the types from the signature: `src/app/(public)/research/tools/search.ts`: ```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) } } ``` The wrapper turns the community tool's loosely typed output into a plain object with a concrete return type. If the output is already serializable, you can skip it. #### Related - [Routes](https://b4.run/docs/routes): how agent routes bind tools automatically at build time - [Middleware](https://b4.run/docs/middleware): how ctx.middleware is populated for every tool call - [Workspace Filesystem](https://b4.run/docs/workspace): the sandboxed ctx.fs handle and pluggable backend - [State](https://b4.run/docs/state): the state contract that tools read and routes return - [Planning](https://b4.run/docs/planning): writeTodos is a capability-contributed tool - [Skills](https://b4.run/docs/skills): readSkill loads route-local instructions - [Subagents](https://b4.run/docs/subagents): delegation policy governs child dispatch - [Scenario Testing](https://b4.run/docs/testing): asserting tool outputs via scenario tests - [Add a Tool](https://b4.run/docs/recipes/add-a-tool): end-to-end recipe with the typed call site --- ### State Source: https://b4.run/docs/state State is the JSON a route receives as input and returns as output. You describe its shape in a `state.ts` file next to the route's `index.ts`. #### The shape `state.ts` default-exports a Zod schema (or any Standard Schema value). B4.run uses it two ways: - **Agent routes** apply it automatically. B4.run reads the defaults and fills them into the agent's state. - **Workflow routes** import it like any other module and parse their input with it. Either way, `z.infer` gives you the TypeScript type: `src/app/(public)/hello/[tenant]/state.ts`: ```ts import { z } from "zod" export default z.object({ tenant: z.string().default(""), /** Accumulated context from tool call results */ context: z.string().default(""), }) ``` `src/app/(public)/hello/[tenant]/index.ts`: ```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 } } ``` To find the defaults, B4.run tries validating `{}` against the schema. A schema that rejects `{}` is skipped, and you get no agent defaults and no generated types for it. So give every top-level field a way to be missing, usually with `.default(...)`. Plain workflows must parse their input themselves, as above. The input arrives as `unknown`, and the defaults appear once `state.parse(input)` succeeds. > **Info: Zod or any Standard Schema** > > State discovery accepts a Zod-compatible schema or any value that implements [Standard Schema](https://standardschema.dev/). It calls `~standard.validate({})` when that method exists, otherwise `.parse({})`, and expects an object back. The scaffold uses Zod. #### Dynamic segments are route params If a route's path has a dynamic segment like `[tenant]`, B4.run records it in the generated route metadata. The value itself comes from the input, so pass it in the JSON 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]' ``` A concrete path like `/hello/acme` won't fill in `tenant`. The resolver matches the route id with its brackets (`/hello/[tenant]`) or the route entry file path. > **Info: Naming matters** > > When you pass a dynamic segment value in the input, name the field after the segment. The generated route parameter types use the segment names. #### Custom reducers An agent route can have a `reducers/` directory with one file per state field. Each file default-exports a `(current, incoming) => merged` function that replaces B4.run's default merge for that field: `src/app/(public)/hello/[tenant]/reducers/context.ts`: ```ts export default (current: string, incoming: string) => current ? `${current}\n${incoming}` : incoming ``` The file name must match the field name (`context.ts` reduces `state.context`). Reducers are handy when a field accumulates, like appending logs, summing counters or deduplicating lists. #### Rules ##### Step 1: State must be JSON-serializable B4.run serializes state whenever it enters or leaves a route (`b4 run`, `b4 dev`, the Node runtime, Hono builds, and scenario tests), and so does LangSmith. Use primitives, plain objects and arrays. Don't use classes, Dates or Maps. ##### Step 2: Prefer readonly Mark fields `readonly` (or use Zod's `.readonly()`) so nothing mutates them. The entry function returns a new state object. This keeps scenario tests deterministic and LangGraph checkpoints safe. ##### Step 3: Outputs accumulate B4.run returns the workflow's result as-is. To keep the incoming state, return `{ ...state, newField }`. #### State flow Here's how state gets in and out at each entry point: - `b4 run` reads JSON from stdin and writes JSON to stdout. - `runs/wait` and `runs/stream` (under `b4 dev`, the Node runtime, and Hono builds) take B4.run's `{ route, input }` HTTP body, where `route` is `#`. The Node runtime is `b4 start` or `.b4/build/server.mjs`. - LangSmith deployments use LangSmith's own request body, keyed by `assistant_id`. You'll need to adapt B4.run's `{ route, input }` body for it. Tool results and other values inside a run stay inside the entry. Only the route's result goes back to the caller. #### Related - [Routes](https://b4.run/docs/routes): how state types flow into route entries - [Tools](https://b4.run/docs/tools): tools that read or mutate state - [Planning](https://b4.run/docs/planning): the todos channel is contributed at runtime - [Scenario Testing](https://b4.run/docs/testing): asserting state shape in scenarios - [Deployment Options](https://b4.run/docs/deployment): how state serialization requirements apply across the runtime boundary - [Typed State](https://b4.run/docs/recipes/typed-state): recipe wiring dynamic segments into a typed shape --- ### Workspace Filesystem Source: https://b4.run/docs/workspace The `workspace/` directory at the app root is where a B4.run app keeps its files. Create it, and agent routes get five built-in tools: `listDir`, `readFile`, `writeFile`, `editFile` and `runBash`. There's nothing else to configure. Every file read and write goes through one permission gate, whether the model calls a tool or your code calls `ctx.fs`. There are three layers: - **The backend** (`@b4run/workspace`): a plain object that reads, writes and lists files. `localFilesystem()` is the default, and you can swap in your own in `b4.config.ts`. - **The agent tools**: `readFile`, `writeFile`, `editFile`, `listDir` and `runBash`. B4.run adds them to agent routes when `workspace/` exists at the app root. The model calls them by name, and each call passes the permission gate before it reaches the backend. - **`ctx.fs`**: the `WorkspaceFs` handle on `B4ToolContext` (route tools) and `RuntimeContext` (workflow and graph entries). It uses the same gate and backend, but your code calls it. It's always on the context. Reads throw `ENOENT` until `workspace/` exists. #### The pluggable backend Every backend implements the `FilesystemBackend` interface: | Method | Required | Notes | |---|---|---| | `readFile(path, ctx, opts?)` | Yes | UTF-8. `opts.maxBytes` overrides the default cap | | `realPath(path, ctx)` | Yes | Resolves symlinks so the permission gate sees the real target. Backends without symlinks return the path as-is | | `readBinaryFile(path, ctx, opts?)` | No | Raw bytes (`Uint8Array`). Needed 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, with two defaults: - **A 256 KiB read cap** per call. Override it with `opts.maxBytes` (for example, `Number.POSITIVE_INFINITY` for no cap). - **`writeFile` creates missing parent directories**, so writing `reports/result.md` works without a `mkdir` first. To use your own backend, set it in `b4.config.ts`: `b4.config.ts`: ```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 behavior to every call, such as logging: `b4.config.ts`: ```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 each method name and its arguments to `stderr`. For `writeFile`, that includes the whole file content, so be careful where those logs go. Pass a `destination` function for structured output: ```ts withFilesystemLogging({ destination: ({ method, args }) => { structuredLogger.debug("workspace", { method, args }) }, }) ``` `readBinaryFile` logs the **path only**, never the bytes. > **Warning: Forward optional methods** > > A middleware that returns only `{ readFile, writeFile, listDir }` silently drops `readBinaryFile`, `statFile`, `removeFile`, `touchFile`, and `mkdir`. Without `statFile` and `removeFile`, offload GC stops working. Without `readBinaryFile`, so does `ctx.fs.readBinaryFile`. Use a conditional spread to pass through the methods you don't 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, so it's a good reference. #### The five agent-facing tools All paths are relative to `workspace/`. A path outside it goes through the permission gate (see [Permissions](#permissions) below). ##### listDir Lists the file and folder names in a workspace directory. It returns names, without the directory path. ``` listDir({ path: "corpus" }) // → ["intro.md", "api-reference.md", "changelog.md"] ``` Leave out `path` to list the workspace root. ##### readFile Reads a UTF-8 file. The default cap is **256 KiB**. A larger file returns an error, with no partial content. ``` readFile({ path: "corpus/intro.md" }) ``` Files in `workspace/tool-outputs/` (where offloaded tool results go) have no size limit, so the agent can read them back in full with the same `readFile` call. Pass `startLine` and `endLine` (1-based, inclusive) to read part of a file. `endLine` may run past the end of the file, and a `startLine` after `endLine` is an error. A ranged read starts with a header line giving the file's length, so the model knows where it is: ``` readFile({ path: "src/server.ts", startLine: 1200, endLine: 1260 }) // → "[src/server.ts lines 1200-1260 of 3644]\n..." ``` Without a range, `readFile` returns the file exactly as before, with no header. The tool's description tells the model to read large files in ranges and change them with `editFile`. A range doesn't lift the read cap: B4.run still reads the whole file first, so a file over 256 KiB fails with or without a range. ##### writeFile Writes a UTF-8 file. **It creates missing parent directories**, so `writeFile({ path: "reports/2024-q1.md", content: "..." })` works before `reports/` exists. ``` writeFile({ path: "reports/summary.md", content: "# Summary\n\n..." }) // returns: "wrote 1234 bytes to reports/summary.md" ``` `writeFile` replaces the whole file. To change an existing file, use `editFile`: a model that rewrites a large file in full can drop or summarize the parts it didn't mean to change. ##### editFile Replaces an exact span of text in an existing file. `oldText` must match the file character for character, whitespace and line endings included, and must occur exactly once. Overlapping matches count, so `aa` occurs twice in `aaa`. Set `replaceAll: true` to replace every occurrence instead, matched left to right without overlap. The rest of the file is written back unchanged. If `newText` equals `oldText`, the call returns `no change` and writes nothing. ``` editFile({ path: "src/config.ts", oldText: "retries: 3,", newText: "retries: 5," }) // returns: "replaced 1 occurrence in src/config.ts at line 42" ``` If `oldText` is missing, the call fails with `oldText not found in `, and says so when the file uses CRLF line endings but `oldText` has bare `\n`. If it occurs more than once without `replaceAll`, the call fails with the count and asks for more surrounding text. `editFile` reads and writes through the same permission gate as `readFile` and `writeFile`, so a path outside `workspace/` is gated as a read and as a write. A few limits: - **UTF-8 only.** `editFile` reads the file's bytes and refuses one that isn't valid UTF-8, leaving it unchanged, instead of rewriting it with replacement characters. - **The read cap applies.** A file over the 256 KiB cap can't be edited. - **Not atomic.** `editFile` reads the file and then writes it. A change another writer makes in between is overwritten. Denying `writeFile` in a route's [tool scope](https://b4.run/docs/tools#scoping-a-routes-tools) also withholds `editFile`. ##### runBash Runs a shell command in the workspace root. A command runs only if it matches an allow rule or a person approves it (see [Permissions](https://b4.run/docs/permissions)). ``` runBash({ command: "node scripts/fetch-source.mjs https://example.com/api" }) ``` #### ctx.fs for tools and routes `ctx.fs` is a `WorkspaceFs` handle. It's a smaller, workspace-relative view of 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 **relative to the workspace**, so `"images/logo.png"` means `/workspace/images/logo.png`. Anything outside the workspace root goes through the permission gate (see [Permissions](#permissions) below). Here is a route tool that reads a binary file: `src/app/(public)/describe/tools/describeImage.ts`: ```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")` turns the `Uint8Array` into a base64 string. If your backend has no `readBinaryFile`, the call throws an error that tells you how to fix it. And here's a workflow that lists and reads files. A workflow gets the caller's input as-is, so it parses it first: `src/app/(public)/report/index.ts`: ```ts import type { RuntimeContext } from "@b4run/sdk" import type { RouteTools } from "b4:routes" import { z } from "zod" const Input = z.object({ topic: z.string() }) export async function workflow(input: unknown, ctx: RuntimeContext>) { const { topic } = Input.parse(input) const entries = await ctx.fs.listDir("drafts") const first = entries[0] if (!first) return { topic, summary: "no drafts found" } const content = await ctx.fs.readFile(`drafts/${first}`) return { topic, summary: content.slice(0, 500) } } ``` `listDir()` with no argument lists the workspace root. Both `readFile` and `listDir` pass the permission gate before they reach the backend. #### Navlog scaffold example The `navlog` template (`create-b4-app --template navlog`) shows how these tools fit together. These paths are relative to the B4.run app root, which is the generated project's `server/` package: ```text workspace/ AGENTS.md ← agent memory (see /docs/memory) poh/ ← Cessna 172N POH tables the agent reads regs/ ← regulation excerpts (VFR cruising altitudes, fuel reserves) reports/ ← agent-written navlogs (writeFile creates this) tool-outputs/ ← large tool results the runtime offloads ``` Here's how the agent uses them: - `readDoc` reads a POH table under `poh/` or a regulation excerpt under `regs/`, and an offloaded result under `tool-outputs/`. - `writeFile` saves each navlog to `workspace/reports/`, creating the directory on the first write. - The runtime offloads a large tool result to `workspace/tool-outputs/` and keeps a preview in context. - The route denies `runBash`, so the agent never runs a shell command. Agent memory lives in `workspace/AGENTS.md`, and B4.run adds it to the system prompt. See [Memory](https://b4.run/docs/memory). > **Warning: Treat AGENTS.md as shared prompt input** > > B4.run reads the app root's `workspace/AGENTS.md` into the prompt on every turn, for every `agent()` route and subagent. With the default local filesystem, `writeFile({ path: "AGENTS.md", ... })` writes that same file. So one route can save instructions that change later requests and every other agent in the app root. > > A sandbox or custom backend may send `writeFile` to a different filesystem, and then the host file stays the same. Raw graph, workflow, and chain routes never read this file. > > Give each tenant its own app root (or its own prompt-memory trust domain). A per-thread sandbox won't separate them, because every thread reads the same host file. Limit or deny writes from untrusted agents that can reach it, review the seeded content, and never copy raw untrusted text into `workspace/AGENTS.md`. > > To make the file read-only guidance, set [`agentsMd: { writable: false }`](https://b4.run/docs/configuration#agentsmd). The prompt then tells the model not to modify it. That's prompt guidance only, so also refuse writes to `AGENTS.md` with a [filesystem middleware](#middleware), and deny or gate `runBash` for routes that can reach the workspace, since a shell command bypasses that middleware. #### Tool-output offloading When a tool returns a large result, B4.run can save it to `workspace/tool-outputs/` and put a short stub in the context. The agent reads the full result back with `readFile` when it needs it. See [Context Management](https://b4.run/docs/context-management) for the options and how offloading works with summarization. #### Permissions Every file read and write goes through the same permission gate, whether the model calls a workspace tool or your code calls `ctx.fs`: | 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. A `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 runs and workflow/graph entries fail closed.** Workflow and graph entries run outside the LangGraph agent graph, so they can't pause for a prompt. When a path outside `workspace/` has no matching rule, the gate returns an error that tells 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. ``` **Allow rules match real paths.** The gate compares paths after resolving symlinks, so write allow rules with the **canonical** path. If the workspace or target sits under a symlink (like macOS `/var → /private/var`, or a symlinked home or project directory), a rule written with the symlinked path won't match, and the call fails closed. **Symlinks resolve before the gate decides.** `localFilesystem` resolves them with `realPath`. A symlink inside `workspace/` that points outside is treated like any outside path: prompted or denied in interactive mode, and denied otherwise. Custom backends get the same protection through `realPath`, which the type requires. > **Warning: Interrupt-resume and side effects** > > When a tool calls `ctx.fs` with an outside path that needs approval, LangGraph pauses in the middle of the tool. On resume, LangGraph **runs the tool again from the top**, so anything before the `ctx.fs` call runs twice. > > Make those side effects safe to repeat, or move them after the gated call. #### Related - [Permissions](https://b4.run/docs/permissions): control which paths and runBash commands require human approval - [Memory](https://b4.run/docs/memory): workspace/AGENTS.md re-read by consuming agent routes - [Context Management](https://b4.run/docs/context-management): tool-output offloading and conversation summarization - [Configuration Reference](https://b4.run/docs/configuration): b4.config.ts reference including backends - [Agents](https://b4.run/docs/agents): agent routes and how tools are wired in --- ### Middleware Source: https://b4.run/docs/middleware Middleware is one global handler that runs before each route-execution request. Use it for authentication, request shaping and per-request context. The handler either allows the request or rejects it. With the object form, an optional [`after` hook](#validating-the-final-assistant-message) can also check or replace an AG-UI run's final assistant message before the client sees it. #### File location Define middleware as a default-exported function in `src/middleware.ts` (or `middleware.ts` at the app root): `src/middleware.ts`: ```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(middleware)` A type-preserving identity helper for editor inference. It accepts either the handler function shown above or a [lifecycle definition](#lifecycle-hooks): ```ts type MiddlewareHandler = ( req: MiddlewareRequest, ) => Promise | MiddlewareResult interface MiddlewareDefinition { setup?: (ctx: { readonly appRoot: string }) => Promise | void dispose?: () => Promise | void handle: MiddlewareHandler after?: MiddlewareAfterHook } type B4Middleware = MiddlewareHandler | MiddlewareDefinition ``` ##### `MiddlewareRequest` Every middleware receives this argument. All values are pre-parsed: | Field | Shape | Notes | |---|---|---| | `body` | `unknown` (optional) | Detached copy of the original parsed JSON envelope on POST execution requests. Absent on GET requests. Client-supplied values must be validated before use. | | `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"`. | The body keeps the protocol envelope. Agent Protocol requests contain `input` or `resume`. AG-UI requests contain fields such as `state`, `context`, and `forwardedProps`. Middleware can read extra AG-UI fields, but they're still unsupported as runtime options. Protocol validation and authorization still run in their usual order, so a malformed request can be rejected before middleware runs. On AG-UI, that ordering is now explicit: the runtime validates `threadId`, `runId` and `state` and rejects a non-empty client-supplied `tools` or `forwardedProps` **before** middleware, so no middleware is handed an envelope B4.run has already refused, and there is no reason for middleware to re-check B4.run's own wire format. See [envelope validation](https://b4.run/docs/ag-ui#envelope-validation). What is left for middleware is what is genuinely the app's — the shape of its own request context. Use this snapshot to validate application context or reject a request. Return validated values through `allow(context)` to make them available to tools. Editing `req.body` leaves the request's execution input and resume decision unchanged. The snapshot lives for one request only. Its contents are client input, so they can't establish caller identity or authorization. ##### `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: `src/middleware.ts`: ```ts export default defineMiddleware(async (req) => { const userId = await verifyJwt(req.headers.authorization) return allow({ userId }) }) ``` `src/app/(public)/hello/[tenant]/tools/lookup.ts`: ```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, so nothing is shared between requests. The `middleware` field is `undefined` if no middleware is defined, or if the middleware called `allow()` without arguments. #### Lifecycle hooks Some middleware needs a resource that outlives one request, such as a database pool or an HTTP client with a keep-alive agent. With the function form you would open it at module scope, where a transient outage becomes a [boot failure](#when-middleware-fails-to-load) and nothing closes it, or write a memoised getter that resets itself on rejection. Use the object form instead: `src/middleware.ts`: ```ts import { allow, defineMiddleware, reject } from "@b4run/sdk" import { Pool } from "pg" let pool: Pool | undefined export default defineMiddleware({ async setup() { pool = new Pool({ connectionString: process.env.DATABASE_URL }) await pool.query("select 1") }, async dispose() { await pool?.end() }, async handle(req) { const token = req.headers.authorization const session = await pool?.query("select user_id from sessions where token = $1", [token]) if (!session?.rowCount) return reject(401, { error: "Unauthorized" }) return allow({ userId: session.rows[0].user_id }) }, }) ``` Keep the resource in a module-scope variable. `setup` opens it, `handle` uses it, `dispose` releases it. ##### `setup(ctx)` - Runs **at most once per runtime, lazily**, before the first request the middleware gates (not at import or boot). A server whose database is unreachable still starts and reports healthy. The first gated request pays for the connection. - **Concurrent first requests share one in-flight call.** `handle` never runs before `setup` has completed successfully. - **A rejection leaves the process healthy.** The request that awaited it gets the same opaque `500` as a throwing handler. The cause is logged to stderr once per distinct message, and the memo is cleared so the next request runs `setup` again. - `ctx.appRoot` is the absolute path of the app the runtime booted for. There is no shutdown `AbortSignal` on the context. The fetch core creates signals per request, because a signal shared across requests isn't safe on every host. ##### `dispose()` - Runs **once, from the runtime's shutdown path**, after in-flight requests have drained, so it never ends a pool a request is still using. - Runs when there is no `setup`, or after a `setup` that completed successfully. It is **not** invoked when `setup` never ran or only failed, so it never has to guard against a resource that was never opened. - A rejection is logged to stderr, and shutdown carries on. ##### Which targets call `dispose` | Target | Shutdown path | |---|---| | `b4 dev` | The dev child exits on `SIGTERM`/`SIGINT` and on every restart through `server.close()`, which runs `dispose` after the drain. | | `b4 start`, generated `server.mjs`, the Docker image | `SIGTERM`/`SIGINT` → `server.close()` → `dispose`. | | Hono / edge builds (`app.mjs`) | **Not invoked.** Edge hosts expose no shutdown hook. A per-isolate pool is also the wrong shape on workerd, which is why the generated `stores.mjs` opens one pool per request. `setup` still runs once per isolate. | | `langsmith` | No middleware is materialized. | #### Validating the final assistant message `handle` runs before the route executes, so it can't check the model's *output*. Checking it inside a tool costs an extra model turn, and the model can skip the tool. Instead, the object form's `after` hook runs on the model's final assistant message, with the context `handle` allowed, before the client sees it: `src/middleware.ts`: ```ts import { allow, defineMiddleware, reject } from "@b4run/sdk" import { validateUi } from "../assistant-ui" export default defineMiddleware({ async handle(req) { const session = await loadSession(req.headers.authorization) if (!session) return reject(401) return allow({ session }) }, async after({ context, finalMessage }) { const verdict = validateUi(finalMessage, context?.session) if (!verdict.ok) return reject(422, { error: verdict.reason }) return { finalMessage: JSON.stringify(verdict.ui) } }, }) ``` ##### `after(run)` Runs once per AG-UI run (`POST /agui/:routeId`) after the agent's final assistant message is known and before the client is shown it. It receives: | Field | Shape | Notes | |---|---|---| | `finalMessage` | `string` | The last assistant message the run produced, concatenated from its streamed deltas. `""` when the run ended without one, for example after a tool call the model never followed up on. | | `context` | `Readonly> \| undefined` | Whatever this request's `handle` returned through `allow(context)`. | | `messages` | `readonly { role, content, id? }[]` | The conversation the client sent with the run. | | `threadId`, `runId` | `string` | The AG-UI run identity. | | `routeId`, `assistantId` | `string` | Same values `handle` saw. | It returns one of three things: - **Nothing** (`undefined`): the final message is delivered as the model produced it. - **`{ finalMessage: string }`**: the final message is replaced. The replacement is delivered as a single `TEXT_MESSAGE_CONTENT` delta under the same message id the model's text would have had. An empty string suppresses the message entirely. - **`reject(status, body?)`**: the run ends with a `RUN_ERROR` event instead of the final message and `RUN_FINISHED`. The event's `message` is the body when it is a string, or its `message` / `error` field when it is an object. Its `code` is `"middleware_rejected"`. The HTTP `status` can't change the response, because it's already streaming with a `200`. The status is only useful in your own logs. A hook that throws also ends the run with a `RUN_ERROR` carrying the error's message. ##### What the client sees The runtime streams assistant text token by token, and B4 only knows which message is final once the run is over. AG-UI has no retraction event, so B4.run keeps the AG-UI event stream as it is and accepts some latency: - With `after` defined, text that may turn out to be the final message is **buffered on the server** and delivered whole, immediately before `RUN_FINISHED`. The client still receives the usual `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` frames for it, just all at once. - Text the model produces **before a tool call still streams live**. The tool call proves it wasn't the final message, so B4 releases it as soon as the tool call arrives. Tool frames and capability events (plans, subagent activity) are never held, so they can reach the client ahead of text the model produced just before them. - A run that parks on a human-in-the-loop interrupt does **not** run the hook. Its final message belongs to the resume that completes it, and the hook runs there. - Without `after`, B4.run leaves the run stream unwrapped, so an app that only defines `handle` emits the same events as before. `after` runs behind the same lazy `setup` as `handle`, and only after `handle` has allowed the request, so it can use a resource `setup` opened. Only the AG-UI endpoint calls it. The Agent Protocol run endpoints (`/threads/:id/runs/*`) end with the graph's output, not a framed assistant message, so they skip it. #### Single-function model B4.run middleware is one global handler. There's no stack of layered middlewares. To branch by route, use 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 you never have to reason about middleware ordering. #### 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. | An app with a [thread-access policy](https://b4.run/docs/thread-access) also authorizes those five endpoints against the thread they name. Middleware answers "may this caller run this route", the policy answers "may this caller touch this thread", and a request must pass both. An app with no policy file is gated by middleware alone. The endpoints middleware does **not** see are `/healthz` (a liveness probe), `/readyz` (a readiness probe), thread create, read and delete, `GET /threads/:id/state`, `POST /threads/:id/cancel`, and the memory-candidate endpoints. Every thread endpoint in that list is covered by the thread-access policy. The two probes and the memory-candidate endpoints have no gate of either kind, so put outer authentication in front of 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](https://b4.run/docs/thread-access#the-run-endpoints-and-middleware). > **Warning: Middleware can now see a GET** > > `GET /threads/:id/pending_interrupts` returns the human-in-the-loop prompts parked on a thread, so it's gated like a run. It's the first gated endpoint whose `req.method` isn't `"POST"`. Middleware that branches on `req.method === "POST"`, or that reads `req.params` to decide access, falls through on it. See [the endpoint reference](https://b4.run/docs/dev-server/agent-protocol#recovering-prompts-without-a-live-stream) for how its route identity is resolved. Middleware also runs on Windows now. If you develop on Windows, a middleware file that used to be inert there will start gating requests. The `langsmith` build target is different. Its generated graph entries don't include B4.run HTTP middleware, so put equivalent authentication in the LangSmith platform when you deploy them. > **Warning: Middleware is execution-only** > > This hook can't serve as blanket service authentication, because create/read/delete, state, cancellation, health, and memory-candidate routes bypass it. Thread authorization is a [separate file on a separate axis](https://b4.run/docs/thread-access) that covers the thread endpoints. Health and memory-candidate routes are covered by neither. Put outer authentication around the complete runtime surface as described in [Security Architecture](https://b4.run/docs/security-architecture). #### When middleware fails to load Middleware is an authorization gate, so B4.run refuses to start without it. A middleware file that is present but can't 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 during import (a missing environment variable, an ESM/CJS interop break, a syntax error or an unresolved dependency). It also covers a file B4.run can't 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". A few things to note: - **An app with no middleware file is unaffected.** All four candidates are absent, so the boot proceeds with every request allowed. - **The first existing candidate is the only candidate.** If `src/middleware.ts` exists but fails to import, B4.run won't quietly fall back to a `middleware.ts` at the app root. - **A file that exports no middleware function is not fatal.** It's 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 your deploy's health check sees the failure before traffic reaches an ungated server. #### Errors thrown from middleware If a middleware function loads fine and then throws *while handling a request*, or its `setup` hook rejects, that request gets HTTP `500`. The server keeps running, and the next request retries a failed `setup`. Prefer an explicit `reject(status, body?)` over throwing, because it gives users a meaningful response. #### Related - [Routes](https://b4.run/docs/routes): how route discovery and dynamic segments work - [Tools](https://b4.run/docs/tools): full tool authoring contract, including the ctx shape - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): request envelope and assistant-id format - [Auth Middleware](https://b4.run/docs/recipes/auth-middleware): recipe verifying a bearer token and flowing identity to tools - [Security Architecture](https://b4.run/docs/security-architecture): outer authentication and tenant authorization --- ### Dev Server Source: https://b4.run/docs/dev-server `b4 dev` runs your app locally. It starts B4.run's HTTP runtime, watches your files, regenerates types, and restarts the runtime when source files change. If you're building a client, see the protocol guides linked below. #### Starting the server ```bash b4 dev ``` The server picks a free localhost port and prints a URL such as `B4.run dev ready at http://127.0.0.1:43127`. Pass `--port` when you need a fixed address: ```bash b4 dev --port 3001 ``` The server always listens on `127.0.0.1`. #### Invoking a route With the server running, call a route from another terminal: ```bash echo '{"messages":[{"role":"user","content":"Hello"}]}' | b4 run '/research' --url http://127.0.0.1:3001 ``` `b4 run` looks up the route's `#` assistant id, creates a thread for the call, sends the input through Agent Protocol, and prints the final state. #### Restart cycle When an app file changes, B4.run regenerates types and then starts a new runtime. Changes under ignored paths such as `.b4/`, `workspace/` and lockfiles are skipped. For everything else, the restart log names the reason. `b4 dev` runs two processes, a parent and a child: - 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 the child and its listener, then starts a fresh child on the same port. The parent never holds the listener. The replacement child reloads the app and `b4.config.ts`, including store and runtime configuration. Configuration edits take effect on that restart. The default SQLite thread store and checkpointer keep your threads and checkpoints across restarts. Other durable stores preserve data too, once the new child reconnects. An in-memory store starts empty after each restart. Running work gets a short grace period before the old child is killed. #### Logging The ready line shows the current URL, and each restart line shows what kind of file changed. Set `B4_DEV_SHUTDOWN_TIMEOUT_MS` to change the grace period for stopping the old child. #### AG-UI endpoint Browser clients use the separate [AG-UI endpoint and lifecycle](https://b4.run/docs/ag-ui). #### Tracing See [Observability](https://b4.run/docs/observability) for the environment variables, trace setup, and how interrupts show up in traces. #### Middleware See [Middleware](https://b4.run/docs/middleware) for how middleware gates route runs, and [Security Architecture](https://b4.run/docs/security-architecture) for the management routes that need authentication in front of the whole service. [Thread access](https://b4.run/docs/thread-access) is a second gate inside the runtime. It checks the thread rather than the route, and it covers every thread endpoint, including the ones middleware never sees. A request must pass both. #### Related - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): thread, run, stream, resume, cancel, and candidate-management contracts - [AG-UI and Web Clients](https://b4.run/docs/ag-ui): the browser-oriented endpoint and adapter - [Scenario Testing](https://b4.run/docs/testing): exercise scenarios against a live server - [Deployment Options](https://b4.run/docs/deployment): choose a production target after local development - [CLI Reference](https://b4.run/docs/cli): all b4 dev flags and commands --- ### Planning Source: https://b4.run/docs/planning Planning gives an agent route a todo list you can see. You seed it from a file, the model updates it, B4.run keeps it in route state, and clients get it over the stream. Use planning when a route does work in several steps and you want to see the model's plan. #### 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: `src/app/support/[tenant]/plan.md`: ```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 that let the model keep the plan current: - a `writeTodos` tool - a `todos` state channel - a planning prompt fragment - a `plan_update` stream event after `writeTodos` runs #### 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 ``` The parser skips other markdown and empty checklist items. Seed items start as `pending` or `completed`, and the model can also set `in_progress` later. An empty `plan.md` still turns on planning, with an empty todo list. B4.run ignores the seed items in a file larger than 64 KiB. Planning still works, but the list starts 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` replaces the whole list, so the agent passes every item each time. The tool updates the run's state. `plan.md` is only the starting list and stays as you wrote it. The tool returns the new todos and updates the route state: ```ts { result: { todos }, state: { todos }, } ``` B4.run's LangChain adapter turns that into a state update, so the next model turn sees the new list. #### State and prompts Planning adds a `todos` state field with a `replace` reducer. Two names are taken: - Don't declare your own `todos` field in `state.ts`. The route fails to load, with an error telling you to remove the field or `plan.md`. - Don't create a tool named `writeTodos`. A tool of yours with the same name as a capability tool is a conflict. B4.run rebuilds the planning prompt from the current state on each turn. 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 keeps the plan in front of the model 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 id for the `writeTodos` call behind the update (the same id that call's tool events carry). It's left out when the model didn't supply one. When a subagent sends a planning event, the parent stream forwards it with a `subagent.` prefix, 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 When a route directory has a `plan.md`, `b4 typegen` adds `writeTodos` to that route's generated tool types. B4.run only runs planning in `agent()` routes, though. The `todos` field exists at runtime only, and the generated route state types still come from `state.ts`. #### Under the hood Planning is implemented by `createPlanningMarker()`. When B4.run prepares an agent route, it finds `plan.md` and reads the seed todos. Then it adds the `writeTodos` tool, the `todos` state field, and a stream transformer that watches tool results. The transformer handles both plain `{ todos }` outputs and LangGraph Command outputs that keep the update in `update.todos`. #### Related - [State](https://b4.run/docs/state): how route state is declared and merged - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): streaming protocol and /runs/stream - [Memory](https://b4.run/docs/memory): workspace-level prompt context - [Subagents](https://b4.run/docs/subagents): child runs can stream subagent.plan_update --- ### Skills Source: https://b4.run/docs/skills Skills let a route offer long instructions without putting them all in the system prompt. The model sees a short list of skills and calls `readSkill({ name })` when it needs one, so the prompt stays small. Use skills for instructions that are too long for `systemPrompt` or only apply sometimes, such as refund rules, triage steps 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 needs frontmatter with a `description`: `src/app/support/[tenant]/skills/refund-policy/SKILL.md`: ```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` when the model asks for it #### Skill names By default, the skill name is the directory name. For example, this file creates a skill named `refund-policy`: ```text skills/refund-policy/SKILL.md ``` To give the model a different name, set `name` in the frontmatter: ```md --- name: refunds description: Instructions for refund, exchange, and exception questions. --- ``` Each skill name must be unique within the route. If two skills end up with the same name, the route fails to load with a duplicate-name error. A directory name starts with a letter or number and can contain letters, numbers, underscores, and hyphens. B4.run skips directories whose names start with a dot or contain spaces or other punctuation. #### What the model sees B4.run adds a short section to the prompt: ```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. ``` Skills are listed by name, in alphabetical order. The `readSkill` tool takes: ```ts { name: string } ``` For a known skill, it returns the markdown body after the frontmatter. For an unknown name, it returns a string listing the available names. Input of the wrong shape fails schema validation. B4.run reads the skill files once, when it prepares the route. `readSkill` serves them from memory. #### Frontmatter requirements `description` is required, because it's all the model sees when it decides whether to load the skill. Here is a valid skill: ```md --- description: Use for billing dispute triage. --- ... ``` This one fails to load: ```md Use for billing dispute triage. ``` So does this: ```md --- name: billing-disputes --- ... ``` Keep the description short, and say when to use the skill. B4.run's frontmatter parser is small on purpose. Stick to simple `key: value` fields rather than full YAML. #### Generated types When a route has at least one valid `skills//SKILL.md` file, `b4 typegen` adds `readSkill` to that route's generated tool types. The generated input type is: ```ts { name: string } ``` The output type is: ```ts string ``` #### Under the hood Skills are implemented by `createSkillsMarker()`. When B4.run prepares an agent route, it reads each skill's frontmatter and body, then adds the `readSkill` tool and the prompt section. Skills add no state fields or stream transformers. #### Related - [Tools](https://b4.run/docs/tools): route-local tools and schemas - [Agents](https://b4.run/docs/agents): agent descriptors and system prompts - [Memory](https://b4.run/docs/memory): workspace-wide context loaded every turn - [Subagents](https://b4.run/docs/subagents): delegate work to another agent route --- ### Subagents Source: https://b4.run/docs/subagents Subagents let one agent route hand a focused task to another agent route. When the parent has at least one child it can dispatch to, it gets an internal `task({ subagent, input })` tool and a `# Subagents` prompt section. The child is still a full B4.run route with its own prompt, tools, state, memory, planning, skills and subagents. Use subagents when a specialist should handle a piece of the work. > **Info: Delegation allows by default** > > Leaving out `delegation` is the same as `delegation: { default: "allow", rules: {} }`. Every child, whether B4.run found it in `subagents/` or you registered it, can be dispatched unless the parent sets a stricter policy. For an allowlist, use `default: "deny"` with allow rules. #### 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 exports an `agent()`. Give it a `description`, since the parent uses it to decide when to delegate: `src/app/support/[tenant]/subagents/research/index.ts`: ```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 gives it a `task` tool that the model calls like this: ```ts task({ subagent: "research", input: "Find the current refund policy for annual plans.", }) ``` The parent gets the child's final text back as the tool result. > **Info: Subagent tool inheritance** > > A child gets the shared `src/tools/*` and its own `tools/*`. The parent's local tools stay with the parent. Capability tools such as `writeFile` and `runBash` are withheld unless you grant them with `tools.allow`. To keep a sensitive shared tool from a child, deny it, for example `tools: { deny: ["deployProd"] }`. A child with children of its own gets its own `task` tool. Keep `task` out of tool policy. See [Scoping a route's tools](https://b4.run/docs/tools#scoping-a-routes-tools). A subagent can set its own `tools: { approve: [...] }` to ask a person before each call to those tools. The `kind: "tool"` interrupt shows up on the **parent's** stream, next to the other `subagent.*` events. See [Per-tool approval](https://b4.run/docs/permissions#per-tool-approval). #### Convention discovery B4.run finds direct child routes at: ```text /subagents//index.ts ``` The model names a child by its folder name, such as `"research"`. Only direct children appear in the parent's prompt. A child can have its own `subagents/` directory, and those children belong to it. If a child route has no `description`, B4.run lists it as `No description provided.` The route still works, but the parent won't pick it as reliably. Children found by folder alone get the parent's `delegation.default` rule. Named rules are typed from registration keys, so to give a child its own rule, import and register it. #### Keyed registration Register children in a keyed object when you want to choose their names or give them their own policy rules: `src/app/support/[tenant]/index.ts`: ```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 key is the name the parent model sees. Delegation policy and saved permissions use it too. It only applies to that parent, so two parents can register the same child under different names. TypeScript limits the keys in `delegation.rules` to the keys in `subagents`. Names must match `^[A-Za-z0-9][A-Za-z0-9_-]*$`, and B4.run uses them as written (no trimming or case folding). If you register a child that also lives in `subagents/`, your key replaces the folder name for that parent. The folder name stops working, so the child can't be reached around its named rule. Registration is always an object, such as `subagents: { researcher, writer }`. Arrays aren't accepted. #### Delegation policy Each parent sets the policy for dispatching to its direct children: ```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 it defaults to `"allow"`. A named rule uses 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 gets `{ input }` plus the parent route, the child's name and route, the thread, the route parameters, and the 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 }, }, }, }) ``` Return `true` to allow the dispatch, a string to deny it with that reason, or `{ approve: true, reason? }` to ask a person. If the function throws or returns anything else, the dispatch fails closed and the child never starts. A constraint can read the input but can't change it. Each level checks its own policy. A parent's rules cover only its direct children. When a child dispatches a grandchild, the child's own `delegation` policy decides. Approvals work the same way. `always` saves the pair of parent route id and subagent name. Approving `researcher` under `/support` doesn't approve a child with the same name under `/finance`, another child of `/support`, or anything deeper. See [Subagent approval](https://b4.run/docs/permissions#subagent-approval). #### What the model sees B4.run adds a prompt section like this: ```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 `task` schema lists the parent's subagent names as an enum, so the model can only name known subagents. Children that are always denied are left out of both the prompt and the schema. Allowed, approval-gated, and constrained children stay listed, since a call to them can succeed. #### Runtime behavior At runtime, B4.run looks up the child and applies the parent's policy right before dispatch. The child gets the task as a user message: ```ts { messages: [{ role: "user", content: input }] } ``` Each dispatch runs as its own LangGraph subgraph, using the root thread's checkpointer. Nested and parallel children keep separate checkpoint namespaces, and you resume all of them through the root thread. B4.run tracks the depth of nested calls and stops at a depth of 3. That's also what stops a cycle in explicit registrations. #### Dispatch failures When a dispatch fails, the parent model gets a coded result: - `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. An invalid registration or policy fails `b4 check` or route preparation with `B4_E1004`, and never falls back to allow. The model never sees a constraint's exception. Set `B4_DEBUG_CONSTRAINTS=1` locally to log it, with the parent and child names. #### Streaming When you run the parent through `runs/stream`, B4.run forwards the child's activity as `subagent.*` events: - `subagent.start` - `subagent.token`, `subagent.reasoning` and `subagent.message_end` - `subagent.tool_call`, `subagent.tool_call_args` and `subagent.tool_result` - `subagent.usage` - capability events such as `subagent.plan_update` - `subagent.end` Each forwarded event carries the child's `call_id`, name, route id and depth, and otherwise has the shape of the root event of the same name. `subagent.start` also includes the child's declared description and, for a nested child, `parent_call_id`. `subagent.end` includes either `final_message` or `error`. While a child runs, its tokens arrive as `subagent.token` with the child's `messageId`, and B4.run drops the duplicate parent message chunks. Child tool calls carry the model's tool-call id, the same id their results carry. See [Streaming output](https://b4.run/docs/recipes/stream-output#sse-event-types) for the payloads. If a child hits a tool, path, command, memory, or delegation approval, B4.run sends a top-level `interrupt` on the root parent's stream. Resume the root thread with every pending interrupt, by ID. Don't start or resume the child yourself. See [Resuming an interrupted run](https://b4.run/docs/permissions#resuming-an-interrupted-run). #### Names and reserved policy fields Folder-discovered and registered subagents share one set of names per parent. Route preparation reports `B4_E1004` for: - invalid names - children it can't resolve, or can't tell apart - the same route registered twice - name collisions `task` is the internal tool the model uses to dispatch. It's invalid in `tools.allow`, `tools.deny`, `tools.approve` and `tools.constrain`, because only the parent's `delegation` policy controls dispatch. A subagent can still be registered under the key `task`, since registration keys are a separate set of names. Don't create a route tool named `task`. To resume, send the full resume body described in [Permissions](https://b4.run/docs/permissions#resuming-an-interrupted-run). #### Related - [Agents](https://b4.run/docs/agents): child routes are still B4.run agents - [Routes](https://b4.run/docs/routes): how folder paths become route ids - [Planning](https://b4.run/docs/planning): child plan updates stream as subagent.plan_update - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): subagent stream events and root-thread resume - [Tools](https://b4.run/docs/tools): task is an internal delegation mechanism --- ### Context Management Source: https://b4.run/docs/context-management Long agent runs pile up tool results, model replies and subagent messages. Left alone, that history can overflow the model's context window and cause errors or truncation. B4.run keeps it in check two ways: - **Tool-output offloading** is on by default. - **Summarization** is off until you turn it on. #### Tool-output offloading When a tool returns a large result, B4.run writes the full output to `workspace/tool-outputs/` and puts a short stub in the context. The stub has a few preview lines (you choose how many) and a file path. When the agent needs the full content, it calls `readFile` with that path, and the content goes into the next model turn. Offloading turns on as soon as a `workspace/` directory exists at the app root. There's nothing else to configure. ##### What the model sees The 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 work from the preview or call `readFile` for the rest. The full content stays on disk, so it's there on later turns without using context tokens. ##### Configuration Here are the `toolOutput` options with their defaults: `b4.config.ts`: ```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` | Offload results longer than this many characters (serialized). | | `previewLines` | `number` | `10` | Lines kept in the stub. | | `maxBytes` | `number` | `268435456` | Total bytes kept in `workspace/tool-outputs/`. Past that, the oldest files go first. | | `ttlMs` | `number` | `10800000` | Delete offloaded files older than this (3 hours). | | `gcThrottleMs` | `number` | `10000` | Minimum time between cleanup passes (10 seconds). | | `noOffloadTools` | `string[]` | `[]` | More tools to never offload, on top of `readFile` and `listDir`. | Offloading needs a filesystem, so the build [turns `toolOutput` off for the `hono` edge target](https://b4.run/docs/deployment/edge#what-the-edge-cannot-serve). A runtime with no filesystem fails each request with `B4_E1005` rather than silently ignoring the settings. Summarization needs no filesystem and runs on every target. ##### Exemptions `readFile` and `listDir` are never offloaded, so the agent can read an offloaded file back without it getting offloaded again. Use `noOffloadTools` to exempt more tools, such as one that already returns a short summary. ##### Garbage collection At most once per `gcThrottleMs`, B4.run runs a cleanup pass in the background. It deletes files older than `ttlMs`. If the total is still over `maxBytes`, it deletes the oldest files until it fits. Agent turns never wait on it. #### Conversation summarization Once a thread's token count passes a threshold, summarization shrinks the older messages. The most recent turns stay word for word. Everything older is folded into a running summary, which goes at the start of the thread on the next model call. Let's enable it in `b4.config.ts`: `b4.config.ts`: ```ts export default { summarization: { enabled: true, }, } ``` ##### Configuration | Key | Type | Default | Description | |---|---|---|---| | `enabled` | `boolean` | `false` | Turns summarization on. | | `maxTokens` | `number` | `12000` | Summarize older history past this many tokens. | | `keepRecentTurns` | `number` | `6` | Recent turns (each starting at a `HumanMessage`) that are never summarized. | | `model` | `string` | Route's model | Model that writes the summary. | | `tokenCounter` | `(text: string) => number \| Promise` | `gpt-tokenizer` (o200k_base), loaded on demand | Your own token counter. | | `summarize` | `(args) => Promise` | One model call | Your own summarizer. Receives `messages`, `model`, `previousSummary`, `signal`, and `maxRetries` (the route's [`retry.maxAttempts`](https://b4.run/docs/retry) minus one). | Use `tokenCounter` to plug in a different tokenizer. `summarize` replaces the whole summary step, for example to use a cheaper model, compress in a way that suits your domain, or call a different provider. #### How they compose Offloading and summarization handle different kinds of growth: - **Tool-output offloading** works on one tool result at a time. It keeps a single large result from filling the context. - **Summarization** works on the whole thread. It shrinks history that built up over many turns. You can use both at once. Here's a typical setup for a long-running research agent: `b4.config.ts`: ```ts export default { toolOutput: { offloadThresholdChars: 1500, // tight threshold for a research agent previewLines: 10, }, summarization: { enabled: true, maxTokens: 12000, keepRecentTurns: 6, }, } ``` #### Related - [Configuration Reference](https://b4.run/docs/configuration): complete b4.config.ts reference for toolOutput and summarization - [Workspace Filesystem](https://b4.run/docs/workspace): workspace/tool-outputs/ and the readFile tool --- ### Reasoning Source: https://b4.run/docs/reasoning-effort `reasoning` tunes how hard an `agent()` route's model thinks, and whether the thinking is shown. It is keyed by provider, and only the block for the route's resolved provider is read. #### Quick start `src/app/support/[tenant]/index.ts`: ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", reasoning: { openai: { effort: "high", summary: "auto" } }, systemPrompt: "You are a careful support assistant.", }) ``` `ReasoningConfig`, `OpenAIReasoningConfig` and `AnthropicReasoningConfig` are exported from `@b4run/sdk`. #### OpenAI ```ts reasoning: { openai: { effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" summary?: "auto" | "concise" | "detailed" } } ``` `effort` is the request's reasoning effort. The provider decides which values mean something for each model, and models without reasoning ignore it. `summary` asks the Responses API to stream a summary of the model's reasoning. Setting it moves the route to the Responses API (B4.run sets `ChatOpenAI`'s `useResponsesApi`), and the summary streams to clients as reasoning: AG-UI `REASONING_*` events, which CopilotKit renders as a reasoning message. Without `summary`, the model still reasons at the requested effort but nothing is shown. #### Anthropic ```ts reasoning: { anthropic: { budgetTokens: number // a whole number of at least 1024 } } ``` `budgetTokens` enables extended thinking with that budget (`ChatAnthropic`'s `thinking: { type: "enabled", budget_tokens }`). The thinking text streams to clients the same way an OpenAI summary does. #### What B4.run checks The block must match the route's provider. `reasoning.openai` on a route that resolves to Anthropic, or either block on a provider with no reasoning controls, fails the route when its model is built, naming the misplaced key. A setting is never silently ignored. An unknown key (including the pre-0.14 flat `effort`) and a `budgetTokens` under 1024 fail the same way. B4.run does not check whether a particular model accepts the setting; that is the provider's error, surfaced as a model-call failure. Leave out `reasoning` and B4.run sends nothing, so the provider's default applies. #### Where it applies Reasoning applies to `agent()` routes, where `@b4run/langchain` builds the model. In `workflow`, raw `graph` and `chain` routes, and for any model you create yourself, set reasoning on that model instance. `GET /agui/{routeId}` advertises `reasoning: { supported: true, streaming: true, encrypted: false }` exactly when the route's config makes reasoning stream, and `{ supported: false }` otherwise. See [AG-UI](https://b4.run/docs/ag-ui#reasoning). #### Choosing a value Leave it unset until you see that a route needs it. Use lower effort for routes where speed and cost matter more than careful thinking: ```ts reasoning: { openai: { effort: "minimal" } } ``` Use higher effort when the route calls many tools, plans a lot, or has to combine several pieces of evidence: ```ts reasoning: { openai: { effort: "high" } } ``` Show the reasoning (`summary`, `budgetTokens`) when users benefit from seeing the plan, such as a research assistant or a long tool loop. Leave it hidden for short answers, where it is noise. Of course, more reasoning won't make up for missing tools or context. If the agent doesn't have the information, thinking harder won't find it. #### Subagents Each `agent()` sets its own reasoning, so a parent and child can use different values: ```ts // parent export default agent({ model: "gpt-5-mini", reasoning: { openai: { effort: "low" } }, systemPrompt: "Coordinate the work.", }) // child export default agent({ model: "gpt-5-mini", reasoning: { openai: { effort: "high", summary: "auto" } }, systemPrompt: "Analyze the evidence carefully.", }) ``` A child keeps its own setting, whatever the parent uses. #### Related - [Agents](https://b4.run/docs/agents): agent descriptors and runtime materialization - [SDK Reference](https://b4.run/docs/api/sdk#agent-and-agentconfig): AgentConfig, ReasoningConfig and the provider blocks - [Subagents](https://b4.run/docs/subagents): children configure their own reasoning - [AG-UI and Web Clients](https://b4.run/docs/ag-ui): how reasoning reaches web clients --- ### Retry Source: https://b4.run/docs/retry The `retry` option on `agent()` sets how many times each model call is tried when it fails for a temporary reason: a server error, a network error or a rate limit. Every model call in the tool loop retries on its own. B4.run never restarts the run, so tools never run twice and the client never sees a token twice. #### Configuring retry Set `retry` in `agent()`: `src/app/(public)/research/index.ts`: ```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` | Attempts per model call, counting the first. `1` sends each call once. | | `baseDelay` | `1000` (ms) | Wait before the first retry of a capacity rate limit (see below). It doubles each retry. | If you omit `retry`, each model call gets 3 attempts. `retry` must be an object whose only keys are `maxAttempts` and `baseDelay`. `maxAttempts` must be a whole number of at least 1, and `baseDelay` a number of milliseconds of at least 0. Anything else, such as a misspelled `maxAttemps`, fails the route the first time it runs, with an error that names the valid keys or the bad value, rather than quietly changing how often it retries. #### What's retried Two layers retry a model call, and both read `maxAttempts`. **LangChain retries the request.** B4.run builds the route's chat model with `maxRetries: maxAttempts - 1`, and LangChain re-sends a failed request up to that many times: - **Server and network errors, and other transient failures:** `5xx` responses, dropped connections, timeouts, and any other error that isn't one of the fail-fast cases below. - **Rate limits with a short wait:** a `429` whose `Retry-After` header, or a "try again in …" hint in its message, is 60 seconds or less. **B4.run retries a capacity rate limit.** LangChain hands back, without retrying, a `429` with no `Retry-After` or hint or with one over 60 seconds, and marks it retryable. B4.run sends that model call again, up to `maxAttempts` attempts of the call in all. In practice that is a `429` with no `Retry-After`: one that carries a wait is always over 60 seconds by then, past B4.run's 10-second cap, so it fails at once (see below). **These fail right away:** - A quota `429` (for example OpenAI's `insufficient_quota`). Waiting doesn't bring a quota back. - A `429` whose `Retry-After` is longer than 60 seconds. LangChain waits out a `Retry-After` of up to 60 seconds itself and hands back a longer one; B4.run won't wait more than 10 seconds, or send the call sooner than the provider asked, so the run fails at once. The provider's error keeps the wait in `retryAfterMs` (milliseconds), on the `cause` of the error the run ends with, for code that runs the agent in process. Over HTTP the client gets the error's message. - An invalid API key, a model that doesn't exist, or a malformed request. LangChain doesn't retry `400` to `407`, `409` or `413`. - An aborted run. When a model call runs out of attempts, the run fails with that call's error. #### Backoff Each layer waits in its own way. LangChain waits 1 to 2 seconds before its first retry (1 second times a random jitter factor) and doubles that each time, or waits out the `Retry-After` when that's longer. `baseDelay` doesn't change LangChain's waits. Before B4.run sends a call again after a capacity rate limit, the wait before retry `n` (counting from zero) is: ``` delay = min(baseDelay * 2^n + jitter, 10s) jitter = random(0, 500ms) ``` When the error carries a `Retry-After` of 10 seconds or less, B4.run waits exactly that long instead. Over 10 seconds, it doesn't retry (see above). LangChain itself waits out any `Retry-After` up to 60 seconds, so in practice the `429`s B4.run retries are the ones with no `Retry-After`. With the defaults (3 attempts, 1s base), a model call that keeps hitting capacity limits is sent three times: | Attempt | Wait before this attempt | |---|---| | 1 (initial) | 0 | | 2 | ~1000–1500 ms | | 3 | ~2000–2500 ms | #### Streaming behavior Every retry happens before the model's response starts. A failed request never produced a token, so sending it again can't repeat anything the client has seen. A response that fails partway through, after some of it has streamed, isn't retried. The client already has the first part, and resending the call would show it twice. The error comes through the stream. If partial output is no use to you, retry the run from your client. Each model call in a tool loop is retried on its own. If the third model call of a run hits a rate limit, only that call is sent again: the earlier model calls and the tools they called don't run again. #### Abort signals Three things abort a run: an AG-UI client disconnecting, an Agent Protocol cancel (`POST /threads/:id/cancel`), and server shutdown. An Agent Protocol client disconnecting leaves the run going, so the thread can pick it up again. An abort during B4.run's wait before a retry ends the wait at once, and the call isn't sent again: the model call fails with the abort's reason. The same happens when the run was already aborted by the time the rate limit came back. LangChain's own wait between retries (up to 60 seconds for a `Retry-After`) isn't cut short on the OpenAI provider; the abort takes effect on the next request. The abort signal also reaches the agent and its tools as `ctx.signal`. Provider calls and tools have to listen to it for pending I/O to stop quickly. #### Per-route, not global Each `agent()` sets its own retry policy, and a [subagent](https://b4.run/docs/subagents) uses its own `agent()`'s. There's no global setting: ```ts // Critical billing 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: "...", }) ``` #### Other models B4.run builds - **Summarization.** When [summarization](https://b4.run/docs/context-management#conversation-summarization) is on, the model that writes the summary gets the route's `maxAttempts - 1` as its `maxRetries` too, and a custom `summarize` function receives that value as `maxRetries`. B4.run's own capacity rate-limit retry covers only the route's model calls, not the summarizer; a failed summary falls back to the full history for that turn. - **Memory distillation.** `b4 memory consolidate` and `b4 memory reflect` run outside any route, over every namespace they select, so no route's `retry` applies. Their model's attempts come from [`memory.distill.retry.maxAttempts`](https://b4.run/docs/memory/distillation#distillation-configuration) in `b4.config.ts` (default 3). It has no `baseDelay`, because there's no capacity rate-limit retry there to pace. `b4 check` and those two commands reject a `baseDelay`, an unknown or misplaced key, or a bad `maxAttempts` there with `B4_E1009`; see [Configuration](https://b4.run/docs/configuration#memory) for the full list. #### Limits - **Only the model call is retried, never the run.** An error outside a model call fails the turn, even a temporary one: for example, a checkpointer connection reset before the first event. Nothing sends the turn again; retry it from your client. - **A response that drops mid-stream isn't retried.** Once part of a response has streamed, the error comes through the stream (see [Streaming behavior](#streaming-behavior)). - **The attempt budget is per layer.** Each of B4.run's attempts is one LangChain request with its own `maxRetries`. A call whose failures mix server errors and capacity rate limits can make up to `maxAttempts²` requests before it fails. - **Ollama isn't retried by LangChain.** `ChatOllama` sends its chat requests without LangChain's retry layer, so its `maxRetries` has no effect and server and network errors on the `ollama` provider fail the call at once. - **Your own model instance keeps its own settings.** `retry` applies to the chat model B4.run builds from `agent()`. A route that exports a LangChain runnable you built, with a model you constructed yourself, uses that model's `maxRetries` (LangChain's default is 6); B4.run's capacity rate-limit retry doesn't apply to it. A runnable that streams is never re-run. One with only `invoke` (no `streamEvents`) is still re-run whole by a legacy fallback, up to 3 times, when its error message looks transient (a `429`, a `5xx`, a timeout or a network error). - **The summarizer and distillation models get `maxRetries` only.** B4.run's capacity rate-limit retry runs inside the agent's model calls, so it doesn't cover them (see [Other models B4.run builds](#other-models-b4run-builds)). - **Tools aren't retried.** `retry` covers model calls. For a tool, write the loop yourself; see [Retry Transient Model Calls](https://b4.run/docs/recipes/retry-flaky-tools). #### Related - [Routes](https://b4.run/docs/routes): agent() descriptor and the four route kinds - [Tools](https://b4.run/docs/tools): tool error handling and the ctx.signal contract - [Deployment Options](https://b4.run/docs/deployment): how retry composes with b4 build output - [Retry Transient Model Calls](https://b4.run/docs/recipes/retry-flaky-tools): recipe applying agent retry policies per route --- ### Memory Source: https://b4.run/docs/memory Memory lets an agent use what it learned in earlier threads. B4.run has three memory mechanisms, and you pick one by who writes the data and how the agent reads it. (Route state and checkpoints preserve workflow execution. They're not a fourth memory mechanism.) | Mechanism | Scope and shape | Best for | Written by | |---|---|---|---| | `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`](https://b4.run/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 the prompt files for small, reviewed context. When records need a schema, governance, retention, or retrieval, the smallest useful `memory.ts` is: `src/app/navlog/memory.ts`: ```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 the guide for your task: - [Long-term Memory](https://b4.run/docs/memory/long-term): schemas, the generated tools, write governance, candidate review, configuration, and testing. - [Recall and Retrieval](https://b4.run/docs/memory/retrieval): how recall ranks records, opt-in semantic recall, and the pgvector backend. - [Episodes](https://b4.run/docs/memory/episodes): the run recorder, retention, and time-windowed recall. - [Distillation](https://b4.run/docs/memory/distillation): consolidating and reflecting on stored records with `b4 memory`. - [Browse and Manage Memory](https://b4.run/docs/memory/browse): building an authenticated admin surface over the store. #### Workspace profile (`AGENTS.md`) If `workspace/AGENTS.md` exists and has content, B4.run injects it under `# Memory` on every model turn. Every agent route and subagent that uses the same app root shares it. Raw graph, workflow, and chain routes skip it. `workspace/AGENTS.md`: ```md # Workspace Memory - Use pnpm for package commands. - Prefer short, direct customer replies. - Escalate billing exceptions to the finance queue. ``` With the default local app root, every thread reads the same file on the host, even with a per-thread sandbox. Use separate app roots or trust domains for tenants, constrain untrusted writes, and review seeded content. If the file is house style your team maintains rather than agent memory, set [`agentsMd: { writable: false }`](https://b4.run/docs/configuration#agentsmd). B4.run then injects it under `# Project guidance` and tells the model not to modify it. That changes only the prompt, so enforce it with a [filesystem middleware](https://b4.run/docs/workspace#middleware) that refuses writes to `AGENTS.md`, and deny or gate `runBash` for routes that can reach the workspace, since a shell command bypasses that middleware. ##### Updating it See [Workspace Filesystem](https://b4.run/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/navlog/ index.ts memory.md ``` `src/app/navlog/memory.md`: ```md # Navlog Route Memory - The aircraft profile lives in long-term memory as subject `aircraft` with predicates `tail_number`, `cruise_rpm`, `usable_fuel_gal`, `reserve_minutes`. Recall it at the start of every plan; remember new facts the pilot states. - Performance numbers come from POH tables the performance subagent read; cite the figure. Weather comes from the live tools; quote the raw observation. - `computeNavlog` owns every number on the navlog. ``` B4.run trims the file and injects it under `# Route Memory`, after `workspace/AGENTS.md`, on every model turn. Creating the file is the whole opt-in. You don't import or register anything. The file is a versioned prompt fragment that only you edit. The agent reads it and never writes to it. Empty files contribute nothing. Files larger than 32 KiB are skipped and produce a prompt note. #### Related - [Workspace Filesystem](https://b4.run/docs/workspace): file tools and backends - [Persistence and Tenancy](https://b4.run/docs/persistence): stores and tenant ownership --- ### Long-term Memory Source: https://b4.run/docs/memory/long-term Add a route-local `memory.ts` when an agent needs durable, typed records rather than another prompt file. It declares the record shape, kind, namespace dimensions, and identity. #### Declare a collection Place `memory.ts` beside the agent route: `src/app/support/memory.ts`: ```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` exists in the type system only, so `remember` rejects `procedural` writes. #### Scope is addressing, not authentication The runtime constructs the route and workspace dimensions. Additional dimensions can come from `memory.resolveScope`: `b4.config.ts`: ```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 ``` B4.run keeps only the dimensions the route declares, so this example needs `agent` in the `scope` of `memory.ts`. The callback receives only `routePath` and `appRoot`. It does not receive verified identity or middleware context. Never derive a tenant from an untrusted route parameter, thread id, or request body. If a namespace needs a verified tenant or user, add your own wiring that authenticates that identity, and test cross-tenant isolation. See [Persistence and Tenancy](https://b4.run/docs/persistence#tenant-ownership). #### Generated `recall` and `remember` tools Typegen adds generated `recall` and `remember` tools to any agent route with a `memory.ts`: - `recall({ query?, kind?, tags?, limit?, since?, until? })` reads active in-scope records. [Recall and Retrieval](https://b4.run/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's omitted when `memory.writes` is `"off"`. The model-facing data type follows your Zod schema, and runtime validation still runs when the 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 always append. Their IDs salt the namespace and data with the write timestamp, so repeated writes normally stay distinct. The store upserts by ID, so identical namespace and data written in the same millisecond get the same ID and overwrite each other. Each record carries `createdAt` and `updatedAt`. Append kinds also use the request timestamp as `effectiveAt`. The model can supply content, tags, and confidence. It cannot set identity or timestamp fields. #### Write governance Let's configure the generated write tool in **b4.config.ts**: `b4.config.ts`: ```ts export default { memory: { writes: "candidate", }, } satisfies import("@b4run/core").B4Config ``` | Mode | Behavior | |---|---| | `candidate` | Default. Writes are stored for review and stay out of normal recall until approved. | | `auto` | Adds and reconciliations become active immediately. | | `ask` | Adds and idempotent updates act like `auto`. A semantic supersede goes through the memory permission gate. | | `off` | The generated `remember` tool is absent. | Approving a semantic candidate reconciles its identity before activation. Episodic and reflection candidates just activate, because append records can't contradict earlier events or insights. ##### `ask` mode `ask` gates only the semantic contradiction branch. A new fact with no matching identity, or one that repeats the same data, writes without a prompt. Episodic and reflection writes never trigger the supersede gate. In headless/non-interactive mode, an unknown decision is allowed, so `ask` behaves like `auto`. And 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. When a write must be prevented, use an explicit deny policy and application authorization. Keep in mind that an interactive decision lives in one process, so it won't authorize anything across replicas. #### Reviewing candidates During development, you can work the queue from the CLI: ```bash b4 memory list b4 memory approve b4 memory reject ``` The local runtime also exposes list, approve, and reject routes for candidates. They have no authentication of their own, so put them behind your own authentication and tenant authorization before exposing them. For a broader admin surface, follow [Browse and Manage Memory](https://b4.run/docs/memory/browse). #### Stores and lifecycle The default Node store is SQLite at `.b4/memory.sqlite`. Set `memory.store` to use another backend. `@b4run/memory-pgvector` provides a shared Postgres store with vector candidate retrieval. It stays separate from the checkpoint, thread metadata, and permissions stores. Long-term memory outlives the thread. Call the store's `delete(id)` to remove one record, and `prune({ now, namespacePrefix?, cap? })` to remove expired records and enforce episodic caps. Account deletion, retention, backups, and audit across every namespace-bearing store are your application's job. #### Configuration You set the store, governance mode, ranking, embeddings, episode recording, distillation, and scope resolution independently. Here are the first three: `b4.config.ts`: ```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 ``` A custom store owns its search behavior and ignores the built-in SQLite ranker options. Keep provider credentials and application identity out of model-authored scope data. #### Testing Seed known records without asking a model to call `remember`: `test/support-memory.test.ts`: ```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 builds the same workspace value the runtime uses: the basename of the app root, not the full filesystem path. The `:memory:` database is process-local, so there's no file to clean up. Test the namespace, schema rejection, candidate approval, semantic update/supersede behavior, and retention separately. Fix timestamps in store tests so recency and expiry don't depend on the wall clock. ##### Verifying against a real model Once the deterministic tests pass, add a narrow integration test that asks the configured model to recall a distinctive seeded fact and, separately, to propose a write. Assert on the tool event or stored record, not the model's wording. Keep candidate mode on unless the test exercises automatic writes. #### What's deferred A few things aren't built yet: - The `procedural` kind is typed but has no generated write path. - Nothing migrates records between namespace schemes. - There's no account-erasure transaction across stores. - `resolveScope` gets no verified request principal. A declared scope names the dimensions, and your application supplies the identity. #### Related - [Recall and Retrieval](https://b4.run/docs/memory/retrieval): search, hybrid ranking, and backend differences - [Episodes](https://b4.run/docs/memory/episodes): record settled runs - [Distillation](https://b4.run/docs/memory/distillation): explicit consolidation and reflection - [Browse and Manage Memory](https://b4.run/docs/memory/browse): administrative filtering and pagination --- ### Recall and Retrieval Source: https://b4.run/docs/memory/retrieval Recall is how an agent searches its memory. The generated `recall` tool builds a scoped query and hands it to `MemoryStore.search`. Use it when the question is “which memories help this run?” For administrative filtering, see [Browse and Manage Memory](https://b4.run/docs/memory/browse). #### The generated recall tool Here is a typical call: ```ts await recall({ query: "customer shipping preference", kind: "semantic", tags: ["customer"], since: "-30d", limit: 8, }) ``` The `memory.ts` scope declaration chooses which dimensions exist, and runtime values plus `memory.resolveScope` construct the namespace for those dimensions. The workspace and route values come from the app root and route path. `resolveScope` receives only `routePath` and `appRoot`. It does not receive verified identity, so tenant and user scope still need authenticated wiring in your application. `since` and `until` accept ISO instants or relative expressions, resolved against the request clock. The tool passes one fixed evaluation timestamp as `now`. That timestamp filters expired rows and anchors recency scoring, so fix it in tests that assert 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 an optional event-time window, without scoring 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, and ranked and hybrid search slice the ranked list first. Eligible tagged rows below that boundary are omitted, so a tagged result can hold fewer than `limit` records. Tags narrow a bounded result. They won't page through every tagged record. #### How recall ranks A keyword query tokenizes the query and the record content. `candidatePool` keeps the newest token-matching rows before scoring them, so a relevant older match outside that pool can't reach the result. As histories grow, raise the pool and measure latency and quality. The pool is then ranked with three signals: | 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 compares whole tokens with no stemming, so use consistent words for IDs, product names, and domain terms. Here are the defaults in **b4.config.ts**: `b4.config.ts`: ```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 ``` Reproducing a ranking needs the same store snapshot, tuning, and fixed evaluation timestamp. Advancing `now` alone can change expiry and recency. #### Semantic recall (opt-in) Add an embedder when paraphrases must match without sharing tokens: `b4.config.ts`: ```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 the vector-nearest list, combines their ranks with Reciprocal Rank Fusion, and then applies bounded recency and confidence. Keyword matches stay in because embeddings are weak on IDs, codes, and names. Every stored embedding carries the embedder model id. Vector comparison only includes rows whose model id matches the active embedder, so incompatible vector spaces are never compared. After you change embedders, older rows still take part in keyword search until you re-embed them. If embedding fails, writes and recall fall back to keyword-only without an error. Set `B4_DEBUG_MEMORY=1` to see those 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: `b4.config.ts`: ```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. Sharing one `embedder` value makes this a complete hybrid configuration. If you omit `memory.vector`, the pgvector store stays on keyword-only recall even though it has a vector column. SQLite loads matching-model vectors and runs 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 differ, so the two backends can return different orders. Evaluate retrieval quality on each backend you deploy, with realistic data. A custom store brings its own ranking, and the built-in `memory.recall` and `memory.vector` tuning is ignored. Document and test how that store's `MemoryStore.search` behaves yourself. #### The injected index Before the model starts, B4.run runs a query-less search for a small index of active memories and adds `id: content` lines to the prompt. It's a recency-ordered sample of the store, so the agent should call `recall` for a specific topic or time window. #### Evaluate retrieval Build an evaluation set that expects record IDs rather than 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`, the embedder output, and the ranking config. Run the set against SQLite and pgvector separately, and expect a different order from each. #### Troubleshooting - No results: confirm the full 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 literal 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's expected. `browse` filters and sorts records without semantic ranking. #### Related - [Long-term Memory](https://b4.run/docs/memory/long-term): declare schema, scope, and writes - [Episodes](https://b4.run/docs/memory/episodes): time-windowed run history - [Browse and Manage Memory](https://b4.run/docs/memory/browse): administrative filters and cursors --- ### Episodes Source: https://b4.run/docs/memory/episodes Episodes record what a run did, so later runs can recall recent history. They come from an opt-in recorder for settled agent runs, or from agent-authored `episodic` records written with the generated `remember` tool. Episodic records append. A later event never reconciles with or supersedes an earlier one, even when their data looks similar. Recall can filter episodes by an event-time window, and [Distillation](https://b4.run/docs/memory/distillation) can later compact old episodes or derive reflections from them. #### Enabling the run recorder The recorder is disabled by default. Enable it for agent routes that have a resolved memory context: `b4.config.ts`: ```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. Setting it to true logs one warning, and 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 whole route must stay 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" } ``` Let's quickly review: - The source id is `runId` if present, then `threadId`, then the start instant. - `input` is length-bounded, and `toolsUsed` lists each tool name once. - `outcome` is `ok` or `error`. - Successful episodes take tool names from the final route state. Note, failed episode records currently use `toolsUsed: []` because no final state is available, even if tools ran before the error. - `effectiveAt` is the run's start time. `createdAt` and `updatedAt` are when the recorder wrote the record. Expiry counts from the start time. Recorder errors never fail the user's run. The first failure is logged, and later failures in that process are muted. If you depend on episode capture, monitor the store. #### 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, so a half-finished run never appears as history. This matters for human-in-the-loop permissions. An interrupt event means the route paused, and it may still fail or finish later. #### 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 it removes the oldest episodic rows beyond the cap, ordered by `effectiveAt` (falling back to `createdAt`) with `id` as a tie-break. Retention is lazy. It runs on a write or an explicit prune, and there's no background timer. If stale rows must go away without new traffic, run `b4 memory prune` on your own schedule. Deleting a thread leaves its episodes in place. Plan tenant erasure and retention around the memory namespace rather than the thread 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, both compared with `effectiveAt` (falling back to `createdAt`). Query-less windowed recall orders by that event time. Adding a query adds keyword ranking, recency, and confidence. #### Governance Recorded episodes are active as soon as they're written, and they skip candidate review. So limit who can run a route, derive tenant namespaces from verified application identity, and keep secrets out of model input the recorder might store. Agent-authored episodes follow `memory.writes`. `candidate` holds them for review. `auto` and `ask` activate them without a supersede prompt. To govern the two sources differently, choose whether to enable the recorder and how to expose `remember`. #### Agent-authored episodes Declare `kind: "episodic"` in the route's `memory.ts` when the agent should write domain events itself: `src/app/support/memory.ts`: ```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`. They append even when identity fields match. The recorder's TTL skips them, so apply retention yourself through the store or the CLI. #### 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 per run, 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, and query by kind and time window rather than relying on wall-clock order. #### Related - [Long-term Memory](https://b4.run/docs/memory/long-term): schema, namespace, and governance - [Recall and Retrieval](https://b4.run/docs/memory/retrieval): time windows and ranking - [Distillation](https://b4.run/docs/memory/distillation): compact episodes and derive insights --- ### Distillation Source: https://b4.run/docs/memory/distillation Distillation compacts and summarizes long-lived memory collections. Nothing runs automatically. B4.run consolidates or reflects only when you run `b4 memory consolidate` or `b4 memory reflect`, by hand or on your own schedule. #### The two commands Each pass has its own job: | 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 do nothing until their thresholds are met. They share these flags: ```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 no 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, as in 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`. Build 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. The oldest batches run first, so a capped run works through the backlog in order. 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. Writing before linking is deliberate. A failure after the summary write can leave a redundant active summary. Linking first could hide sources with no durable replacement. The operation as a whole isn't transactional. There is no generic safe-to-rerun guarantee. If no source got linked, a later pass can select the same batch and retry its deterministic summary ID. After a partial link failure, though, 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, which leaves a superseded source without its planned expiry. In both cases the command reports the affected source IDs and exits non-zero. Compare the summary's `derivedFrom` list with the source states, and do a manual reconciliation before the next pass. The summary contains `data.period`, `sourceCount`, and `derivedFrom`. It is tagged `consolidated` and uses the end of the source window as `effectiveAt`, which keeps it newer than its own superseded sources under the episodic cap. Later consolidation passes skip derived summaries. Superseded source rows stay inspectable for `sourceTtlMs` (seven days by default), and then normal pruning removes them. Without that expiry they would be invisible to recall but still count against the episodic cap, which ignores status. A source can expire before a scheduled pass if its own retention window is shorter, so line up the episode TTL, the consolidation age, and the schedule. #### Reflection Reflection asks “what have we learned?” rather than “what happened?” It works on the active semantic and episodic records of each namespace, matched in full rather than by prefix. The pass reads the newest `data.coveredUntil` watermark and selects records strictly newer than it. By default it needs at least ten of them, and it sends at most `maxRecords` (100 by default) to the model. With a larger backlog, only the newest window is processed and the watermark moves past it. The older rows then sit behind the watermark, and no later pass picks them up. Run reflection often enough, or raise `maxRecords`, so the backlog never outgrows the cap. Insights are reflection records with `derivedFrom` provenance, and they default to `candidate` status. Approve them through the [Long-term Memory candidate workflow](https://b4.run/docs/memory/long-term#reviewing-candidates), or set `memory.distill.reflect.writes` to `auto`. Each insight record carries the reflection watermark. Rejecting a candidate is a hard delete, so rejecting every candidate insight deletes every persisted watermark for that pass. With no watermark left, a later run can select the same inputs and repeat the model call and its cost. Before you reject a whole batch, approve at least one valid insight or preserve the watermark some other way. When the model returns no insights, B4.run writes a superseded no-insight sentinel. The sentinel advances the watermark and is never recalled, so a schedule won't pay again and again for the same 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 your data-classification, residency, retention, and provider policies before you enable distillation, and redact or exclude secrets and regulated data when you collect them. Treat stored memory as untrusted prompt input. Source content can contain prompt injection aimed at the distillation model, and model output can be wrong or malicious. Consolidation output is written active before source linking begins. Reflection output is a candidate by default, and you can configure it to be active too. So review and monitor derived records, restrict model and provider changes, and alert on unexpected summaries, insights, or link failures. #### Distilled records are found by keyword Consolidation and reflection writes skip embeddings for now. You'll find summaries and insights through keyword recall, query-less recall, and time filters. If derived records must be reachable by semantic recall, add your own re-embedding workflow and test how it handles model ids. Use specific, stable words in prompts and outputs so later keyword queries find the result. Names, ticket IDs, and domain terms help the most. #### Provenance Every derived record lists its source IDs in `data.derivedFrom`. Consolidation also links sources through supersession. Reflection leaves its inputs active and records the covered watermark. These links are an audit trail. Retention can still delete the source rows. Decide how long operators need the evidence, and whether an external audit store must keep it. Distillation provides no account-erasure transaction and no 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`. The provider follows the resolved model unless you set one explicitly. In production, pin the model and provider if a change would affect cost or output shape. #### Running it on a schedule Run the commands from cron or a job runner. Empty passes do no model work. Dry-run with the same namespace and batch cap first, and go live after reviewing the counts. A non-zero exit means something needs reconciling, so don't retry blindly. Schedule against a single application-owned store, and keep jobs for the same namespace from overlapping. The write/link sequence and the reflection watermarks help with retries, but they aren't a distributed lease. Alert on failed batches and on “more not examined” output, so a capped backlog doesn't grow unnoticed. #### Distillation configuration These are the defaults: `b4.config.ts`: ```ts export default { memory: { distill: { model: "gpt-5-mini", maxBatches: 5, retry: { maxAttempts: 3 }, 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 ``` `retry.maxAttempts` is the attempts per model call, counting the first; `1` sends each call once. `maxAttempts - 1` becomes the model's `maxRetries`, which LangChain applies to server errors, network errors and rate limits with a `Retry-After` of 60 seconds or less (see [Retry](https://b4.run/docs/retry#other-models-b4run-builds)). A pass runs outside any route, so no route's `agent({ retry })` applies. `retry` takes only `maxAttempts`: a `baseDelay`, a misspelled or misplaced key, or a `maxAttempts` that isn't a whole number of at least 1 fails the command with `B4_E1009` before any model is built, and `b4 check` reports the same. [Configuration](https://b4.run/docs/configuration#memory) lists every shape it rejects. `consolidate.ttlMs` sets an expiry on summaries. Leave it unset to keep them. If a summary should outlive its evidence, make sure its TTL is longer than the source review window. #### 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 aren't selected again. 5. Reflection respects the per-namespace watermark and the `maxRecords` bound. 6. A zero-insight response writes only the superseded sentinel. 7. Reflection output defaults to candidates and stays keyword-reachable. 8. Batch caps report unexamined work. Keep model-output parsing tests separate from store lifecycle tests, so a failure tells you whether selection, generation, or persistence broke. #### Related - [Episodes](https://b4.run/docs/memory/episodes): capture the run history being compacted - [Long-term Memory](https://b4.run/docs/memory/long-term): review reflection candidates - [Recall and Retrieval](https://b4.run/docs/memory/retrieval): find distilled records by keyword - [Browse and Manage Memory](https://b4.run/docs/memory/browse): inspect provenance and status --- ### Browse and Manage Memory Source: https://b4.run/docs/memory/browse `MemoryStore.browse` is for administration. It filters, sorts, counts, and paginates records across namespaces and statuses, with no semantic ranking. `MemoryStore.search` is for agent recall. It searches one namespace and can rank by keyword or vectors. B4.run has no public HTTP endpoint for browsing memory. The Inspector is a local development tool, and the candidate management endpoints only cover review. So a production admin UI is your own authenticated, authorized route. Let's build one. #### Derive the tenant prefix on the server Authenticate before the request reaches B4.run's models and tools. Derive the tenant namespace prefix from the verified principal, and AND any narrower query with that server-owned prefix. Never trust a prefix, tenant, or user identifier from request JSON. Here is a handler that does this: `src/admin/memory.ts`: ```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 holds only shared query code (types, validation, ordering, the cursor codec, and range helpers), and it doesn't import `node:sqlite`. Keep the store itself on the server. Never 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 needed, and bind every request to the server-derived tenant prefix. The handler assumes the collections use a tenant-first scope with at least one more dimension (for example, `scope: ["tenant", "user"]`), so `tenant=|` is a delimiter-bounded prefix. If a route includes `workspace` or `route`, B4.run's canonical dimension order puts those first. In that case, define and test a different server-owned prefix. Searching for `tenant=` in the middle of a namespace string isn't safe. #### 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`, as one value or a set | | `kind` | `semantic`, `episodic`, `procedural`, or `reflection`, as 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 ANDed together, with 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 `É` can match under Postgres case-folding rules but not under stock SQLite. Namespace filters stay case-sensitive and byte-exact. Call `validateBrowseQuery` where untrusted input enters, even though the in-repo stores also validate. The [memory API reference](https://b4.run/docs/api/memory#trust-boundaries) lists the limits, constants, and error codes. A `BrowseQueryError` is a client error, so don't retry it as if the store were down. 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 filter with no choices points to a bug in the UI or query builder. Map `BrowseQueryError` to HTTP 400 as the handler does. Unexpected store or infrastructure failures stay 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`. The direction is `asc` or `desc`. Store code maps 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`. Don't add arbitrary column names by casting request JSON. If a UI needs a new sort, add it to the shared type, the validator, the resolution table, both stores, and the conformance tests together. #### Pagination and a fixed clock Use `cursor` for a stable forward walk. A continuation is opaque. It carries a query fingerprint plus the last ordered key. It proves nothing about authorization, and clients shouldn't parse it. If you change the filters, sort order, namespace restrictions, or `now`, the store rejects it. Pick one fixed `now` before the first page and reuse it for the whole walk. Calling `new Date().toISOString()` on every request changes the query fingerprint and can change which rows count as expired: ```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 return a continuation whenever a page is full. They don't fetch an extra row to check for more. So if the result count is an exact multiple of the limit, the last full page has a continuation and the walk ends with one empty page. That's a normal finish. Offset still works for bounded jumps a person makes, but inserts above the seam can shift records between requests. A cursor uses keyset continuation, so inserts above the seam leave the next window alone. #### Count and snapshot semantics `BrowsePage.total` counts the whole matching set, not the current window or the rows left after a cursor. Both in-repo stores read the page records and the total in the same transaction snapshot, so concurrent writes can't produce a records/total pair that never existed together. The snapshot covers one `browse` call. A multi-request export gets no shared snapshot. If every page must reflect one point in time, use database snapshots or locking, or an export job in your application. #### Backend parity Conformance tests keep SQLite and pgvector in step on namespace, filter, order, and cursor behavior. The parity has these 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 limits, the same query pages safely on either backend. Outside them, expect serialized rows and order to differ. #### Mutations and candidate review Browse only reads records. Build separate mutation handlers that check the record's tenant before calling `update`, `delete`, approval, or rejection. Candidate review already exists through `GET /memory/candidates`, `POST /memory/candidates/:id/approve`, and `POST /memory/candidates/:id/reject`. In a deployed service those routes still need outer authentication and tenant authorization. Approval can reconcile semantic identities and delete a duplicate candidate. After a mutation, refresh the record. The row you displayed may have changed. #### Test the admin surface Run the shared memory-store conformance tests against your store, then add application tests that prove: 1. An unauthenticated or unauthorized caller receives no records. 2. Request JSON can't override the server-derived tenant namespace prefix. 3. Namespace, enum, 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 can't skew `records` from `total` inside one response. 7. Cross-tenant mutation IDs are rejected after lookup. When this page and the code disagree, the exported types, the browse modules, the SQLite and pgvector stores, and their conformance suites win. #### Related - [Recall and Retrieval](https://b4.run/docs/memory/retrieval): agent-facing ranked search - [Long-term Memory](https://b4.run/docs/memory/long-term): candidate approval and lifecycle - [Security Architecture](https://b4.run/docs/security-architecture): outer authentication and tenant authorization - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): candidate management routes --- ### Agent Protocol Source: https://b4.run/docs/dev-server/agent-protocol Agent Protocol is B4.run's HTTP API for threads, runs, streaming, resuming after a human approves something, cancelling, and reviewing memory candidates. Both `b4 dev` and a Node runtime started with `b4 start` serve it. #### Local quickstart Start a server on a known local port: ```bash b4 dev --port 3001 ``` Requests name a route with its generated `#` key, such as `/navlog#agent`. > **Warning: Authenticate the entire production service** > > 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. So before you expose > the service beyond your machine, put outer authentication and network > restrictions around all of it. Leave out only the health probe you mean to expose. > > An app with a [thread-access policy](https://b4.run/docs/thread-access) also checks every > thread endpoint in the table below against the thread, and a request must pass > both gates. Neither gate covers the memory-candidate and health routes. #### Agent Protocol endpoints | Method and path | Request | Success | |---|---|---| | `POST /threads` | Optional `{ "metadata": { ... }, "workspace": { "sourceDigest", "environmentLinks"?, "baseline"? } }` body. `workspace` only with `sandbox.stagedWorkspaces`, which also bounds the body to 1 MiB | `200` thread object. `400` `workspace_not_accepted` when the app does not accept a workspace, `422` `workspace_source_not_held` (not uploaded) or `workspace_invalid`, `409` when the source was reclaimed meanwhile, `413` over 1 MiB, `429` `workspace_create_in_flight` | | `GET /threads/:thread_id` | None | `200` thread object, or `404` when absent | | `DELETE /threads/:thread_id` | None | `204`. Deletes the thread metadata, supported checkpoints, and sandbox, in that order | | `GET /threads/:thread_id/state` | None | `200 { config, created_at, metadata, next, parent_config, values }` (`created_at` is the checkpoint's time), or `404` without a checkpoint | | `GET /threads/:thread_id/pending_interrupts` | None | `200 { "interrupts": [...] }`. `404` `thread_not_found`. `409` `thread_route_unknown` when no route is recorded | | `GET /threads/:thread_id/turns` | None | `200 { threadId, status, turns, warnings, truncated }`: the thread's activity turns rebuilt from its checkpoints (`turns` is `@b4run/ag-ui/view`'s `TurnsView`). `404` `thread_not_found`. `409` `thread_route_unknown` when no route is recorded | | `GET /threads/:thread_id/events` | None | `200 { threadId, status, events, warnings, truncated }`: the thread replayed as the AG-UI events its live runs would have carried (`@b4run/ag-ui/view`'s `eventsFromState`). `404` `thread_not_found`. `409` `thread_route_unknown` when no route is recorded | | `POST /threads/:thread_id/workspace/inspect` | Optional `{ "root", "excludeRootDirectories", "expectedRootSymlinks", "ignorePrefixes", "maxEntries", "maxFileBytes", "maxTotalBytes" }` | `200 { threadId, sourceDigest, intentDigest, root?, inspection }`. Only with `sandbox.workspaceRead: "http"` and a thread-access policy. `409` `run_in_flight` or `workspace_changed`, `422` `workspace_root_missing`, `413` over 64 KiB, `504` `workspace_read_timeout` | | `PUT /workspace/sources/:digest` | A `SourceBundle` whose `digest` is the path's | `201 { digest, status: "created" }` or `200 { digest, status: "held" }`. Only with `sandbox.stagedWorkspaces` and a thread-access policy. `400` `digest_mismatch`, `422` `workspace_source_invalid`, `413` over `maxUploadBytes`, `408` `upload_timeout` past `uploadTimeoutMs`, `422` `workspace_uploader_invalid` when the policy's stamp cannot be kept, `429` `upload_in_flight`, `507` `staged_quota_exceeded` | | `POST /threads/:thread_id/runs/wait` | `{ "route": "#", "input": { ... } }` | `200` final state JSON, or `null` when the route returned nothing. `500` naming the route when its output isn't JSON-serializable | | `POST /threads/:thread_id/runs/stream` | Same run body | `200 text/event-stream` | | `GET /threads/:thread_id/runs/stream` | None | `200 text/event-stream` (reattach). `404` `thread_not_found`. `409` `thread_route_unknown` when no route is recorded | | `POST /threads/:thread_id/resume` | `{ "resume": [...], "route": "#" }` with no other keys | `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` | None | `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 needs `route`. `input` is optional and defaults to `{}`. A route id without `#` isn't a registered assistant id, so it returns `404`. B4.run's dev, Node, and Hono HTTP runtimes all use this `{ route, input }` body. LangSmith uses its own, keyed by `assistant_id`. #### Thread lifecycle with curl Let's create a thread, run a route and wait for it, then read the latest checkpoint. We'll use `jq` to pull out the 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": "/navlog#agent", "input": { "messages": [{ "role": "user", "content": "Explain checkpoints briefly." }] } }' curl -sS "$BASE_URL/threads/$THREAD_ID/state" ``` A run creates the thread if it doesn't exist yet. Create it yourself when you need metadata or want to set it up before running anything. #### Streaming over SSE `runs/stream` and `resume` return Server-Sent Events. The `event:` line is the chunk type, and the `data:` line is the chunk payload as plain JSON: ```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 this SSE comment every 15 seconds by default. SSE clients ignore it, and proxies see the connection is still alive: ```text : ping ``` #### Interrupt and resume When a run pauses for a permission, it sends an `interrupt` event with the `interruptId` and the permission details: ```text event: interrupt data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","detail":{"command":"ls","suggestedPattern":"ls"}} ``` Answer 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": "/navlog#agent" }' ``` The rules for this body: - It takes `resume` and `route`, and nothing else. - A resolved entry takes `interruptId`, `status`, and a `payload` of `"once"`, `"always"`, or `"deny"`. - A cancelled entry takes only `interruptId` and `status`, and counts as a denial. - The array lists every pending interrupt id once. A stale, partial, duplicate, or extra set returns `409`. - Interrupts from nested subagents are answered through the root thread. - The old `{ interrupt_id, decision }` form returns `400`. Only one Agent Protocol or AG-UI resume can answer a thread's pending prompts 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`. Anything else that tries to start work while the thread is busy gets `run_in_flight`. `route` is required in the body, but it usually doesn't pick the route. B4.run looks in the in-process thread-route map first, then persisted thread metadata. The body route is the last fallback. So changing it does not redirect a parked thread while either recorded route exists. ##### Recovering prompts without a live stream After a page reload, a client has no stream to read the `interrupt` event from. `GET /threads/:thread_id/pending_interrupts` returns the prompts still waiting on a thread, so you can show the permission UI again: ```bash curl -sS "$BASE_URL/threads/$THREAD_ID/pending_interrupts" # {"interrupts":[{"interruptId":"perm-abc123","resumeKey":"...","value":{...}}]} ``` `value` is the interrupt payload as the checkpoint stores it. For a permission prompt, that's `{ interruptId, type, kind, detail }`, matching the `interrupt` event, except that a subagent's prompt carries a `callId` on the wire and not in the stored copy. `resumeKey` is the checkpoint write's key, or `null`. Clients refer to prompts by `interruptId`. A thread with nothing waiting returns `200` with an empty array. Responses carry `cache-control: no-store`, because the checkpoint can change at any moment. Answer the prompts with the same `POST /threads/:thread_id/resume` body as above, `{ resume, route }`. `route` is required even though the server prefers the route it recorded. If a reloaded client has lost the route, it can read `metadata.route` from `GET /threads/:thread_id`. That's the route key recorded at the start of every run on the thread. This endpoint checks access against the route that *parked* the interrupts, which the caller can't change. B4.run records it as `metadata.parked_route` when a turn parks and clears it once the last prompt is answered. Why not the last-run route? A caller allowed to run a cheaper route could start a run, move the thread's route, and read a prompt that route never raised. With no parked route, the check falls back to the in-process map, then `metadata.route`. A thread with none of those (usually one that was created but never run) returns `409` with `error.details.code` set to `thread_route_unknown`. So does a thread whose recorded route is no longer registered, without naming that route. An unknown thread returns `404` with `thread_not_found`. ##### Restoring a thread's activity `GET /threads/:thread_id/turns` returns the same turns a live AG-UI client builds with `reduceTurns`: tool steps with their labels and timing, the plan, reasoning, nested subagent turns, and the approval a parked turn is waiting on. The runtime rebuilds it from the checkpoint chain (every tool call persists `additional_kwargs.b4_step`, a `task` call persists `b4_subagent`, and a run's end is stamped as `b4:turn` on the head checkpoint), so there is no separate event store. A call that a permission or argument constraint denied restores as a `denied` step, never a failure. `warnings` names stamps the runtime could not read; `truncated` is true when a very long thread hit the decoding cap and only the newest turns are returned. Threads written before these stamps existed do not restore. The gate is the one `pending_interrupts` enforces: a denied read is the same `404` as a miss. Responses carry `cache-control: no-store`. `GET /threads/:thread_id/events` serves the same restore as AG-UI events instead of a view: each turn's `RUN_STARTED` carries its user message, and a parked turn ends with `RUN_FINISHED` carrying the interrupt. A chat client that restores by replaying events, such as CopilotKit through `createB4AgentRunner`, reads this one. It shares `/turns`' gate, cap and warnings. #### One run at a time per thread B4.run allows one active run per thread. An ordinary run-slot collision (a second `runs/wait` or `runs/stream`, or a resume while another run is going) returns `409` with `error.details.code` set to `run_in_flight`. A second resume at the same time returns `resume_in_progress`, because of the resume claim above. The run registry lives in memory and is process-local, so this only holds within one process. To cancel the active run: ```bash curl -sS -X POST "$BASE_URL/threads/$THREAD_ID/cancel" # {"status":"interrupted","thread_id":"..."} ``` Cancel 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 nothing is running. Cancelling keeps the checkpointed state, with no rollback. How a cancelled run reports it depends on the endpoint. A cancelled SSE run or resume ends the stream with: ```text event: done data: {"output":{"cancelled":true}} ``` A cancelled `runs/wait` hasn't sent a response yet, so it returns `409` with `error.details.code` set to `run_cancelled`. A route failure, on the other hand, 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 also for a turn waiting on a person. Use `pending_interrupts` to tell them apart. A non-empty `interrupts` array means the thread is waiting on a person. An empty one means the run was cancelled or the prompts were already answered. The streaming endpoints `runs/stream` and `resume` set the parked status. `runs/wait` returns its thread to `"idle"` even when its turn parks, so after a `runs/wait`, check `pending_interrupts` rather than the thread status. #### Client disconnect When a `runs/stream`, `runs/wait`, or `resume` client disconnects, the run keeps going. To stop it, call `POST /threads/:thread_id/cancel`. Shutting down the server also stops active work. A client that reconnects rejoins the run with `GET /threads/:thread_id/runs/stream` (below), so there's no need to poll `GET /threads/:thread_id/state`. After a reload, `GET /threads/:thread_id/turns` rebuilds the activity view. Run admission and cancel are process-local. So a service with several replicas must send every request for a thread to the same process, or coordinate runs and cancels across processes itself. Shared Postgres stores keep the data durable, but they don't do that coordination. #### Reattaching to a running turn `GET /threads/:thread_id/runs/stream` is a read-only copy of the `POST` stream. A client that dropped off (after a reload, a network blip or a sleeping laptop) rejoins with no request body and cancels nothing, so a stock `EventSource` can read it. It needs thread-access `read`, plus middleware approval for every recorded route whose content it shows: the live run's route, its anchor, and the parked and last-run routes. That set is fixed before middleware runs. Which route owns a checkpoint comes from metadata the runtime writes for that checkpoint ID (including verified routes from earlier turns), never from client-written thread metadata. A checkpoint whose origin B4.run can't verify, including one from an older version, fails closed with `409 thread_route_unknown`. Running another turn won't fix it, so start a fresh thread. B4.run never deletes checkpoints or thread history on its own. Reattaching gives you the current **state**. There's no event log, so there are no cursors, no `Last-Event-ID`, and no retention window. Every reconnect gets a fresh snapshot, so a client recovers from any failure by reconnecting. The stream always opens with one `event: state` frame (a B4.run addition to the Agent Protocol wire format). It carries `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":[]} ``` - **A turn is running (`live: true`):** `values` is the checkpoint at `anchor` (the moment the run started), and `turn` holds the turn's merged frames so far. Then the live frames follow: the same `chunk`, `tool_call`, `tool_result`, `interrupt` and capability frames the `POST` stream sends, ending with the turn's own `done`. `interrupts` is always `[]` here, since a running turn isn't parked. During a `resume` turn, `resume` is `true`, and the client must leave `input` out of the transcript. If the in-memory buffer overflowed, `turn` is `null` and `turn_truncated: true` is set. Reconnect for a fresh snapshot. - **Nothing is running (`live: false`):** `values` is the latest checkpoint, `interrupts` holds the parked prompts with their `value`, and the stream sends a `retry:` hint and `event: done` `{"output":null}` before closing. This path reads from the checkpoint, so it works across restarts, replicas, and serverless. The live frames are best-effort, and only the process running the turn has them. Clients **must** treat `done` and `detached` as the end of the stream. Cancelling the response body or aborting the request disconnects the viewer and leaves the run going. A viewer that falls behind until its queue fills is detached with `{"reason":"overflow"}`. Heartbeats wait for the client to catch up. Two fixed limits protect the in-memory buffer: - Each thread's turn buffer holds up to 2 MiB. On overflow, B4.run drops the whole buffer, and the snapshot falls back to values plus the live frames. - Each thread serves up to 16 viewers at once. The 17th gets `event: detached` `{"reason":"capacity"}` and closes. Neither limit affects the durable snapshot or cancellation. #### Review memory candidates `GET /memory/candidates` lists candidates from every memory namespace. Approving one checks it against existing records with the same identity and reports an `action` of `activated`, `superseded`, or `deduped`. It returns `404` for a missing record and `409` when the record isn't a candidate. Rejecting a candidate deletes it. The list spans namespaces, and approve and reject are destructive. All three routes bypass B4.run execution middleware. Add outer authentication, tenant authorization, and audit logging before you expose them beyond your own machine. #### Production topology The Node runtime serves the same API. For production, you'll also set up durable stores, outer authentication, network policy, health checks, and coordination between replicas. See [Production Topology](https://b4.run/docs/production-topology), [Persistence and Tenancy](https://b4.run/docs/persistence), and [Security Architecture](https://b4.run/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`, and turns B4.run chunks into AG-UI events. It handles disconnects the other way around: the run stops when its viewer disconnects, and there's no event replay. See [AG-UI and Web Clients](https://b4.run/docs/ag-ui) for that endpoint and lifecycle. #### Related - [Dev Server](https://b4.run/docs/dev-server): start, restart, and inspect the local runtime - [Middleware](https://b4.run/docs/middleware): gate route execution and pass request context to tools - [Stream Output](https://b4.run/docs/recipes/stream-output): consume Agent Protocol SSE in an application - [Permissions](https://b4.run/docs/permissions): configure the decisions carried by resume entries --- ### AG-UI and Web Clients Source: https://b4.run/docs/ag-ui B4.run serves [AG-UI](https://github.com/ag-ui-protocol/ag-ui) for browser clients alongside Agent Protocol. `@b4run/ag-ui` is the translation library. It maps B4.run stream chunks to AG-UI events, and maps `RunAgentInput` back to a B4.run-shaped run input. It has no server, transport, or agent runtime of its own. `b4 dev` and the production runtime started by `b4 start` use that adapter to serve an SSE endpoint. This page covers requests, resume, threading, and lifecycle. See [Middleware](https://b4.run/docs/middleware) for execution gating and [Security Architecture](https://b4.run/docs/security-architecture) for the service routes that need 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 the thread if it's new, invokes the same route runtime Agent Protocol uses, and streams translated events back as SSE, or as the HTTP+protobuf binding when `accept` admits `application/vnd.ag-ui.event+proto` (see [Transport](#transport)). B4.run speaks AG-UI protocol **1.0**; `RUN_STARTED` declares `protocolVersion: "1.0"`. A request declaring another `1.x`, or no parseable version, is served; a different major is refused (see [Envelope validation](#envelope-validation)). `@b4run/ag-ui` takes `@ag-ui/client` `>=1.0.1 <2.0.0` as an optional peer; the runtime validators moved to `@ag-ui/core/schemas`. `GET` on the same URL reports what the route honors. See [Capabilities](#capabilities). #### Transport `accept` selects the AG-UI HTTP binding, by AG-UI's own rule: `text/event-stream` unless the header admits `application/vnd.ag-ui.event+proto` with a positive quality — named, or through a wildcard range such as `*/*` — in which case the response is `content-type: application/vnd.ag-ui.event+proto` and each event is a 4-byte unsigned big-endian length followed by exactly that many bytes of one protobuf-encoded event, with no separator between frames. The rule asks only whether protobuf is admitted, never how it ranks against SSE: `text/event-stream, */*;q=0.1` selects protobuf too. A client that cannot read protobuf must name `text/event-stream`; `@ag-ui/client` and CopilotKit do. A bare `curl` sends `*/*` and receives protobuf: pass `-H 'accept: text/event-stream'` to read the stream by eye. Both bindings carry the same events. The Agent Protocol attach stream (`GET /threads/:thread_id/runs/stream`, which `b4 threads tail` reads) is SSE whichever binding the primary client chose. WebSocket is not served. #### Envelope validation The runtime validates the run envelope itself, so no app has to re-check B4.run's own wire format. Envelope refusals — the `422`s and the foreign-major `400` — carry [`B4_E5401`](https://b4.run/docs/errors) and are told apart by `error.details.code`; a body the schema itself rejects is a plain `400`. Unknown top-level keys are tolerated and never echoed; they do not become route params. | Status | `details.code` | When | |---|---|---| | `422` | `invalid_envelope` | the body is not a JSON object | | `422` | `invalid_thread_id` | `threadId` is missing, not a string, blank, or longer than 256 characters | | `422` | `invalid_run_id` | `runId` is missing, not a string, blank, or longer than 256 characters | | `400` | `unsupported_protocol_version` | the request declared an AG-UI protocol major this runtime does not speak | | `422` | `invalid_state` | `state` is present and is not a JSON object | | `422` | `client_tools_not_allowed` | `tools` is not an array, or is non-empty on a route that did not opt in | | `422` | `forwarded_props_not_allowed` | `forwardedProps` is not an object, or is non-empty on a route that did not opt in | ##### Client-supplied `tools` and `forwardedProps` are closed by default These two fields are not the app's inputs — they are the caller's attempt to add to what the *route* decided. A client that sends `tools: [...]` on a route whose tool set the server chose is asking for authority it was not given, and `forwardedProps` reaches route code the same way. B4.run rejects them rather than ignoring them. Silently dropping a field is indistinguishable, from outside, from honoring it: a client could not tell whether it had just widened the run. A route that genuinely wants them says so in `b4.config.ts`: ```ts server: { agui: { clientTools: ["/chat"], clientForwardedProps: ["/chat"] } } ``` See [`server.agui`](https://b4.run/docs/configuration#serveragui) for the full key. An empty `tools: []` or `forwardedProps: {}` — what an AG-UI client sends when it has nothing to add — is always accepted, so an ordinary client is unaffected. Opting in to `clientTools` makes the model able to call the client's tools. See [Client-provided tools](#client-provided-tools) for the round trip, the limits, and what the opt-in does and does not protect against. > **Warning: Behavior change** > > Before this, a non-empty `tools` or `forwardedProps` was accepted and passed > through to the route as `raw` — and, for `tools`, never acted on: the adapter > did not interpret client tools then. An app that read either from `raw`, and > any client that sends frontend tools (a CopilotKit frontend action, for > instance), now gets a `422` where it used to get a silently narrower run, > unless the route is opted in to `clientTools`. Name the route in > `server.agui` to accept them. > > In CopilotKit, `useFrontendTool` adds its tool to `tools` on every run, and > generated suggestions run with `tools: [copilotkitSuggest]` and > `forwardedProps: { toolChoice }`. Static suggestion lists > (`useConfigureSuggestions` with `{ title, message }` entries) and > render-only hooks (`useRenderTool`) send neither. > > On a route opted in to `clientTools`, a generated-suggestion run's > `copilotkitSuggest` is a client tool like any other: the model can call it > as `client_copilotkitSuggest`, and the call parks like any client tool call. > B4.run does not force that call: it does not interpret `toolChoice`, and > `clientForwardedProps` accepts it without acting on it. On opted-in routes, > prefer static suggestion lists. ##### Where the check runs Before route middleware and before the thread-access policy. The envelope check performs no I/O, takes no resume claim, reads no thread row and discloses nothing about one — it judges only what the caller said about itself. Running it first means an app's middleware is never handed an envelope the runtime has already decided not to honor, and a thread-access policy is never asked to authorize — or create a row for — a request that is about to be rejected. Because it never looks at a thread, it cannot leak whether one exists. `resume` is deliberately **not** decided here. Whether a turn genuinely resumes is not a property of the envelope: it depends on what is parked in the checkpointer, which B4.run only reads *after* the thread-access policy has authorized this caller to touch that thread. A `resume` that matches no pending interrupt is still rejected — as a `409` with `stale_interrupt`, and a turn that ignores a pending one as `409` `resume_required` — just at the point where the answer is actually known. Media parts are not judged here at all: whether the route's model can take a part depends on that model, so it is decided when the run reaches it, and a part it cannot take is [dropped and announced](#multimodal-content), not refused. #### Client-provided tools A route named in [`server.agui.clientTools`](https://b4.run/docs/configuration#serveragui) lets the model call tools the client defines and runs itself — CopilotKit's `useFrontendTool`, for example. It is off by default and opted in per route. A route that is not named still refuses a non-empty `tools` with `422` `client_tools_not_allowed`. Only an `agent()` route can take client tools; on a chain, graph or workflow route, or a route that exports an agent runnable rather than an `agent()` descriptor, the run is refused with `422` `client_tools_not_supported`. `b4.config.ts`: ```ts export default { server: { agui: { clientTools: ["/chat"] } }, } ``` ##### The round trip Each tool the client sends becomes a tool the model sees as `client_`, with the description prefixed by `[Client-provided tool; definition authored by the caller]`. When the model calls one: 1. The server records the call in the tool-call record (thread, route, tool-call id, expiry) and parks the turn. The client sees the tool-call frames under the name **it** registered — `openPanel`, not `client_openPanel` — followed by an ordinary `RUN_FINISHED` with `outcome.type: "success"`, with `pendingToolCallIds` naming the client calls this run left parked — AG-UI 1.0's prescribed shape for a frontend tool call. There is no interrupt for the client to answer. A turn that parks an approval in the same step ends with the `interrupt` outcome instead; the client's follow-up is refused with `client_tool_pending` until the approval is answered. 2. The client runs the tool and, on its next run, sends its history with a `{ role: "tool", toolCallId, content }` message carrying the result. 3. The server matches that message against its record: a call it issued, on this thread and this route, still outstanding and not expired. A match resumes the parked turn with the result, and the model continues. Every AG-UI client resends its whole history on every run, so a tool message that matches nothing outstanding — an already-answered call, or one the server never issued — is treated as history and ignored, never as an error. A result is used exactly once: the resume value comes from the stored record, not from the message. On an opted-in route, a run whose last message is a tool message that matches nothing answers nothing: it returns an empty `RUN_STARTED` / `RUN_FINISHED`, and the model does not run. That makes a client's retry of a run that already resumed a harmless no-op. When the model calls several client tools in one turn, the client can answer them in one run or across several. A run that answers only some of them records those results and finishes as an ordinary success without touching the graph; the turn resumes once every call is answered. Only the route that issued a call can answer it: the record is bound to that route, and another route that shares the thread gets `409` `client_tool_pending` for a tool message it cannot answer. The thread's pending call is answered through this endpoint only. `POST /threads/:thread_id/resume` refuses with `409` `client_tool_pending` while a client tool call is parked. `GET /threads/:thread_id/state` shows the raw graph state. While a call is parked, `values` holds the model's tool call under its `client_`-prefixed name, and `next` lists the pending `__interrupt__` write. A permission prompt and a client tool call can park in the same model turn. Both are then answered in **one** AG-UI run: the tool message plus the `resume` entries for the prompt. A run that carries only the tool message is refused with `409` `resume_required`; the result it carried stays stored, so the next run only has to add the `resume` entries. ##### Abandoned calls A client that never answers must not leave the thread stuck, and because the park is invisible it cannot know it is holding one up. The call is abandoned when: - the client sends a new user message while the call is unanswered, or - the call has expired. The default lifetime is 10 minutes; set `server.agui.clientToolTtlMs` to change it. Expiry is checked when the thread is next used; no timer abandons a call. Abandoning closes each parked call with a tool result — the one the client already sent, if it did, otherwise `The client did not return a result for this tool call.` — and then runs the new user message as an ordinary turn. With no new message, the model replies to the closed calls. A result stored before the call expired is used; one that arrives after it expired is discarded. The closing tool results are written straight into the thread's checkpoint. No tool-result frame is streamed for them, and the route's result offloading and stream transformers do not see them. Any route on the thread can abandon a parked call with a new user message, including a route that did not issue it or does not take client tools, so the thread is never a dead end. A permission prompt pending on the same thread must be answered first. On the route that issued the call, the abandon is refused with `409` `resume_required`, as any new turn over a pending prompt is; on any other route, with `409` `client_tool_pending`. Abandon a parked call over AG-UI. While one is parked, the Agent Protocol run endpoints (`POST /threads/:thread_id/runs/stream` and `/runs/wait`) and `/resume` refuse with `409` `client_tool_pending`: a new run there would drop the park and leave the model's tool call with no result, which the provider rejects on every later turn. ###### Repairing a thread after a deployment change Closing a parked call needs the route that issued it and the store that recorded it. If a deployment removes or renames that route, or removes the configured `clientToolStore`, while a call is parked, requests on the thread fail closed: `409` `client_tool_close_failed` for the route, `503` `client_tool_store_unavailable` for the store. Restore the route or the store so the call can be abandoned, or start a new thread. ##### The tool-call record When some route is listed in `server.agui.clientTools`, or `server.agui.clientToolStore` is set, every tool call the model makes on an AG-UI run is recorded, not only client ones, on every route. A server tool's row is identity only: thread, the route that issued it, run, tool name, when it was issued and when it returned or threw. No result text is stored; the checkpoint holds the tool message. A tool whose permission gate parks the turn is issued but not settled until it actually runs. The `task` call that launches a subagent is recorded like any other tool, open while the subagent is parked; the subagent's own tool calls are recorded against the parent thread, each naming the child route that issued it and the `task` call that launched the subagent (`parentToolCallId`; `null` for a root call). A default `.b4/client-tool-calls.sqlite` left over after every route dropped its opt-in is still opened, so calls parked back then can be closed, but it records no server calls; boot logs one warning saying so. Delete the file to drop it. The record is what makes the rule above a guarantee rather than a habit: a `role: "tool"` message is used only when it names an open client call this server issued on this thread; one naming a server call, an already-closed call, or nothing the server knows is history. It also means the server will not run a tool call it cannot account for: a row is written before the tool body runs, and if that write fails, the tool call fails like any other tool error. With a store that is unavailable, every server tool call on every AG-UI route fails until it is back. Each server tool call costs two store writes on such an app. Server rows join the same sweep as client ones. When a turn settles, at most once an hour per store, the runtime deletes rows of every thread closed longer ago than [`server.agui.clientToolRetentionMs`](https://b4.run/docs/configuration#serveragui) (7 days by default, never less than `clientToolTtlMs`): a server row once it is that long past settling, a client row once it is that long past its answer, void or expiry. Open rows — an unsettled server call, an outstanding unexpired client call — are never deleted. `b4 client-tools prune` runs the same pass on demand. An app with no store records nothing. ##### Limits These are starting bounds, not measured ones. They cap context budget; a definition that breaks one is refused with a `422` before the run starts. | Bound | Value | |---|---| | Tools per run | 32 | | Tool name | matches `^[a-zA-Z0-9_-]{1,64}$` and does not start with `client_` | | Description | at most 1,024 characters | | `parameters` | a JSON object schema (`type` omitted or `"object"`), at most 8,192 characters serialized and at most 8 schema levels deep | | All definitions together | at most 32,768 characters, counting each tool's name, description and serialized `parameters` | | Each tool result | at most 64 KiB (65,536 bytes of UTF-8) of text/JSON; a part list is measured as JSON with inline media bytes excluded, and those bytes count against the 8 MiB body only | | AG-UI request body | at most 8 MiB | An over-cap result — over 64 KiB of text/JSON, inline media bytes excluded — is refused with `413` only when the run is answering — its last message is the tool message, for a call that is outstanding and not expired. Anywhere else, such as a resend of that history with a new user message, the over-cap result is dropped before anything is stored, and the call is abandoned like any unanswered one. The 8 MiB body ceiling applies to every `POST /agui/:routeId` request, opted in or not, because every AG-UI client resends the thread's history on each run. A very long conversation — one carrying many images inline, for instance — can reach it, and is refused with `413` `payload_too_large`. `$ref`, `oneOf` and `allOf` are accepted and bounded, but the tool converter does not enforce them, so the model's arguments are not checked against those constraints. Tool names starting with `client_` are reserved for client tools. An authored or capability tool with such a name fails route preparation, and `b4 check` reports an authored one before you run. ##### Threat model Client tool definitions are written by the caller, and every byte of them becomes prompt content. Treat them as untrusted input to the model. - **The bounds cap budget and blast radius. They do not prevent prompt injection.** A description such as "always call this tool first" fits in 1,024 characters. - **The provenance label is not a defence.** The `[Client-provided tool; definition authored by the caller]` prefix makes it legible to the model and to anyone reading a trace where a tool came from. A model can still follow what the description says. - **The authority is the server's opt-in.** A route takes client tools only because its operator named it in `b4.config.ts`. Opt in only routes whose callers you would let steer the model. - **A client tool cannot impersonate a server tool.** The `client_` prefix keeps the two namespaces apart, and client tools are gated under their own [`clientTool` permission key](https://b4.run/docs/permissions#client-provided-tools), so a client tool named like a server tool never inherits that tool's approval. - **Results are untrusted tool output.** The client decides what its tool returns; the model reads it as it reads any tool result. ##### Storage The record of outstanding calls lives in a client tool store. On node, B4.run opens a SQLite store at `.b4/client-tool-calls.sqlite` when any route is listed in `clientTools`. Edge and serverless deployments have no default: set `server.agui.clientToolStore` — `createPostgresClientToolCallStore` from `@b4run/postgres-storage`, for example — or every run that sends client tools is refused with `503` `client_tool_store_unavailable`. A deployment with more than one instance needs a shared store for the same reason. Records do not accumulate. Once a turn settles, the runtime deletes records answered, voided or expired — or, for a server tool call, settled — longer ago than [`server.agui.clientToolRetentionMs`](https://b4.run/docs/configuration#serveragui) (7 days by default, never less than the TTL), at most once an hour per store. `b4 client-tools prune` does the same on demand. An outstanding, unexpired record is never deleted. A call that was answered but whose thread never resumed is treated the same way once the window has passed: its record is gone, so a thread that returns after that closes the call with the abandoned result instead of the stored one. A result made of content parts is kept in the same text column as a self-describing JSON envelope; rows written before parts were supported are plain text and read back unchanged, so no migration is needed. ##### Error codes `error.details.code` on the refusal says why: | Status | `details.code` | When | |---|---|---| | `422` | `client_tools_not_allowed` | `tools` is non-empty on a route not named in `clientTools` | | `422` | `client_tools_not_supported` | the route is not an `agent()` route | | `422` | `invalid_client_tool` | a definition is not an object with a string name, or its description or `parameters` has the wrong shape | | `422` | `invalid_client_tool_name` | a name breaks the name pattern or starts with `client_` | | `422` | `duplicate_client_tool` | two definitions share a name | | `422` | `client_tool_too_large` | a description or `parameters` is over its limit | | `422` | `client_tool_too_deep` | `parameters` nests more than 8 schema levels | | `422` | `client_tool_budget_exceeded` | more than 32 tools, or more than 32,768 characters of definitions | | `413` | `client_tool_result_too_large` | a run whose last message is a tool message answers an outstanding, unexpired call with a result over 64 KiB of text/JSON (inline media bytes excluded); nothing is recorded | | `503` | `client_tool_store_unavailable` | no client tool store is configured | | `409` | `client_tool_pending` | a client tool call is parked and this request can neither answer nor abandon it | | `409` | `client_tool_close_failed` | the parked calls could not be closed, for example because the route that issued them cannot be resolved | | `409` | `resume_required` | a permission prompt must be answered before the parked calls can be abandoned | | `500` | `client_tool_close_incomplete` | closing the parked calls did not complete; the new turn did not run | The `422` refusals carry [`B4_E5401`](https://b4.run/docs/errors). #### Multimodal content A 1.0 message's `content` may be a list of parts: `text`, and `image`, `audio`, `video` or `document` parts whose `source` is inline bytes (`data`, base64 with a `mimeType`), a URL (`url`), or a handle the model provider issued (`file`). B4.run carries them to the route's model as LangChain content blocks: inline bytes as bytes, a URL as a URL — B4.run never fetches it; the provider does — and a provider handle as that provider's file id, when the route runs on the provider that minted it. A `document` is a PDF: a part whose `mimeType` is not `application/pdf` is not guessed at. Which parts a route's model takes is read from the model's LangChain profile, then narrowed by what each provider's LangChain converter can actually send. Images are carried inline and by URL where the profile allows; documents (`application/pdf` only) likewise, except that a PDF by URL never reaches an OpenAI model; audio and video only where the profile says so. A model with no profile — every `ollama` and `mistral` model, and an id the provider package does not know — takes images only (`ollama` inline only, since its converter cannot pass a URL through), plus the file handles below. Provider file handles are carried only on OpenAI (documents) and Anthropic (images and documents). Media in a tool result reaches the model only where both the profile and the provider allow it, which is never on OpenAI: B4.run uses its Chat Completions path, which sends a tool message as text. What the model cannot use is **dropped, never refused**, as the protocol requires: the run continues with the parts that remain, and the drop is announced for the developer — in the server log, one warning per user turn or tool result that lost parts, and on the stream as a `CUSTOM` event named `b4.content_parts_dropped`. Drops inside a subagent are logged only; the adapter does not map them to a `CUSTOM` event. Its `value` is `{ provider?, model?, toolCallId?, parts }`: `provider` and `model` name the route's model (omitted for a route that exports its own runnable), `toolCallId` is present when the parts came from a tool result, and each entry of `parts` is `{ index, type, source?, reason }`, where `index` is the part's position in the message and `reason` is one of: | `reason` | The part was dropped because | |---|---| | `modality_unsupported` | the model does not take this kind of media, or not as inline bytes | | `url_source_unsupported` | the model or its provider cannot take this kind of media by URL | | `file_source_unsupported` | the provider cannot take a file handle for this kind of media | | `foreign_file_provider` | the handle names a different provider than the route's model | | `document_not_pdf` | a `document` part's `mimeType` is not `application/pdf` | | `tool_result_media_unsupported` | the media came from a tool result, and the model or provider does not take media in a tool message | | `malformed_part` | the entry is not a valid content part (only reachable outside `/agui`, whose schema check refuses it with `400` first) | Inline parts count against the 8 MiB body ceiling; there is no per-part limit. The [capability document](#capabilities) says which parts a route's model accepts as inline bytes (`multimodal.input`), so a client can hide an attach control ahead of time; the server log warning for a drop ends by pointing at `GET /agui/` for the same reason. URL support varies by provider and is not in the document; a URL part the model cannot take is reported by the drop event and warning. Tools can return parts too: a tool's result may be an array of the same parts (or `{ result: parts, state }`), and it travels as `TOOL_CALL_RESULT.content`. The model sees the media only as described above; the UI sees every part regardless. A [client-provided tool](#client-provided-tools) may answer with parts as well: a `role: "tool"` message's parts are stored as sent and replayed to the model under the same tool-result rules, so media reach the model only where the profile and provider allow it — never on OpenAI's Chat Completions path — and the UI gets every part on `TOOL_CALL_RESULT`. The one exception is a call closed by the [abandon path](#abandoned-calls), such as a new user message sent instead of resuming: the stored result is replayed as its text only, and the server logs a warning once the call is closed. For a client that attaches images, renders media parts, and shows the dropped-part notices, see [Attach an image](https://b4.run/docs/recipes/flight-planner-web-ui#attach-an-image) in the flight-planner web UI recipe. `role: "reasoning"` and `role: "activity"` history is dropped on the way in: it is the client's stored artefact of an earlier turn, not something the assistant said. #### Capabilities `GET /agui/{routeId}` answers with the route's AG-UI [`AgentCapabilities`](https://docs.ag-ui.com). Each claim is computed by the same check that `POST` enforces it with, so what a route advertises and what it honors cannot drift apart: | Field | Comes from | |---|---| | `tools.clientProvided` | the route is named in `server.agui.clientTools`, is an `agent()` route, and a client tool store is configured — every check `POST` applies to a non-empty `tools` | | `tools.supported`, `tools.parallelCalls` | the route is an `agent()` route; its tool calls run in parallel | | `output.structuredOutput` | the route's provider can bind a `hashbrown.responseSchema`, the check behind `response_schema_not_supported` | | `humanInTheLoop.interrupts` | the route is an agent route, the only kind that can park | | `humanInTheLoop.approvals` | an `agent()` route under an `interactive` permissions mode, on a runtime that can answer the approval: `approvals.grants: "required"` with no grant store refuses every resume, so it reports `false`. It means the route *can* pause for approval; whether a turn does depends on its tools and the grants already recorded | | `humanInTheLoop.approveWithEdits` | always `false`: a resume answers `once`, `always` or `deny` | | `transport.streaming`, `transport.httpBinary` | always `true`: the handler serves SSE, and protobuf when `accept` asks (see [Transport](#transport)) | | `reasoning` | `{ supported: true, streaming: true, encrypted: false }` when the route's `reasoning` config makes reasoning text stream (an OpenAI reasoning `summary` or an Anthropic thinking budget), framed as `REASONING_*` events; redacted or encrypted material is dropped, never carried. Otherwise `{ supported: false }`: effort alone, no `reasoning`, a config the provider rejects, a chain, graph or workflow route, or a raw runnable. See [Reasoning](#reasoning) | | `multimodal.input.image`, `.pdf`, `.audio`, `.video` | the route model's LangChain profile with the provider's converter limits — the same judgment that keeps or drops each part at run time. `image` and `pdf` describe the inline `data` source; URL support varies by provider (for example Gemini, Ollama and OpenRouter drop image URLs; OpenAI drops PDF URLs), and a dropped URL part is reported in the run's [dropped-parts warning](#multimodal-content) | | `multimodal.input.file` | always `false`: AG-UI's flag for arbitrary uploads the four part types do not cover; a provider file handle is a source, not that | | `multimodal.output` | always `false`: AG-UI 1.0 defines no image or audio output carrier | | `state.snapshots`, `state.deltas` | always `false`: no state snapshot or state delta event is emitted | | `state.persistentState` | `true` for an `agent()` route, whose graph runs under the boot checkpointer; `false` for a chain, graph or workflow route, invoked once without one | A chain, graph or workflow route reports `false` for client tools, tool calls, structured output, interrupts, approvals and persistent state. AG-UI treats an omitted field as unknown, so a claim B4.run cannot settle is left out rather than guessed. An agent route that exports a raw runnable omits approvals, parallel calls and `state.persistentState`, because its own code decides them. `multimodal` is omitted for a raw runnable, a chain, graph or workflow route, and a route whose provider package is missing or cannot be read; the rest of the document stands, and a run on that route still reports the real error. A runtime that cannot load route modules at all (no node fallbacks and no static manifest) answers only `transport`, `reasoning`, `state.snapshots` and `state.deltas` for an agent route — the claims that hold for every route. A route whose module fails to load is an error, as it is for a run. `multiAgent` and `state.memory` are omitted until AG-UI sub-project 2 settles them; `transport.websocket`, `transport.resumable` and `transport.pushNotifications` are omitted because nothing in the runtime serves them. Route middleware runs first, as it does for a run, but sees `method: "GET"`, no body and no route params, so middleware that authorizes on the request body refuses it. An unknown route is `404`. The response is `Cache-Control: no-store`, because middleware decides per caller who may see it. The stock `HttpAgent` does not read this endpoint. `B4HttpAgent` from `@b4run/ag-ui/client` is an `HttpAgent` whose `getCapabilities()` sends `GET` to the URL it runs against, with the same headers. CopilotKit's runtime calls `getCapabilities()` on each registered agent when it answers `/info`: ```ts import { B4HttpAgent } from "@b4run/ag-ui/client" const agent = new B4HttpAgent({ url: `${b4Url}/agui/${encodeURIComponent("/chat#agent")}` }) await agent.getCapabilities() ``` `getCapabilities()` throws on any non-2xx answer, including a `404` from a B4.run release that predates the endpoint, and after 10 seconds without an answer, instead of reporting nothing declared. CopilotKit catches the error and reports the agent without capabilities. `@ag-ui/client` is an optional peer dependency of `@b4run/ag-ui`; install it alongside if you import this subpath. #### Consuming it from a web UI `examples/chat/web` is the reference client. Its CopilotKit runtime registers a `B4HttpAgent` pointed at B4.run's encoded route URL. The browser talks to the Next.js runtime route, and only the B4.run server holds model credentials: ```text browser -> CopilotKit runtime -> B4HttpAgent -> 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. The client shows what the agent did by wrapping a stock `` in the activity connector from `@b4run/ag-ui/react/copilotkit`: ```tsx import "@b4run/ag-ui/styles.css" import { B4Activity, useB4ChatSlots } from "@b4run/ag-ui/react/copilotkit" import { CopilotChat, CopilotKit } from "@copilotkit/react-core/v2" function Chat() { return } export default function Page() { return ( ) } ``` Each turn renders as one `TurnActivity` (the summary line and the step list, nested for subagents) in place of CopilotKit's generic tool rows, and a parked interrupt renders as an `ApprovalCard` in the chat. The `labels`, `hiddenTools` and `renderStep` props on `B4Activity` reword, hide or re-render steps per tool. Host UI outside the chat reads the same turns through `useB4ActivityContext`: the navlog example drives its map and sheet from it, and mounts `` with `useB4ChatSlots` inside `B4Activity`. `` takes the same `messageView` slots; the chat example uses it that way. A host with its own transcript skips the slots and uses `useB4Turns` with `TurnActivity` and `ApprovalCard` from `@b4run/ag-ui/react` directly. ##### Restoring a conversation after a reload CopilotKit's chat calls `connect` when it mounts. Its default in-memory runner only remembers runs this process served, so give the runtime a runner from `createB4AgentRunner` and the chat restores from B4's checkpoints instead: ```ts import { AsyncLocalStorage } from "node:async_hooks" import { B4HttpAgent } from "@b4run/ag-ui/client" import { createB4AgentRunner } from "@b4run/ag-ui/copilotkit-runtime" import { CopilotRuntime, createCopilotRuntimeHandler, InMemoryAgentRunner } from "@copilotkit/runtime/v2" const b4Url = process.env.B4_SERVER_URL ?? "http://127.0.0.1:3001" // The caller of the request being served. The agent and runner are built once // per process, so the identity travels by context, not by constructor. const caller = new AsyncLocalStorage<{ userId: string }>() // Every B4 request, run and restore alike, carries the current caller. const callerFetch: typeof fetch = (input, init) => { const headers = new Headers(init?.headers) const userId = caller.getStore()?.userId if (userId !== undefined) headers.set("x-user-id", userId) // the header your thread-access policy reads return fetch(input, { ...init, headers }) } const agent = new B4HttpAgent({ url: `${b4Url}/agui/${encodeURIComponent("/chat#agent")}`, fetch: callerFetch }) const runtime = new CopilotRuntime({ agents: { default: agent }, // CopilotKit's own runner class goes in: @b4run/ag-ui never imports @copilotkit/runtime. runner: createB4AgentRunner(InMemoryAgentRunner, { url: b4Url, fetch: callerFetch }), }) const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" }) async function serve(request: Request): Promise { const userId = await authenticate(request) // your session lookup return caller.run({ userId }, () => handler(request)) } // CopilotKit's handler serves `GET /info` as well as the POST routes. export const GET = serve export const POST = serve ``` The runner reads `GET /threads/:thread_id/events`, so the thread-access policy and route middleware that guard the run guard the restore too. While a run is live in this process, the restore drops that run's unfinished turn from the replay and continues with the live run's events; a resumed run continues from the parked run it answers. The read carries only the headers your `fetch` adds, never the browser's. That `fetch` must carry the current caller's identity, derived per request as above (the navlog example does the same with its visitor id), never a fixed service credential: a fixed credential lets any browser restore any thread whose id it knows. A read the thread gate refuses (404) restores as an empty chat, with no error. React and `@copilotkit/react-core` `>=1.76.0` are optional peer dependencies, so a server-only consumer of the root or `/sse` entry installs nothing extra; only the `/react/copilotkit` entry imports CopilotKit. The server-side `/copilotkit-runtime` entry, which only a CopilotKit runtime route imports, never imports `@copilotkit/runtime`: the route passes CopilotKit's `InMemoryAgentRunner` class to `createB4AgentRunner`, so the runner extends the copy the route resolves. Its `rxjs` is a regular dependency of the package. 1.76.0 is the first `@copilotkit/react-core` whose bundled AG-UI client speaks 1.0, the protocol B4.run serves; earlier releases resolve a pre-1.0 `@ag-ui/*` (0.0.59 on 1.70–1.75), and npm 7+ rejects the unmet peer with `ERESOLVE` where pnpm only warns. The `/react` subpath also exports the kit's building blocks (`Step`, `StepGroup`, `PlanStep`, `ReasoningStep`, `SubagentStep`, `Disclosure`, `StepIcon`) for a client that presents the activities its own way. The reducers live in `@b4run/ag-ui/view`, which has no React dependency: `reduceTurns` folds a whole thread into turns, steps and approvals, for any framework. For the full application wiring (a thread rail, suggestions, generic tool cards, and permissions), see the [flight-planner web UI recipe](https://b4.run/docs/recipes/flight-planner-web-ui) and its [`examples/navlog/web`](https://github.com/cacheplane/b4run/tree/main/examples/navlog/web) implementation. ##### An Angular client The same activity kit ships for Angular in the same package: standalone components at `@b4run/ag-ui/angular` that render the same DOM contract as the React kit, styled by the same sheet. Angular 22 or later is required; `@angular/core`, `@angular/common`, `@angular/platform-browser` and `@copilotkit/angular` are optional peer dependencies of `@b4run/ag-ui`, so install the ones your app uses: ```bash npm install @b4run/ag-ui ``` Load the stylesheet once, in `angular.json`'s `styles` or a global stylesheet: ```json "styles": ["@b4run/ag-ui/styles.css", "src/styles.css"] ``` With CopilotKit's Angular chat (`@copilotkit/angular` 0.5.3 or later), provide the activity store next to the chat and hand `` the kit's assistant message and approvals: ```ts import { Component, inject } from "@angular/core" import { CopilotChat } from "@copilotkit/angular" import { B4ActivityApprovalsComponent, B4ActivityAssistantMessageComponent, B4ActivityStore, provideB4Activity, } from "@b4run/ag-ui/angular/copilotkit" @Component({ selector: "app-chat", imports: [CopilotChat], providers: [provideB4Activity({ agentId: "default" })], template: ``, }) export class ChatComponent { readonly activity = inject(B4ActivityStore) // created with the chat, so it sees every event readonly assistant = B4ActivityAssistantMessageComponent readonly approvals = B4ActivityApprovalsComponent } ``` Each assistant message renders its text through CopilotKit's own assistant message, then the turn's activity once per turn, and each parked interrupt renders as an approval card that resolves or cancels it through CopilotKit. Without a chat framework, `@b4run/ag-ui/angular/events` folds the thread's AG-UI events (an RxJS `Observable`, or anything with the same `subscribe`) into turns: `provideB4Turns` provides the store, `` renders a turn's activity on its first assistant message in your own transcript, and `` renders the parked interrupts' approval cards for you to resume. ```ts import { Component, inject, input } from "@angular/core" import { ApprovalsComponent, type B4ApprovalDecision, MessageActivityComponent, provideB4Turns, } from "@b4run/ag-ui/angular/events" // ThreadEvents: your service that owns the thread's AG-UI event stream and sends resumes. @Component({ selector: "app-chat", imports: [MessageActivityComponent, ApprovalsComponent], providers: [provideB4Turns(() => ({ events$: inject(ThreadEvents).events$ }))], template: ` @for (message of messages(); track message.id) { } `, }) export class ChatComponent { readonly messages = input.required() readonly #thread = inject(ThreadEvents) readonly resume = ({ interruptId, decision }: B4ApprovalDecision) => this.#thread.resume(interruptId, decision) } ``` The Angular entries ship in Angular's partial compilation format, which your Angular CLI build links, so import them from an Angular application, not from Node. See [`@b4run/ag-ui` reference](https://b4.run/docs/api/ag-ui) for every export. #### 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 live behind subpaths. For example, an HTTP server can encode an event like this: ```ts import { agUiContentType, encodeAgUiEvent } from "@b4run/ag-ui/sse" response.writeHead(200, { "content-type": agUiContentType(request.headers.accept) }) response.write(encodeAgUiEvent(event, request.headers.accept)) ``` ##### Outbound events `toAguiEvents(chunks, context)` is a stateful async generator. It frames B4.run's implicit assistant text and keeps upstream tool-call ids so results can be correlated: | 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_result` for a tool that threw | `TOOL_CALL_RESULT` under the same `toolCallId`, with `content` set to the error text the model receives (a `b4.step` event with `status: "failed"` follows it; see [Step events](#step-events)) | | `step` (from a tool's `display`) | `CUSTOM { name: "b4.step", value: { toolCallId, status: "running" \| "completed" \| "denied", icon?, label?, sources? } }`, `running` as the call starts and `completed` as it returns (`denied`, with `icon` only, when a permission or argument constraint blocked it), both before the call's `TOOL_CALL_RESULT` (`failed` comes from the `tool_result` row above); a child's step is tagged `subagentRunId` | | `tool_call` and `tool_result` for a `writeTodos` call whose plan activity was emitted | none (the activity is the whole presentation) | | `plan_update` | replacement `ACTIVITY_SNAPSHOT` with activity type `b4.plan` — `b4:plan:${runId}` for the root agent, `b4:plan:${call_id}` tagged `subagentRunId` for a subagent's plan | | `subagent.start` | `SUBAGENT_STARTED { subagentRunId: call_id, name, description?, parentToolCallId: call_id, parentSubagentRunId? }` | | the child's `token`/`reasoning`/`message_end`/`tool_call`/`tool_call_args`/`tool_result`/`plan_update` chunks | the same events the root chunk produces, each tagged `subagentRunId: call_id` | | `subagent.end` with `final_message` / with `error` | `SUBAGENT_FINISHED { result, outcome: success }` / `SUBAGENT_ERROR { message }` | | a subagent still open at an interrupt, a cancel, or a stream that ended | `SUBAGENT_FINISHED { outcome: suspended, interruptIds? }` / `SUBAGENT_ERROR { code: "cancelled" }` / `SUBAGENT_ERROR { code: "unterminated" }`, before the terminal event | | `reasoning` (per model invocation, alongside its `token`s) | `REASONING_START` and `REASONING_MESSAGE_START { role: "reasoning" }` once, then `REASONING_MESSAGE_CONTENT` per delta; both closed at the invocation's `message_end` or the next tool or terminal boundary | | `usage` (one per model call, root or subagent) | none on its own; aggregated into `usage` on the terminal event | | `interrupt` | terminal `RUN_FINISHED` with `outcome.type: "interrupt"`; each interrupt's approval grant is at `metadata.grant` | | `done` | terminal `RUN_FINISHED` with `outcome.type: "success"`; when this run left client-provided tool calls parked, `outcome.pendingToolCallIds` names them; a null route result is omitted, never `null` | | `POST /threads/:id/cancel` or a server shutdown during the run | terminal `RUN_FINISHED` with `outcome.type: "cancelled"` | | upstream error | terminal `RUN_ERROR` | ##### Token usage Every terminal event — `RUN_FINISHED` with a `success`, `interrupt` or `cancelled` outcome, and `RUN_ERROR` — carries `usage: TokenUsage[]` when at least one model call in the run reported token counts, and omits the key otherwise (never `[]`). There is one entry per provider and model, so a coordinator on `gpt-5-mini` with a subagent on `gpt-5-nano` reports two entries; sum them for a run total. Counts follow the protocol's accounting: `inputTokens` and `outputTokens` are inclusive totals, and `cachedInputTokens`, `cacheWriteInputTokens` and `reasoningTokens` are parts of them. A count the provider did not return is absent, not zero. A cancelled or failed run reports what it accrued before the stop; a resumed run reports only its own calls. The same `usage` chunk reaches the Agent Protocol stream as `event: usage` with the provider's raw LangChain `usage_metadata`; it has no typed API there. ##### Reasoning When a route's [`reasoning`](https://b4.run/docs/reasoning-effort) asks the provider to show its thinking (OpenAI `summary`, Anthropic `budgetTokens`), each model invocation's reasoning is framed as one span and one `role: "reasoning"` message with their own ids, distinct from the invocation's assistant text. Reasoning and text of one invocation may interleave; both close when the invocation ends, and anything still open at a tool call, an interrupt, cancellation or an error is closed before the terminal event, as 1.0 requires. Redacted or encrypted reasoning is dropped, never carried, so `reasoning.encrypted` is `false`. `GET /agui/{routeId}` advertises `reasoning` from the same config the model is built with: `{ supported: true, streaming: true, encrypted: false }` when the route streams reasoning, and `{ supported: false }` otherwise. A route with only `effort` set reasons but streams nothing, and advertises `{ supported: false }`. ##### Activity snapshots Activities use standard AG-UI framing and complete replacement snapshots. The one activity B4.run emits is the plan: activity type `b4.plan`, content holding only the complete todo list, and a stable message id per owner — `b4:plan:${runId}` for the root agent and `b4:plan:${call_id}` for a subagent's own plan, which also carries that subagent's `subagentRunId`. Todo status is one of `pending`, `in_progress`, or `completed`. B4.run emits no seeded-plan activity at run start; the first snapshot follows a valid `plan_update`. Every event has `replace: true`, so later snapshots replace the same stable activity message. B4.run emits no activity deltas. Unknown capability chunks close any open text message and are then ignored. Activities are informational. Only the standard interrupt UI resolves or cancels permission requests. ##### Subagents A subagent is presented the way AG-UI 1.0 defines it, not as an activity. When the `task` tool starts a child, the adapter emits `SUBAGENT_STARTED` with `subagentRunId` set to the `task` call's own tool-call id (`parentToolCallId` names that call; a nested child also carries `parentSubagentRunId`), the child's declared `name` and `description`. Everything the child then does — its assistant text, reasoning, tool calls and results, its plan snapshot — goes on the wire as the ordinary AG-UI events for those things, each tagged with that `subagentRunId`, so a client can show the child's full transcript or fold it away. The child's `final_message` ends it as `SUBAGENT_FINISHED { result, outcome: { type: "success" } }`; a thrown child ends it as `SUBAGENT_ERROR { message }`. The `task` call itself is an ordinary tool call with its own frames and result. Every announced invocation closes before the run ends, as 1.0 requires. A permission interrupt raised inside a child suspends it: `SUBAGENT_FINISHED { outcome: { type: "suspended", interruptIds } }` names the interrupts it raised, the interrupt itself carries the child's `subagentRunId`, and a parent suspended only because its child did carries no `interruptIds`. The resumed run announces the same `subagentRunId` again — the id is the tool-call id, which a resume replays unchanged. A cancel closes an open child with `SUBAGENT_ERROR { code: "cancelled" }`; a stream that ends without the child's `end` closes it with `code: "unterminated"`. `RUN_ERROR` abandons open children, as the protocol says it does. A chunk for a child that was never announced in this run is dropped. Root events never carry `subagentRunId`, and `RUN_STARTED`, `RUN_FINISHED` and `RUN_ERROR` never do. `GET /agui/{routeId}` advertises `multiAgent: { supported: true, delegation: true, handoffs: false, subagents: [{ name, description }] }` from the same subagent registry the `task` tool dispatches from, and omits the section for a route with no subagents. ##### Step events A tool that exports [`display`](https://b4.run/docs/tools#describing-a-call-to-people) tells the chat what each call means. The runtime emits one `CUSTOM` event named `b4.step` as the call starts (`status: "running"`, with `icon` and the running `label`) and one as it returns, just before its `TOOL_CALL_RESULT` (`status: "completed"`, with the done `label` and any `sources`). A tool that threw gets a `status: "failed"` step after its `TOOL_CALL_RESULT`, whether or not it has a `display`. A call that a permission or argument constraint denied gets a `status: "denied"` step in place of `completed`, with `icon` only: no `label` and no `sources`, because the denial text the model receives is not something the tool did. B4.run's own chat components show it as "Denied searchCorpus" and never count it as a failure. Every step names its call in `value.toolCallId`; a subagent's step carries `subagentRunId`. B4.run's vendor events all use the `b4.` prefix: `b4.step` here, and `b4.content_parts_dropped` from [Multimodal content](#multimodal-content). A client that does not know a name ignores it. The step's done payload, with its timing and the gate decision, is also stored on the checkpointed tool message as `additional_kwargs.b4_step` (the `task` tool's message carries `b4_subagent` as well), so `GET /threads/:thread_id/turns` rebuilds the labels for a restored thread. ##### Canonical orchestration presentation B4.run presents its built-in planning action once. When a `writeTodos` call produced the `b4.plan` activity, 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`); that internal correlation decides the suppression, and it stays out of activity content. A subagent's own `writeTodos` is never suppressed: its plan snapshot and its tool frames both flow, tagged with the child's id. Every other tool is unchanged. `task`, `recall`, `computeNavlog`, `readDoc`, `writeFile`, `fileFlightPlan`, and any application tool produce ordinary tool events. The rule fails open. When the adapter can't prove the suppression is safe, the client sees the ordinary tool card. That covers a call with no tool-call id or a colliding one, a malformed plan payload, a result that arrives with no correlation, buffer limits, and a stream that ends early. At a permission interrupt that belongs to the held call, the adapter drops the held frames rather than flushing them. The resumed run presents the same call again under the same tool-call id, and the client merges the two into one card. Unrelated held calls still flush before the interrupt. > **Warning: Activities are the only surface for writeTodos** > > A client that reads only tool frames shows nothing for `writeTodos`. `reduceTurns` > folds the `b4.plan` snapshots into the turn's plan step, so `B4Activity` (or > `TurnActivity` over `useB4Turns`) shows the plan and the subagents' nested steps > with no registration, as shown above. ##### Inbound input `fromRunAgentInput(input)` translates every AG-UI message and keeps the original request as `raw`, where you can inspect tools, state, and context. `B4Message.content` is a string or the ordered content parts the client sent; see [Multimodal content](#multimodal-content). The CLI endpoint forwards only the newest user message, because B4.run already keeps the checkpointed history for the thread. The standard top-level `RunAgentInput.resume` array becomes a list of B4.run resume requests: ```ts resume?: Array<{ interruptId: string status: "resolved" | "cancelled" payload?: unknown metadata?: { grant?: string } }> ``` Under [approval grants](https://b4.run/docs/approval-grants), echo the interrupt's grant back at `metadata.grant` on its resume entry. A 1.0 client strips any `grant` outside `metadata`. For a permission prompt, a resolved payload can carry a B4.run decision such as `"once"` or `"always"`. The runtime treats a cancelled interrupt as a denial. Every answer must address one currently pending interrupt. A permission interrupt names the tool call it gates in `toolCallId`, whichever gate raised it: tool, command, memory or path. Inside a subagent it also carries `subagentRunId`, the `task` call that started that subagent (see [Subagents](#subagents)). A gate whose model call had no id gets the enclosing `task` call as its `toolCallId` instead. A permission prompt carries `responseSchema: { type: "string", enum: ["once", "always", "deny"] }`, so a client knows what a resolved payload may say. A tool approved with `allowAlways: false` (see [Approve every call](https://b4.run/docs/permissions#approve-every-call)) carries `enum: ["once", "deny"]` instead, and its envelope in `metadata` has `allowAlways: false`; an `always` answer to it is treated as `once`. #### Response schema Some AG-UI clients render the assistant's reply as UI. [Hashbrown](https://hashbrown.dev) is one. Its client sends `hashbrown: { ui: true, responseSchema }` on every run, where `responseSchema` is the JSON Schema the final assistant message must match. The runtime either applies that schema or rejects the run. It never ignores it, because to the client an ignored schema looks like an honored one until a reply fails to parse. **Applied.** On an `agent` route whose provider can constrain its final message to a schema while still calling tools, the schema is bound on the root model as that provider's native structured output: | Provider | How the schema is applied | | --- | --- | | `openai` | `response_format: { type: "json_schema", json_schema: { name: "hashbrown_response", schema, strict: true } }` on every root-model call, on both the Chat Completions and Responses paths. | | `anthropic` | `output_config.format = { type: "json_schema", schema }` on every root-model call. | Tool-calling turns are unaffected. A turn that calls tools still returns tool calls, and only the answering turn is constrained. The schema is bound on the **root** model only. Subagents, summarization, and memory extraction never see it. Because the binding is per request, a run that carries a schema always compiles a fresh agent graph and skips the process cache. Strict OpenAI structured outputs need a schema OpenAI accepts in strict mode: every object has `additionalProperties: false` and lists all of its properties in `required`. Hashbrown's schema builder emits that shape. OpenAI rejects a hand-written schema that breaks these rules, and you'll see it as a `RUN_ERROR`. **Rejected.** Anywhere else, the run is refused with `422` and error code `B4_E5402`. This happens before middleware context is built into a run, before the thread is created, and before a run slot is claimed. `error.details.code` says why: | `details.code` | Meaning | | --- | --- | | `invalid_response_schema` | `hashbrown` is present but not a JSON object, or `hashbrown.responseSchema` is present but not a JSON Schema object. Judged from the body alone, before middleware runs. | | `response_schema_not_supported` | The route is not an `agent` route, or its provider (`google`, `mistral`, `groq`, `ollama`, `xai`, `openrouter`) has no schema-constrained output mode that coexists with tool calls. Judged after middleware has admitted the caller. | A run without `hashbrown`, or with `hashbrown: { ui: true }` and no schema, is unchanged. #### Threading AG-UI's `threadId` is the B4.run thread id, with no mapping table in between. AG-UI and Agent Protocol requests run the same route code and use the same thread store and checkpointer, so a caller can inspect state through Agent Protocol while a UI drives later turns through AG-UI. A permission prompt is answered on the route that parked it. An AG-UI `resume` sent to `/agui/` for the same thread is refused with `409` `resume_route_mismatch` before anything is consumed, so a caller admitted to one route cannot answer, and run under that route's graph, a prompt another route parked. The response does not name the parking route. A turn that parks on a permission prompt leaves the thread `interrupted` (not `idle`) on both surfaces. The park is written to the checkpointer, so it outlives the response that carried it. After a reload, a client reading `GET /threads/:thread_id` sees that the agent is still waiting on a person. `GET /threads/:thread_id/turns` rebuilds the activity view the chat showed, approvals included, from the checkpoint. Both surfaces 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`. The gate lives in one process. Shared durable stores won't extend it across replicas. #### Middleware and service authentication B4.run execution middleware gates AG-UI route execution the same way it gates Agent Protocol execution. It can reject the request or pass context into authored tools. It only covers route execution. Thread management, state, cancellation, memory-candidate management, and health routes bypass it. See [Middleware](https://b4.run/docs/middleware) for the request contract, and read [Security Architecture](https://b4.run/docs/security-architecture) before you expose the runtime outside a trusted local environment. #### Disconnect and reconnect > **Warning: No reconnect or replay support** > > Each POST gets one SSE response. B4.run doesn't buffer emitted AG-UI events > or support last-event-id replay. If the connection drops, B4.run aborts that > AG-UI run, and the client cannot reattach to its event stream. A later > request on the same thread starts a new run from the latest completed > checkpoint. Aborting the run keeps any 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. A client disconnect emits nothing further, since no one is left to receive it. A run stopped by `POST /threads/:thread_id/cancel` or by a server shutdown ends on the AG-UI stream with `RUN_FINISHED` and `outcome.type: "cancelled"`, not `RUN_ERROR`. Agent Protocol attach viewers of an AG-UI-started run see `{ cancelled: true }` for a cancel and an error for a shutdown or a disconnect. #### Related - [Dev Server](https://b4.run/docs/dev-server): start and restart the local runtime - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): the durable thread and run lifecycle - [Permissions](https://b4.run/docs/permissions): the B4.run decisions carried by resolved interrupt payloads - [Deployment Options](https://b4.run/docs/deployment): building and starting the production runtime --- ### Embed the Runtime Source: https://b4.run/docs/embedding B4.run can run its own Node listener, or it can give you a web-standard fetch handler to mount in a host you already run. Pick the highest-level stable export that gives your 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 your code should create and close that listener. - Use `createRuntimeFetchHandler` when an existing server, worker, test harness, or Hono app owns the transport and dependency lifetimes. - Use `serve` when one Node process must answer both B4.run's routes and your app's own. The rooted B4.run endpoint paths and middleware coverage stay the same when you embed. Your host still handles 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: `server.ts`: ```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`. If your host already coordinates several servers, keep signal handling there and call `await runtime.close()` in your own shutdown sequence. `b4 start` opts in. The generated Node `server.mjs` currently leaves it off. #### Serve application routes beside the runtime An application that answers its own HTTP routes from the same process — a local development entry point, a small single-process deployment — needs one listener that splits requests between B4.run and itself. `serve` owns that split: `main.ts`: ```ts import { serve } from "@b4run/cli" import handler from "./api.js" import middleware from "./middleware.js" await serve({ appRoot: process.cwd(), middleware, port: 4325, // Everything the runtime does not own. fallback: handler, }) ``` Which paths the runtime owns is B4.run's fact, not the application's. `serve` routes `/healthz`, `/readyz`, `/agui`, `/threads`, `/memory`, and `/workspace` — with any deeper path under each — from the same definition the Vercel build target writes into its route table, so a local process and a deployed one cannot disagree. A hand-written prefix list instead goes stale the moment the runtime grows a surface: the new endpoint reaches the application handler and answers 404, or, behind a single-page fallback, answers an HTML document with a 200. Omit `fallback` and the runtime answers every path, including the 404 for paths it does not recognize. `serve` defaults `installSignalHandlers` to `true`, because it is an entry point rather than a component of a larger host. Its shutdown runs in the only order that terminates: stop accepting connections, close the runtime so in-flight runs abort and streams finish, then drop the connections still held open. Pass `installSignalHandlers: false` when the host owns signals, and call `await handle.close()` from its own shutdown path. `onListening` replaces the logged address line. `guard` runs ahead of the split, for every request the process receives. It is where a single-process deployment checks what the whole service requires of a caller before any route runs: the internal token a proxy in front injects, an origin check, a rate limit. Return `true` once the guard has answered the request itself, `false` to let it continue. It may be async. A guard that throws answers 500. A guard that writes the response must return `true` (setting a header alone is fine), and it must not read the request body unless it answers the request. Exempt the health path by comparing the parsed pathname exactly, never by a prefix match on the raw URL: the runtime routes on the normalized path, so `/healthz/../threads` is `/threads` to the runtime. `main.ts`: ```ts import { serve } from "@b4run/cli" await serve({ appRoot: process.cwd(), guard: (request, response) => { const pathname = new URL(request.url ?? "/", "http://localhost").pathname if (pathname === "/healthz") return false const token = process.env.B4_INTERNAL_TOKEN if (token && request.headers["x-internal-token"] === token) return false response.writeHead(401, { "content-type": "application/json" }) response.end(JSON.stringify({ error: "unauthorized" })) return true }, }) ``` With the token unset or empty, every request except the health check is refused, which is the failure you want to notice at deploy time rather than an open service. The guard sees the raw Node request, so it covers surfaces `src/middleware.ts` never sees: middleware is a route-execution hook, while the thread-management, memory, and workspace surfaces reach the runtime without it. See [Security Architecture](https://b4.run/docs/security-architecture) for which gate owns what. Every other option is the runtime server's and is passed through untouched, including `modules`, `config`, `middleware`, and the boot-resolved stores. #### Compose the fetch runtime The edge-safe entry is `@b4run/cli/fetch`. It does no route discovery and has no filesystem or SQLite fallback, so you supply the generated edge manifest, a serializable config, and every store your exposed endpoints need. The example below is an **advanced lifecycle/store skeleton**, not a complete production edge/model host. It leaves out the generated `seedModelImporter` wiring, literal provider imports, `seedRuntimeEnv` binding seeding, and the complete serialized configuration. Without those, a model route can bundle incorrectly or read the wrong environment. For a production edge deployment, use the generated `.b4/build/app.mjs`. A host that replaces it must reproduce everything that artifact does, on top of this lifecycle pattern: `runtime.ts`: ```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) }, } ``` Let's quickly review: - The handler is built lazily, inside the first request. Construction creates an `AbortController`, which workerd forbids in module/global scope, so only the promise and a plain binding map live there. - The environment is bound to the incoming `Request`, so a later request can't reuse the first request's database environment by accident. - The memoized promise is reset when construction rejects, so a later invocation can retry. - `"/my-app"` is a placeholder for a specific value. `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` can return a checkpointer, threads store, permissions store, memory store, and `dispose`. A resource that can live as long as the handler can be passed at boot instead. 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`. If a request reaches a required store you left out, it fails loudly. It never falls back to 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 kept: `host.ts`: ```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 base-path option. Don't put it under a `/b4` prefix or use Hono's request-rebuilding mount helper. B4.run's routes are rooted, and the per-request environment lookup is keyed by the original request. Keep `app.route("/", b4App)` as shown. #### Lower-level tooling surface `@b4run/cli/runtime` is a lower-level tooling surface for B4.run's testing and internal runtime integrations. It exposes dynamic, Node-oriented machinery, so skip it when embedding. Application hosts should import `serveRuntime` and `loadStaticModules` from the package root, or `createRuntimeFetchHandler` from `@b4run/cli/fetch`. #### Dependency precedence On Node, modules, middleware, and store instances you pass in win. When a store is missing, Node falls back to the matching `b4.config.ts` field, and then to the local SQLite/file default where one exists. A `requestStores` result overrides matching boot instances for that request. The edge fetch entry has no filesystem fallback. It can't discover routes, load config, read permission files, or open default SQLite, so you supply those dependencies. A configured feature the edge cannot serve fails with a capability error. #### Endpoint paths Embedding changes who runs the listener. The API layout stays put. The handler dispatches these rooted paths: - `/healthz` for liveness (never builds request stores) and `/readyz` for dependency readiness (builds them and probes each durable store, as [Production Topology](https://b4.run/docs/production-topology) explains). - `/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 you need a public prefix, rewrite it at an outer proxy and test every request/response path. The runtime has no prefix setting. #### Resource ownership and shutdown The handler tracks response lifetime and run lifetime separately. For server-sent events, stream the response body through the host without buffering it. An Agent Protocol run can keep going after its viewer disconnects, but an AG-UI run aborts on disconnect. Await `close()` before ending pools your application owns. `close()` stops accepting requests, aborts shutdown-aware work, drains with a time bound, waits for request-store disposal, and releases sandboxes. Injected boot stores and pools stay open for you to close. If a request-store factory throws before returning, it must clean up whatever it already opened: `request-stores.ts`: ```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 } } ``` Let's quickly review: - The explicit `await permissionsStore.load()` fulfills the store's load contract before the store is returned. In `interactive` mode, that call hydrates persisted runtime grants into the synchronous match cache. Other modes skip those grants on purpose. - B4.run calls neither `ready()` nor `load()` for a `requestStores` override. It also skips layering the sibling B4.run permission config or the `B4_PERMISSIONS_MODE` override onto a custom store. Your application owns the effective mode, static allow/deny policy, hydration, refresh, and disposal. - `dispose` runs after the response and any run it started have settled. - If construction or hydration throws, the runtime has nothing to dispose, so the `catch` closes the partly opened pool itself. The generated Hono `stores.mjs` currently 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. When static policy, effective mode, or persisted interactive grants matter, write your own request-store wiring that supplies and loads the complete policy as above, and decide how replicas refresh it. If you own a boot pool, shut down in this order: ```ts await handler.close() await pool.end() ``` That way active runs and response bodies can't write through an ended pool. #### Authentication Put blanket authentication and tenant authorization in your host 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. Read [Security Architecture](https://b4.run/docs/security-architecture) before you expose an embedded handler. #### Test the embedded host Test the assembled host as well as the bare handler: 1. Verify unauthenticated requests fail for `/healthz`, `/readyz`, `/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](https://b4.run/docs/deployment/edge) for generated edge artifacts and target constraints, or [Node and Docker](https://b4.run/docs/deployment/node) for the standalone production server. --- ### Testing Overview Source: https://b4.run/docs/testing-overview B4.run gives you three ways to test an app. Scenario tests check what a route returns. The agent test harness checks what an agent does, turn by turn, against a replayed model. Evals score an agent over a whole dataset and fail CI when quality drops. Let's figure out which one you need. #### Which one should I use? Here is how the three compare: | | Scenario tests | Agent test harness | Evals | |---|---|---|---| | **Runs with** | `b4 test` | `vitest` | `b4 eval` | | **Package** | `@b4run/sdk/testing` | `@b4run/testing` | `@b4run/evals` and `@b4run/testing` | | **Real model?** | The route runs as written, so an agent route calls its configured provider | No. aimock replays fixtures. Live mode is a local opt-in | Not by default. `--live` and `--record` call the real model locally | | **Files** | `src/app//run.test.ts` | Any vitest test file, such as `test/agent.test.ts` | `src/app//evals/*.eval.ts` | | **Typical use** | Route input and output, workflow logic, mocked application tools | Tool calls, final messages, interrupts and state for an agent route | A quality bar across many cases that survives prompt and model changes | For workflow, graph and chain routes, start with scenario tests. Use the harness for agent routes, because it keeps the model deterministic. Add evals once you care about a score across a dataset rather than a single pass or fail. #### Scenario tests A scenario file sits next to the route it tests and default-exports a suite built with `scenarios()`: `src/app/(public)/support/[tenant]/run.test.ts`: ```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!" }), ) ``` Then run every scenario in the app: ```bash b4 test ``` Scenarios run in-process by default. Add `.server(url)` to send one through a running dev server when you need to exercise middleware or the HTTP layer. See [Scenario Testing](https://b4.run/docs/testing). #### Agent test harness The harness boots your route in the test process and swaps the model's HTTP endpoint for aimock. Your tools, prompts and state run normally. Let's install it: ```bash npm install -D @b4run/testing vitest ``` Here is a minimal test: `test/agent.test.ts`: ```ts import { fileURLToPath } from "node:url" import { afterAll, it } from "vitest" import { createAgentHarness, 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" }) }, 60_000) ``` Run it with `npx vitest --run`. See [Agent Test Harness](https://b4.run/docs/testing-agents) for the API. When an inline `script()` gets long, move it to a committed file as shown in [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures). #### Evals An eval runs an agent over a dataset, scores each output and gates on the aggregate. It lives in an `evals/` directory under the route: `src/app/chat/evals/quality.eval.ts`: ```ts import { contains, defineEval } from "@b4run/evals" import { script } from "@b4run/testing" export default defineEval({ name: "chat quality", dataset: [ { name: "greets the user", input: "hello", fixtures: script().user("hello").replies("Hi! How can I help?"), }, ], scorers: [contains("help", { threshold: 1 })], threshold: 1, }) ``` Then run it: ```bash b4 eval ``` Plain `b4 eval` replays fixtures and is safe for CI. `b4 eval --live` measures the real model on your machine, and `b4 eval --record` captures real responses as fixture files. Both need `OPENAI_API_KEY`. See [Evals](https://b4.run/docs/evals). #### Related - [Scenario Testing](https://b4.run/docs/testing): run.test.ts scenarios run by b4 test - [Agent Test Harness](https://b4.run/docs/testing-agents): vitest tests against a replayed model - [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures): author, record, commit, and replay model fixtures - [Evals](https://b4.run/docs/evals): score an agent over a dataset and gate CI on it --- ### Scenario Testing Source: https://b4.run/docs/testing Scenario tests check what a route returns for a given input. They live next to each route 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: `src/app/(public)/support/[tenant]/run.test.ts`: ```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!" }), ) ``` Then run all scenarios: ``` b4 test ``` B4.run discovers every `run.test.ts` under `src/app/`, checks 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 the builder uses, so you'll get IntelliSense for route names, `.mockTool()`, and `.expectTool()`. #### 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 types hide combinations that can't succeed, and the loader checks the same rules at runtime. For programmatic assertions, import the existing helpers from `@b4run/sdk/testing`: `src/app/(public)/support/[tenant]/run.test.ts`: ```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: `src/app/navlog/run.test.ts`: ```ts import { scenarios } from "@b4run/sdk/testing" export default scenarios("/navlog").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 you don't name in `.mockTool()` keep their real implementations. B4.run resolves shared and route-local tool precedence first. Then it swaps only the selected definition's implementation and keeps 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 checked separately when combined. Multiple `.withArgs()` assertions each need a matching invocation, and one compatible invocation may satisfy more than one matcher. This API has no assertions for call order or return values. Mocks apply only to the root route's generated application-tool set: route-local tools and shared tools. They can't shadow built-in planning, workspace, memory, skill, or subagent capability tools. A parent route's mocks never reach a child subagent route either. Each scenario invocation gets a fresh override set and call journal. B4.run never mutates cached route modules, so mocks can't leak into another scenario, a later `b4 run`, a server request, or a concurrent 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. You set the URL per scenario, because `b4 test` has no `--url` flag: `src/app/(public)/support/[tenant]/run.test.ts`: ```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 ``` > **Tip: Use both execution modes** > > Keep fast, isolated behavior checks in-process. Add a few server-backed scenarios for behavior that only exists across the transport or runtime. ##### When to use a server-backed scenario Use a server-backed scenario 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 - real staging infrastructure and deployment configuration Server-backed scenarios execute in another process and exchange JSON. JavaScript mock functions can't cross a process, so `.server()` and `.mockTool()` are mutually exclusive in the builder and in runtime validation. The server has no test backdoor for mocks. So control server dependencies from outside. 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](https://b4.run/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](https://b4.run/docs/middleware). The in-process agent harness also bypasses middleware. `createAgentHarness({ middlewareContext })` supplies the context its tools would otherwise read from it. #### Rules ##### Step 1: File location `run.test.ts` must live in the route's own directory. B4.run matches tests to routes by directory, so a sibling or nested folder won't match. ##### Step 2: Route-scoped suite Default-export `scenarios("/route").scenario(...)`. The declared route must match the file location, and plain scenario arrays are rejected. ##### Step 3: Complete every scenario Call `.input()` once and select `.expectPassed()` or `.expectFailed()`. Add declarative expectations or `.assert()` for the behavior the scenario owns. ##### Step 4: Use typed application-tool mocks narrowly 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 itself is under test. ##### Step 5: Keep scenarios focused One scenario makes one claim about the route's behavior. If you're adding branches, add more scenarios. #### CI Run `b4 verify` first (it covers app, routes, typegen, and deps in one call), then `b4 test`: ```yaml - run: npx b4 verify - run: npx b4 test ``` `b4 verify` runs typegen and check, plus the deps check (missing packages, missing env vars) that bare `b4 check && b4 typegen` skips. 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 Sometimes you want to test one unit (a single route tool, a `FilesystemMiddleware`, or code that uses `ctx.fs`) 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 you get real permission gating, realpath resolution and parent-directory creation. All three are async `create*Harness` factories that return a handle with `.close()` and `[Symbol.asyncDispose]`, the same convention as `createAgentHarness` and every other handle in `@b4run/testing`. ##### 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 disposes the harness (and cleans up the temp dir) at the end of the block, in place of `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 - [Testing Overview](https://b4.run/docs/testing-overview): when to use scenario tests, the agent harness, or evals - [Routes](https://b4.run/docs/routes): the four route kinds whose behavior scenarios assert - [State](https://b4.run/docs/state): state schema that input and output are validated against - [Middleware](https://b4.run/docs/middleware): why live-server scenarios exercise middleware but in-process scenarios bypass it - [Dev Server](https://b4.run/docs/dev-server): starting the runtime targeted by .server(url) scenarios - [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures): deterministic model responses for route scenarios and harness tests --- ### Agent Test Harness Source: https://b4.run/docs/testing-agents Learn how to test an agent route without calling a real model. `@b4run/testing` intercepts 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, so the tests are deterministic and safe for CI. Locally, you can opt into live mode to probe model behaviour, with looser assertions that allow for nondeterminism. #### Install Install the harness and vitest as dev dependencies: ```bash npm install -D @b4run/testing vitest ``` #### First test Here is the shape you'll use for most agent tests: `test/agent.test.ts`: ```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. > **Warning** > > 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, and `.replies()` tells it to respond with a text message. For a same-thread follow-up, use explicit fixtures with cumulative turn indexes (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 can skip `.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 Use `expectToolCalled` with `withArgs`: ```ts expectToolCalled(run, "applyFilter").withArgs({ status: "open" }) ``` `withArgs` does a partial (subset) match, so extra args are ignored. Chain `.times(n)` to assert a call count, or `.never()` to assert the tool wasn't called. ##### Assert the final message `expectFinalMessage` supports substring, regex, and full-text checks: ```ts expectFinalMessage(run).toContain("Found 2") expectFinalMessage(run).toMatch(/found \d+ items/i) expectFinalMessage(run).toEqual("Found 2 open items.") ``` ##### Assert streamed tokens arrived This one throws when nothing streamed: ```ts expectStreamedTokens(run) // throws if zero tokens were streamed ``` ##### Multi-turn: run the agent twice The second run continues the same thread, so its fixture expects `turnIndex: 1`: ```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 thread state. ##### Fresh-thread test isolation Call `h.reset()` to start over on a new thread: ```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 need a fresh thread. Leave it out between turns of one thread. ##### 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 Pass the tool names in the order you expect: ```ts expectToolSequence(run, ["lookupAirport", "computeNavlog", "writeFile"]) ``` By default it's a subsequence match, so other tools may appear between them. Pass `{ strict: true }` to require the tools to appear consecutively: ```ts expectToolSequence(run, ["validate", "save"], { strict: true }) ``` ##### Assert no tool returned an error HITL permission interrupts don't count as errors here: ```ts expectNoToolErrors(run) ``` To inspect tool results yourself, use `run.toolResults`. It's a `ReadonlyArray` collected from the run's streamed `tool_result` events. A tool that threw streams its `status: "error"` ToolMessage, so `isError` never depends on the final message list. A stream that carried no tool results falls back to the final thread messages via `deriveToolResults` (also exported). Each entry has the shape `{ name: string, status?: "error" | "success", content: unknown, isError: boolean }`. Use it to assert 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 `expectState` reads the final agent state: ```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. ##### Supply the middleware context `createAgentHarness` invokes the route's agent directly, so `src/middleware.ts` never runs and `ctx.middleware` is `undefined` inside tools. Pass `middlewareContext` with the record your middleware would have returned from `allow(context)`. It's either a fixed value or a function evaluated once per `run()`/`resume()` that receives the turn's `threadId` and `input`: ```ts const h = await createAgentHarness({ appRoot, route: "/assistant#agent", middlewareContext: async ({ threadId }) => ({ session: await openTestSession(threadId), ledger: snapshotLedger(), }), }) ``` Every tool the run invokes reads that record as `ctx.middleware`. The same option is accepted on `defineEval` so `b4 eval` can drive routes whose tools depend on middleware. ##### Constrain the final message A Hashbrown client sends a JSON Schema as `hashbrown.responseSchema` on every AG-UI run, and the server binds it on the root model so the final message must match it. The harness drives the route directly, with no AG-UI request, so pass the same schema as `responseSchema`: ```ts const h = await createAgentHarness({ appRoot, route: "/assistant#agent", responseSchema: { type: "object", properties: { ui: { type: "array", items: { type: "string" } } }, required: ["ui"], additionalProperties: false, }, }) ``` Every turn then sends the model the same `response_format` a production run sends, so recorded fixtures capture what the model does under that constraint. Without it, a recording can show replies production never produces, such as an empty final message. Only an `agent` route on a provider that supports schema-constrained output (`openai`, `anthropic`) can take one; any other route fails the run instead of running unconstrained. `defineEval` accepts the same option. #### Choose how much to run `@b4run/testing` has a separate factory for each level: - **`createAgentHarness()`** runs tools, prompts, capabilities, and state directly through B4.run's runtime in the test process, with no application port. Use it by default for fast agent-behavior tests. - **`createAgentProtocolInjector()`** drives Agent Protocol requests through the runtime's fetch handler without binding a port. Use it when the HTTP request, response, or SSE shape is what you're testing. - **`createSubprocessApp()`** starts a real `b4 dev` child process. Use it for multi-process, restart, and persistence tests. Each factory has its own option and result types. There's no `mode` option on `createAgentHarness()`. #### CI setup You don't need any extra setup. aimock runs on a random port and stops when the harness closes. Just guard that fixture files are committed: `.github/workflows/ci.yml`: ```yaml - run: npx vitest --run - run: test -z "$(git status --porcelain -- test/fixtures/)" # tracked and untracked drift ``` #### Your scaffolded app already has a test The default `basic` template generates `test/agent.test.ts`, which imports `@b4run/testing` and replays one scripted greeting against the `/hello#agent` route. The navlog template (`npm create b4-app@latest my-agent -- --template navlog`) generates keyless unit tests under `server/test/` for its navlog math, POH tables, weather parsers and tools, and replays its agent through scripted fixtures in `server/src/app/navlog/evals/navlog-quality.eval.ts`. In either app, run the suite right after scaffolding from the app root: ```bash npm test ``` To test the route itself, add a harness test that scripts the model and lets the tools run, for example (`computeNavlog` is pure, so this one needs no network): `server/test/plan.test.ts`: ```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: "/navlog#agent" }) afterAll(async () => { await h.close() }) const navlogInput = { aircraft: { tailNumber: "N738ZU", cruiseRpm: 2400, usableFuelGal: 50 }, altitudeFt: 4500, departureTimeUtc: "2026-10-06T14:00:00Z", waypoints: [ { id: "KSTP", lat: 44.9346, lon: -93.0603, elevationFt: 705, magneticVariationDeg: 0, kind: "airport" }, { id: "KRST", lat: 43.9083, lon: -92.49, elevationFt: 1317, magneticVariationDeg: 0, kind: "airport" }, ], winds: [{ dirDegTrue: 320, speedKt: 20 }], } it("computes the navlog and cites the POH", async () => { h.reset() const run = await h.run({ input: "Plan KSTP to KRST at 4500 feet, departing 1400Z.", fixtures: script() .user("Plan KSTP to KRST at 4500 feet, departing 1400Z.") .callsTool("computeNavlog", navlogInput) .replies("66 nm, 33 minutes, 5.5 gal. [poh/cruise-performance.md, Figure 5-7]"), }) expectToolCalled(run, "computeNavlog") expectFinalMessage(run).toContain("[poh/") }, 60_000) ``` Call `h.reset()` per test. `reset()` starts a fresh thread and clears the previous scenario's fixtures, so the tests stay isolated. 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](https://b4.run/docs/testing-agents/fixtures) for multi-turn or multi-tool scenarios that are tedious to hand-write. #### Related - [Testing Overview](https://b4.run/docs/testing-overview): when to use scenario tests, the agent harness, or evals - [Scenario Testing](https://b4.run/docs/testing): declarative run.test.ts scenarios for route-output unit tests - [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures): author, record, commit, and replay model fixtures - [Agents](https://b4.run/docs/agents): the agent route kind that @b4run/testing exercises - [Tools](https://b4.run/docs/tools): tools that run normally during harness tests - [State](https://b4.run/docs/state): the state shape expectState() asserts against --- ### Fixtures and Recording Source: https://b4.run/docs/testing-agents/fixtures Fixtures are the model responses the agent test harness replays. Use an inline fixture when a scenario is short enough to read beside the test. Use a committed fixture file when several tests share an exchange, a tool chain is long, or you want to review the model traffic itself. Both replay deterministically. Recording and live mode are 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 next to its assertions: ```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 keeps counting in a continuing thread. - `hasToolResult` examines only messages after the latest user message. It's `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 suit fresh-thread scenarios. For a same-thread follow-up, supply explicit fixtures with the cumulative `turnIndex` from the existing thread. #### Fixture files: author, commit, replay Resolve a fixture beside the test once, then pass that absolute path to the file helpers: `test/agent.test.ts`: ```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 as given, so a relative string passed directly to either helper is relative to `process.cwd()`. ##### Author inline and snapshot to a file `writeFixtures` accepts a `script()` builder or a fixture array. It creates parent directories and writes formatted `{ "fixtures": [...] }` JSON: `test/agent.test.ts`: ```ts writeFixtures( fixturesPath, script() .user("Filter open items") .callsTool("applyFilter", { status: "open" }) .replies("Found 2 open items."), ) ``` Run this authoring step on purpose, inspect the JSON and commit it. Then take it out of the normal test path so tests only replay. ##### 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 also 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: `test/agent.test.ts`: ```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, and they stay. 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, and 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 any `turnIndex` mismatch must reject the fixture. ##### Record from a real model (local only) Harness recording exercises one B4.run route and turns 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 right away, inspect the result, then write it. Set `OPENAI_API_KEY` for the default upstream: `test/record-filter.ts`: ```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()`. It builds each fixture's match fields from that call's own request, read the way aimock reads it on replay: - `userMessage`: the latest user message with text in the request. - `hasToolResult`: whether a tool-role message follows that user message. - `turnIndex`: the number of assistant messages already in the request. So each call is keyed by its position within its own agent run. A route that dispatches subagents in parallel records correctly: their calls interleave with the parent's in any order, but each subagent's request carries only its own history. Every call in record mode goes to the upstream, including a call that an earlier recording in the same run would match. After building the fixtures, `getRecordedFixtures()` replays each recorded request against them. A subagent's input often contains its parent's message, and `userMessage` matches by substring, so one fixture can match another call's request. That fixture gets a `toolName` key (a tool only its own request offers) or a `toolCallId` key (the tool call its request answers). If nothing in the two requests tells them apart, `getRecordedFixtures()` throws and names both turns. It won't return fixtures that replay would serve to the wrong call. The standalone `record({ out, provider? })` helper is a different API. It launches the aimock recorder in a separate process and returns nothing. There's no harness and no route involved: `scripts/record-fixture.ts`: ```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", }) ``` The child process resolves a relative `out` path against 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 with an error. #### 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: `test/agent.live.test.ts`: ```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 can't 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 asserting on literal arguments or response text. Never run live mode in CI, even when a provider key is present there. #### CI rules and fixture drift CI should only replay fixtures. Keep record and live mode local. Commit every fixture a test loads, keep recording scripts out of the test command, and fail when a fixture update is tracked or untracked: `.github/workflows/ci.yml`: ```yaml - run: npx 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. #### Process-global constraints and cleanup One harness changes process-global aimock and environment state: `OPENAI_BASE_URL`, `OPENAI_API_KEY`, and materialized-model caches. So 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 thread content to the configured provider. Treat captured files as reviewable source, and 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 don't overlap. #### Related - [Agent Test Harness](https://b4.run/docs/testing-agents): run routes and assert agent behavior deterministically - [Scenario Testing](https://b4.run/docs/testing): test route outputs with run.test.ts scenarios - [Evals](https://b4.run/docs/evals): score behavior across datasets and repeated cases --- ### Evals Source: https://b4.run/docs/evals Scenario tests and `@b4run/testing` answer "is it correct?" for a handful of cases. Evals answer "how good is it?". They run your agent over a whole **dataset**, score each output and aggregate the scores into a single verdict you can **gate** on. Use them when you want a quality bar that survives prompt edits, model swaps and refactors. By default, each case replays pre-written **aimock** fixtures, just like `@b4run/testing`, so evals are safe to run in CI. `b4 eval --live` measures the real model locally. #### Install Install both packages as dev dependencies: ```bash npm install -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, in 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 under a route directory binds to that route automatically. You only need the `route` field when the file lives elsewhere. #### `defineEval` Here is a one-case eval: `src/app/chat/evals/quality.eval.ts`: ```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 lives under a route, required otherwise. - **`dataset`:** the cases to run (see below). - **`scorers`:** one or more scorers. Each runs against every case. - **`middlewareContext`:** the context your `middleware.ts` would have produced, as a value or a `(run) => context` function evaluated per case. `b4 eval` runs the route in-process and bypasses middleware, so tools that read `ctx.middleware` need it supplied here. See [Supply the middleware context](https://b4.run/docs/testing-agents#supply-the-middleware-context). - **`responseSchema`:** the JSON Schema your client sends as `hashbrown.responseSchema`. `b4 eval` binds it on the root model exactly as the server does for an AG-UI run, so replayed, live and recorded cases send the model the request production sends. See [Constrain the final message](https://b4.run/docs/testing-agents#constrain-the-final-message). - **`threshold`:** sugar for `gate.mean(threshold)` (see Gating). - **`gate`:** a composable gate policy. It 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 (`--live` ignores it). #### 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 }`. That's its own pass bar, used by `gate.perScorer` and per-case pass logic. For anything else, 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 judge sends a chat-completions request whenever it runs. In B4.run CLI replay, that request goes through aimock, and fixtures can answer it. 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. `gate.perScorer()` uses it, and so does the check for whether a case "passed" (every scorer must meet its bar, and the default bar is `0.5`). If you set neither `gate` nor `threshold`, the eval is **informational**. It always passes and just reports scores, so you can 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](https://b4.run/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: ```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 prompt tuning. Keep both `--live` and `--record` out of CI. ##### Recording fixtures with `--record` `b4 eval --record` runs the suite against the real model (it requires `OPENAI_API_KEY`) 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. Scorer model calls stay out of 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. `--record` skips cases that already have inline `script()` fixtures (logging `skipped record (inline fixtures)`) and never overwrites them. The gate still applies during `--record`, and scores are checked against the threshold after all cases run. Fixture files are written per case before the verdict, so a gate failure never discards captured responses. A recording is checked against the same rules replay loads it with, so `--record` never writes a file that plain `b4 eval` would reject. The usual cause is a model turn that came back empty: the model refused, ran out of output tokens, or had nothing left to say after a tool did the work. In that case the command exits with code 2 and names the case and turn, and that case's file is not written. If the answer is a tool call, export [`returnDirect`](https://b4.run/docs/tools#ending-the-run-from-a-tool) from that tool so the run ends on its result. ```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`). - **`--record`:** record real-model responses into sibling fixture files (requires `OPENAI_API_KEY`). 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](https://b4.run/docs/cli) for the full command surface. #### A full example Let's put a tool check and a text check behind a per-scorer gate: `src/app/chat/evals/quality.eval.ts`: ```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 its fixture files alongside your route. CI replays them, including any `llmJudge` calls. #### Your scaffolded app already has an eval The default `basic` template generates `src/app/hello/evals/smoke.eval.ts`, a one-case replay eval for the `/hello` agent, and an `eval` npm script. The navlog template (`npm create b4-app@latest my-agent -- --template navlog`) generates `server/src/app/navlog/evals/navlog-quality.eval.ts` next to its flight-planning route, adds `@b4run/evals` as a dev dependency of the `server` package, and wires an `eval` npm script at the workspace root. In either app, run it right after scaffolding: ```bash npm run eval ``` This wraps `b4 eval` and runs the agent cases in **replay mode** by default. The navlog eval (`server/src/app/navlog/evals/navlog-quality.eval.ts`) has three dataset cases, each with inline `script()` fixtures, and these scorers: - `toolCalled("computeNavlog", { threshold: 1 })`, and `toolCalled("task", { withArgs: { subagent: "weather" } })` and its `performance` twin, assert the plan used the navlog tool and both subagents. - Custom `navlog-shape` and `totals-add-up-and-reserve` scorers parse `computeNavlog`'s result and check its legs, totals and legal reserve. - A custom `cites-poh` scorer asserts the brief cites a POH file that exists in the workspace, and `files-only-when-asked` that no flight plan was filed unasked. - `llmJudge({ criteria: "The brief names the flight category at every airport, …", model: "gpt-5-mini", threshold: 0.7 })`. The gate is `gate.all(gate.passRate(1), gate.perScorer())`, so every case must pass and every scorer must meet its threshold. The scripted fixtures include the judge turn used by `llmJudge`, so the default `npm run eval` needs no key; the tools still run, so the weather tools reach aviationweather.gov. 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, for example in custom CI tooling or a reporting pipeline: `scripts/run-chat-eval.ts`: ```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`. - `baseDir` (optional) is the base directory for resolving a string dataset path. It returns a `Promise` containing `{ name, cases, byScorer, mean, gated, passed, reason? }`. #### Related - [Testing Overview](https://b4.run/docs/testing-overview): when to use scenario tests, the agent harness, or evals - [Scenario Testing](https://b4.run/docs/testing): declarative run.test.ts scenarios for route-output unit tests - [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures): author, record, commit, and replay model fixtures - [CLI Reference](https://b4.run/docs/cli): the b4 eval command and its flags - [Agents](https://b4.run/docs/agents): the agent route kind evals exercise - [Tools](https://b4.run/docs/tools): tools that run normally during eval replay --- ### Security Architecture Source: https://b4.run/docs/security-architecture B4.run's route middleware is an execution hook, and it only sees requests that are about to run a route. So put outer authentication and tenant authorization in front of every non-local runtime endpoint. Then layer B4.run's inner agent controls behind it. #### Pages in this section This page covers the service edge. The other Secure pages cover the checks inside B4.run: | Page | What it covers | |---|---| | [Thread Access](https://b4.run/docs/thread-access) | Who may create, read, change or delete a thread, checked in the runtime on every thread endpoint. | | [Access Control](https://b4.run/docs/access-control) | The four controls on a running route (tool scoping, permissions, execution sandbox and guarded delegation) and how they compose. | | [Permissions](https://b4.run/docs/permissions) | Human-in-the-loop approval for commands, paths, tools, subagents and memory writes. | | [Execution Sandbox](https://b4.run/docs/sandbox) | Running the workspace tools in an isolated filesystem, shell and network. | | [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes) | The Pod-backed sandbox provider and the cluster setup it needs. | #### Start at the service edge Authenticate the caller before the request reaches the B4.run service, and restrict network reachability where you can. The outer layer should: - reject unauthenticated access on every route, - apply rate and body-size limits, - terminate TLS, - attach verified identity for authorization and audit. This layer must cover the entire service. Management and health routes never reach `src/middleware.ts`, so protect them here. #### Endpoint coverage B4.run middleware runs when a request is about to execute a route, and on the two reads that disclose a parked prompt's payload. Other 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 | | Parked prompts and turns | `GET /threads/:thread_id/pending_interrupts`, `GET /threads/:thread_id/turns`, `GET /threads/:thread_id/events` | Yes, the parking route's middleware | 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 no request reaches a B4.run handler unauthenticated. The "B4.run middleware" column is about route execution only. When the app has a [thread-access policy](https://b4.run/docs/thread-access), that policy checks per-thread ownership on every thread row above (management, state, cancellation, Agent Protocol execution and AG-UI execution) inside the runtime. It's a second gate behind the outer one. 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 records your application keeps. A sound request path looks like this: 1. Resolve the verified principal. 2. Determine its tenant and roles. 3. Load or check thread ownership. 4. Forward the request. A thread identifier proves nothing about ownership, even when it's hard to guess. Use the same rule for state, cancellation, deletion, resume and any custom list endpoint. Memory needs the same care. Candidate management can operate across namespaces, and `memory.resolveScope` receives route and app-root context but never the verified request identity. Don't 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 that trusted value and returns canonical identity through `allow({ ... })`. Tools read it from `ctx.middleware`: `src/middleware.ts`: ```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 the request body must never 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, [Access Control](https://b4.run/docs/access-control) composes four narrower controls on each running route: - [Tool scoping](https://b4.run/docs/tools#scoping-a-routes-tools) limits which tools the model is offered. - [Permissions](https://b4.run/docs/permissions) gate commands, paths, approved tools, delegations and selected memory writes. - The [execution sandbox](https://b4.run/docs/sandbox) constrains B4.run's workspace filesystem and shell tools. - [Guarded delegation](https://b4.run/docs/subagents#delegation-policy) decides whether a subagent receives the delegated input. These controls cover B4.run's own tools, not your 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 Keep secrets out of `b4.config.ts` values. 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 somewhere their contents stay private. 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 it fits, and an audited deletion workflow. #### Target differences Each build target draws the service edge in a different place: - **Node** (`b4 start`, `serveRuntime`, generated server). Your reverse proxy or Node host provides blanket auth. B4.run middleware covers execution and AG-UI only. Node can use configured sandboxes. - **Hono**. Hono or 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 handles authentication. Generated graph entries don't include B4.run HTTP middleware. Route tool scope and the applicable agent controls remain part of graph materialization. Check the capabilities of your chosen [Deployment Option](https://b4.run/docs/deployment) before assuming a control carries over unchanged between targets. #### Production checklist - Restrict network access and require authentication on every endpoint group in the table. - 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. - Review all of this again whenever the build target or embedding host changes. #### Related - [Middleware](https://b4.run/docs/middleware): execution gating and verified context flow - [Access Control](https://b4.run/docs/access-control): tool scoping, permissions, execution sandbox and guarded delegation - [Persistence and Tenancy](https://b4.run/docs/persistence): data ownership, deletion, and encryption - [Production Topology](https://b4.run/docs/production-topology): replica and cancellation routing --- ### Thread Access Source: https://b4.run/docs/thread-access `src/thread-access.ts` answers one question: **may this caller create, read, mutate, or destroy this thread?** [Middleware](https://b4.run/docs/middleware) answers a different question ("may this caller run this route") 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 it onto a route of their choosing. So thread authorization is 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. Ids are neither secret nor collision-proof (`t-` plus four random bytes). #### The shape Here's a policy that lets each caller reach only their own threads, with read access for admins: `src/thread-access.ts`: ```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, and a named `threadAccess` export is the fallback. The `basic` template of `create-b4-app` scaffolds this file, and the `src/auth.ts` it imports, as `src/thread-access.ts.example` and `src/auth.ts.example` at the generated root. Drop both `.example` suffixes to activate them. B4.run only probes the names above, so an unrenamed scaffold changes nothing. They ship inert because a deny-by-default policy denies every caller the app can't authenticate yet, and a generated app has no identity provider on its first run. The `navlog` template ships them active in its `server/` package: its `principalOf` returns one local principal when no proxy token is configured, so local development is unaffected, and each visitor is its own principal behind the deployed proxy. `fallback` is required. That makes "I forgot to handle delete" a compile error. Without it, every request for that action would be silently allowed (or denied). #### 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. | | `requestedWorkspace` | The workspace the request stages or chooses: `{ sourceDigest }` on `workspace.source.put`, the whole reference `{ sourceDigest, environmentLinks?, baseline? }` plus `uploadedBy` on a `thread.create` that names one. `undefined` everywhere else, including the create's recheck. | | `resuming` | `true` when this request carries a resume credential or a client tool result, and may 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 continues an already-interrupted run instead of starting a fresh one — or when an AG-UI request ends in a `role: "tool"` message, which is how a client answers a parked [client-provided tool](https://b4.run/docs/ag-ui) call. It's `false` on every other request and never absent, so a policy writes `if (req.resuming)` and never `?? false`. The trailing tool message counts on every AG-UI route, whether or not the route takes client tools, and whether or not a call is actually parked: the policy runs before B4.run reads the thread, so `resuming` describes the request, not the thread. It can therefore be `true` on a request that turns out to resume nothing — the safe direction for a rule that holds resumes to a higher bar. **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}` whose last message has `role: "tool"` | `run.agui` | `true` | | `POST /agui/{routeId}` with neither | `run.agui` | `false` | | everything else | any | `false` | The middle rows are why this field exists. An AG-UI resume reports `run.agui`, just like an ordinary AG-UI turn. Only the request body tells them apart, and a policy never sees it. So **a policy that holds resumes 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` is endpoint identity ("which door did this come through"). `resuming` is request shape ("what is in the body"). Neither replaces the other. 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. ```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 can't forge one, and it's 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 leaves that case to you. 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's safe under `===` and unsafe under `includes`, `startsWith` or `split(",")`, which is 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. #### Workspace endpoints `sandbox.workspaceRead: "http"` serves a read of a thread's workspace files as the `thread.workspace` operation, under `read`. B4.run refuses to boot the option without a policy. A policy that authorizes by owner already covers it; a service that reads every thread (a controller, a reviewer) is usually admitted by a credential in `headers`, compared in constant time. `sandbox.stagedWorkspaces` serves `PUT /workspace/sources/:digest` as the `workspace.source.put` operation, a `create` with no `threadId` and no `thread`, and accepts a `workspace` on `POST /threads`. Both carry `requestedWorkspace`: the upload its `{ sourceDigest }`, the create the whole reference it names. Enabling the option means reviewing your `create` handler: one written before it admits both unless it checks this field. One rule, `if (req.requestedWorkspace)`, decides who may stage and choose a workspace, and lets a caller create threads without choosing what they run on. B4.run refuses to boot the option without a policy. A stamp your policy returns on `workspace.source.put` is not a thread's: B4.run keeps it with the upload as its uploader, and a create that names that source carries every such stamp in `requestedWorkspace.uploadedBy`. Uploads are content-addressed, so two callers who upload the same bytes share one source and both appear. To keep one caller from choosing another's upload, stamp uploads with the caller and check it on create: `src/thread-access.ts`: ```ts export default defineThreadAccess({ create: async (req) => { const user = await principalOf(req.headers) if (!user) return deny() // The stamp on an upload is kept as its uploader, not as a thread's. if (req.operation === "workspace.source.put") return permit({ ownerId: user.id }) const uploadedBy = req.requestedWorkspace?.uploadedBy if (uploadedBy && !uploadedBy.some((uploader) => uploader.ownerId === user.id)) return deny({ status: 403 }) return permit({ ownerId: user.id, org: user.org }) }, fallback: owned, }) ``` `principalOf` and `owned` are the ones from [the shape](#the-shape) above. B4.run compares uploaders by their JSON with keys sorted, so a stamp built in a different key order is the same uploader. A stamp that is not a JSON object of at most 16 KiB cannot be kept, and the upload is refused with `422 workspace_uploader_invalid` before its body is read. #### 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 | | `GET /threads/:thread_id/turns` | 404 `thread_not_found`, the same body a genuine miss returns | | `GET /threads/:thread_id/events` | 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 looks the same as a miss. That 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 can't mint a 200, a 500 or a redirect). Anything else falls back to the per-action default. > **Warning** > > **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 allows it, because an app that authenticates every caller may want honest diagnostics. Make it a deliberate choice, not something you get by copying the `update` branch. > **Warning** > > **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 mean "this exists and isn't yours". DELETE keeps that hidden. 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's 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. The per-action default is deliberate. Forcing 403 on a read would make a broken policy answer differently from a working one and hand back the enumeration oracle. B4.run puts no timeout on a policy call, so a policy that **hangs** on a slow identity provider turns 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. An import failure can then only mean "the policy is broken". - No policy file on disk: no gate. - 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`. The message names which of four cases hit: no `default` or `threadAccess` export, the bound value is not an object, `fallback` is missing or not a function, or a per-action key is present but not a function. There is no path on which a policy you wrote resolves to "allow everything". See [Error codes](https://b4.run/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. A build that silently dropped it would deploy every thread endpoint ungated. Put equivalent authorization in the LangSmith platform. `node`, `hono` and `vercel` carry the policy. The built manifest has a slot for it: the policy rides in as a static import, and the generated entry point (`server.mjs` for `node`, `app.mjs` for the web targets) records that the build saw one. So the built app never probes the disk for the policy at boot, where a missing file would read as "no policy". For `node`, the built app is `node .b4/build/server.mjs`, the entry point of the generated `Dockerfile`. `b4 start` doesn't run the build output: it serves the source tree, and reads the policy from source the way `b4 dev` does. Instead of a disk probe, the built app handles a missing policy like this: - On `node`, the manifest imports the policy from the app's source tree, so a policy file removed after the build fails that import and the server refuses to start. `hono` and `vercel` bundle the policy into the build output, so the deployed app doesn't need the source file. - A manifest generated before the app had a policy, deployed beside a newer entry point, fails the boot. Either way the app refuses to start rather than coming up silently ungated. `b4 build` itself never imports the policy, so building needs none of the secrets the policy reads. The built manifest imports it as it loads, together with the routes and middleware. So a policy that reads a required environment variable at import time needs that variable wherever the manifest loads: the running server, and `b4 check` run after `b4 build`. Read secrets inside the hooks instead if `b4 check` runs somewhere they aren't set. An app with no policy file builds for every target exactly as before. See [Deployment](https://b4.run/docs/deployment) and [Edge and Hono](https://b4.run/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 shut on purpose. There is no HTTP endpoint for metadata updates, and the runtime asserts that none of its own metadata patches carry the key. `scripts/backfill-thread-access.ts`: ```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" }, }) } ``` Like `b4 inspect` and `b4 memory`, this assumes a local operator with filesystem or database access. We document it, but we don't defend against it. #### Identifiers, never secrets `GET /threads/:thread_id` returns the raw thread, reserved key included. That's 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. Leave out anything 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`, `GET /threads/:thread_id/turns` and `GET /threads/:thread_id/events` are on this policy **as well as** [route middleware](https://b4.run/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. Keep middleware doing its per-caller work. A `run.*` operation on a thread that exists arrives under `action: "update"`, because 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. B4.run asks about that under `action: "create"`, then again as the `update` recheck that follows every create (the same two steps `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, just 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**. > **Warning: On a client-chosen id, ownership is first come, first served** > > `POST /threads` mints the id itself. The run endpoints take the one the caller sent. This matters most for `POST /agui/{routeId}`, 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 makes the race safe. 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`, `GET /threads/:thread_id/turns` and `GET /threads/:thread_id/events` compose both checks as AND: the route that parked the interrupts must admit the caller *and* this policy must permit the read. The response doesn't say which one refused. A thread-access deny returns the handler's own `404 thread_not_found`, the same bytes a genuine miss returns. 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. > **Warning: On /resume, a thread-access deny replaces a middleware 401** > > `POST /threads/:thread_id/resume`, `GET /threads/:thread_id/pending_interrupts`, `GET /threads/:thread_id/turns` and `GET /threads/:thread_id/events` are the 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`, `/turns` and `/events`. > > We had no choice about that ordering. Middleware needs the route key, and on both endpoints the route key is read from the thread's own metadata. Resolving it means reading a thread the caller isn't 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, with no credential needed) and read the `400`/`409` codes as an oracle on a guessed `interruptId`/`resumeKey`. Both checks still apply on both endpoints. Only the order differs. --- ### Access Control Source: https://b4.run/docs/access-control Four independent controls decide what a running route can do: tool scoping, permissions, the execution sandbox and guarded delegation. Each one answers a different question, so they compose. A route uses the controls it needs. > **Warning: Start outside the agent boundary** > > These controls act on a running route. Authenticating the HTTP service and establishing tenant ownership happen earlier, at the service edge, where you protect health, thread management, state, cancellation, AG-UI, execution and memory-candidate routes. See [Security Architecture](https://b4.run/docs/security-architecture). B4.run middleware is a narrower execution hook, and [Thread Access](https://b4.run/docs/thread-access) decides who may use a thread. | Control | Question it answers | Docs | |---|---|---| | Tool scoping | *Which* tools can the model call? | [Tools](https://b4.run/docs/tools#scoping-a-routes-tools) | | Permissions | *Whether* a given call runs (human-in-the-loop approval) | [Permissions](https://b4.run/docs/permissions) | | Execution sandbox | *What* an allowed, approved call can actually touch | [Execution Sandbox](https://b4.run/docs/sandbox) | | Guarded delegation | *Whether* a child receives the delegated input before any of its tools execute | [Subagents](https://b4.run/docs/subagents#delegation-policy) | #### Tool scoping `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. - Denying `writeFile` also withholds `editFile`, unless `allow` names `editFile`. An `allow` that names `writeFile` doesn't grant `editFile`. B4.run enforces this at composition time, so the model has no way to call a denied tool. Here's a route that removes `runBash`: `src/app/ops/index.ts`: ```ts export default agent({ model: "gpt-5", systemPrompt: "…", tools: { deny: ["runBash"] } }) ``` Scoping controls the *surface*. Once a tool is granted, it does whatever its implementation permits. A granted `writeFile` can still write anywhere it's allowed to. See [Tools](https://b4.run/docs/tools#scoping-a-routes-tools). #### Permissions 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`. The workspace command and path tools already have their pattern-aware gates. See [Permissions](https://b4.run/docs/permissions) for the interrupt payloads and resume flow. #### Execution sandbox Even a tool that's allowed and approved still runs somewhere. By default that's the local `workspace/` directory on the host. Add a `sandbox` key to `b4.config.ts` and every `readFile`, `writeFile`, `editFile`, `listDir` and `runBash` call for a thread runs in an isolated environment: filesystem, shell and (optionally) network. B4.run ships a Docker reference provider and a [Kubernetes provider](https://b4.run/docs/sandbox/kubernetes) behind the same `SandboxProvider` interface. See [Execution Sandbox](https://b4.run/docs/sandbox) for what any provider guarantees. #### Guarded delegation A parent's `delegation` policy runs at the last step of dispatch, before the child starts. Its allow, deny, approve or constraint decision determines whether the child receives the delegated input at all. The child's own tool policies and permission gates run only after that decision allows dispatch. See [Subagents](https://b4.run/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 uses all four controls. First, the config: `b4.config.ts`: ```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({ scope: "my-app", image: "node:24-slim" }) }, // what runBash can touch }) ``` Then the route: `src/app/ops/index.ts`: ```ts import { agent } from "@b4run/sdk" import researcher from "./subagents/researcher/index.js" export default agent({ model: "gpt-5", systemPrompt: "…", // Denying writeFile also withholds editFile, so this route can't write files. tools: { deny: ["writeFile"], approve: ["deployProd"] }, subagents: { researcher }, delegation: { rules: { researcher: { action: "approve", reason: "Review delegated customer context." }, }, }, }) ``` > **Info: Each control answers a separate question** > > Tool scoping without a sandbox still lets an allowed `runBash` touch the whole host filesystem. A sandbox redirects only the five built-in workspace tools. 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. After the child starts, its own tool scope, permissions and sandbox decide what it can do. #### Related - [Tools](https://b4.run/docs/tools): tool scoping with allow, deny, approve and constrain - [Permissions](https://b4.run/docs/permissions): human-in-the-loop approval for paths, commands, tools, and memory writes - [Execution Sandbox](https://b4.run/docs/sandbox): per-thread filesystem, shell, and network isolation - [Subagents](https://b4.run/docs/subagents): guarded delegation before a child receives input - [Security Architecture](https://b4.run/docs/security-architecture): outer authentication and tenant authorization - [Configuration Reference](https://b4.run/docs/configuration): b4.config.ts reference including permissions and sandbox --- ### Permissions Source: https://b4.run/docs/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 more gates use the same interrupt machinery: 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. #### Configuration Set allow and deny lists in `b4.config.ts`: `b4.config.ts`: ```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. For example, the navlog scaffold sets `tools: { deny: ["runBash"], approve: [{ tool: "fileFlightPlan", allowAlways: false }] }` on its route, so the agent never runs a shell command and a person approves every flight-plan filing. Static entries stay in `b4.config.ts`. The runtime evaluates them alongside its own decisions but keeps them out of 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`. Custom stores and the Postgres store persist them in their own backend. #### 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 | `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`; `editFile` is gated as a `readFile` and a `writeFile`) | Canonical parent directory with a trailing slash | Prefix | | Memory write | `memory` | Workspace-and-route namespace with a trailing pipe character | 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 | | Client-provided tool | Reserved `clientTool` | None (never prompts) | Exact | Thus `"ls"` covers `ls`, `ls -la`, and `ls workspace/corpus`. Use longer bash, path, and memory prefixes to be more specific. Reserved `tool`, `subagent` and `clientTool` entries never prefix-match. #### Modes | Mode | Unknown command behavior | |---|---| | `interactive` (default) | Run pauses and sends the client an interrupt for a human decision | | `non-interactive` | Unknown commands are denied immediately (fail-closed) | | `bypass` | All commands are allowed without checking (dev/test only) | To override the mode for a single run, set the `B4_PERMISSIONS_MODE` environment variable. It takes precedence over `permissions.mode` in `b4.config.ts`: ```sh B4_PERMISSIONS_MODE=non-interactive b4 dev ``` #### Per-tool approval A route can also require human approval before *any* named tool call (authored route tool or capability tool) with the `tools.approve` knob: `src/app/ops/index.ts`: ```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](https://b4.run/docs/tools#constraining-arguments) can escalate a specific call to this same prompt by returning `{ approve: true }`. For example, 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", "toolCallId": "call_deploy_1", "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). The human sees it, but it's never matched against or persisted. `detail.suggestedPattern` is always the tool name itself. `toolCallId` is the model's id for the gated call, so a client can show the prompt on that call. Command and memory gates carry it too; path gates don't carry it yet. ##### Decisions are name-level Resuming a `kind: "tool"` interrupt uses the same `once` / `always` / `deny` decisions as the other gates, but they apply to the tool name, not a pattern: | 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. | ##### Approve every call Some tools should never get a standing approval: filing, sending or paying is a per-call decision, and behind a shared permission store one person's "Always" would approve the tool for everyone. Write the entry as an object with `allowAlways: false`: `src/app/navlog/index.ts`: ```ts export default agent({ model: "gpt-5-mini", systemPrompt: "…", tools: { approve: [{ tool: "fileFlightPlan", allowAlways: false }] }, }) ``` A bare name and the object form can be mixed in one list. For a tool approved this way: - Every call prompts in `interactive` mode, even when the permission store holds an allow rule for the tool, whether configured under `permissions.allow.tool` or persisted by an earlier "Always" answer. - The interrupt envelope carries `"allowAlways": false`, and the AG-UI interrupt advertises `responseSchema.enum: ["once", "deny"]`, so the activity kit's approval card shows only **Allow once** and **Deny**. - A client that answers `always` anyway gets `once`: the call runs, nothing is persisted, and the step records `once`. - `bypass` still skips the gate, a deny rule still denies, and `non-interactive` mode fails closed, so a headless run of such a tool needs `bypass`. A [constraint](https://b4.run/docs/tools#constraining-arguments) on the same tool wins over its `approve` entry, and a `{ approve: true }` escalation still offers "Always". `b4 check` warns when a route has both. ##### Pre-approval in config To pre-approve a tool so it never prompts, add it to `permissions.allow.tool` in `b4.config.ts`: `b4.config.ts`: ```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`, `editFile` 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, since they're 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, and `default: "approve"` requires review for every child without an explicit rule: `src/app/support/index.ts`: ```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 keys on the parent route id and parent-local registration name serialized in `suggestedPattern`. The target route id and the input play no part. So `always` approves only that one 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 registration under the reserved `subagent` key: `b4.config.ts`: ```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 static and constraint denials still apply. `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. #### Client-provided tools A route opted in to [client-provided tools](https://b4.run/docs/ag-ui#client-provided-tools) gates each call the model makes to one under the reserved `clientTool` key, matched against the name the client registered (`openPanel`, not `client_openPanel`). - **Allowed by default.** The gate never prompts, so an unmatched name is allowed in every mode, `non-interactive` included. There is no prompt whose absence could fail closed. - **`deny` refuses.** A matching `permissions.deny.clientTool` entry refuses the call: it is never recorded or parked, and the model gets the denial (`B4_E3001`) as the tool result, as it does for a denied server tool. - **Exact match**, like `tool` and `subagent`: `deny: { clientTool: ["open"] }` does not cover `openEverything`. - **Never inherits `tool:`.** A client tool named `deployProd` is not covered by an `allow.tool` or `deny.tool` entry for the server's own `deployProd`. The name is caller-authored, so it must not borrow a decision made about a server tool. - **`always` is never offered.** Its pattern would be a caller-authored string saved into the operator's permissions. - `bypass` allows every call. `b4.config.ts`: ```ts export default { permissions: { deny: { clientTool: ["deleteAccount"] } }, server: { agui: { clientTools: ["/chat"] } }, } ``` The decision is taken once, before the call parks. A `deny` added while a call is parked does not discard a result the client already produced. #### Memory write approval (`writes: "ask"`) Routes with [long-term memory](https://b4.run/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, so all future overwrites in the route proceed silently. - **Deny:** the old memory stays active, and 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` behaves like `auto`. It helps a human supervise, so don't rely on it as a security control. Explicit `deny` entries are honored in every mode except `bypass`. Hand-authored patterns should keep the trailing `|` terminator: `"workspace=app|route=/a|"` can't 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 wasn't on the allow list: ``` event: interrupt data: { "interruptId": "perm-abc123", "type": "permission-request", "kind": "command", "toolCallId": "call_bash_1", "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 `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": "/navlog#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 takes `resume` and `route` and nothing else. 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, so clients never resume a child route or thread separately. The old scalar resume body is gone, and B4.run won't parse it as a compatibility form. Let's walk through the full sequence with 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 and 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":"/navlog#agent"}' # ...SSE output... # event: interrupt # data: {"interruptId":"perm-abc123","type":"permission-request","kind":"command","toolCallId":"call_bash_1","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":"/navlog#agent"}' # The response streams the continuation as SSE ``` Only `"always"` persists. It adds the allow entry to the configured permissions store (`.b4/permissions.json` with the default Node store), or, for a thread with [its own permissions](#per-thread-permissions), to that thread's record. On later runs the command matches the allow list and proceeds without prompting. #### Per-thread permissions An app whose `sandbox.thread` resolver returns `permissions` gives that thread its own lists: - The thread's `allow` replaces the app's `permissions.allow` for that thread. The app's allow-list, and grants other threads saved to `.b4/permissions.json`, don't apply to it. A thread that sets only `deny` gets an empty allow-list: nothing the app allowed applies to it. - Denials add up: the thread's `deny` and the app's denials both apply, and a denial wins over any allow. - The mode is the app's. In `bypass` mode nothing is checked, as for any thread. - An **Always** decision in such a thread is saved to that thread's record in the workspace installation, never to `.b4/permissions.json` or a configured `permissions.store`. It applies to later turns of that thread and to its subagents, not to other threads. Deleting the thread deletes its grants. - An empty pattern (`""`) is refused, because it matches every candidate and is almost always a mistake, and so is a whitespace-only one, which names nothing. A deliberately broad pattern, such as `"/"` for every path, is accepted. - A grant is kept only if its pattern is at most 4096 characters. A longer suggested pattern is allowed for that one call, as **Once** would be, and not saved, with a warning in the server log. A thread whose resolver returns no `permissions` uses the app's store unchanged. #### Testing In `@b4run/testing`, use `expectInterrupt` and `harness.resume` to drive the approval flow in tests without a live server. See the [testing docs](https://b4.run/docs/testing-agents). #### Related - [Workspace Filesystem](https://b4.run/docs/workspace): the runBash tool and the five agent-facing workspace tools - [Configuration Reference](https://b4.run/docs/configuration): b4.config.ts reference including the full permissions block --- ### Approval Grants Source: https://b4.run/docs/approval-grants An **approval grant** is a single-use capability the runtime mints when it parks a [human-in-the-loop approval](https://b4.run/docs/permissions), and requires back when that approval is answered. It exists because disclosure control is not consumption control. [Thread access](https://b4.run/docs/thread-access) already decides who may touch a thread at all, and that is the right answer to *who*. It is coarse by construction: it cannot tell two resumes of the same thread by the same caller apart, and it should not try. So inside a session that legitimately holds the thread, two things were unguarded: - **Replay.** The same approval answered twice — and a financial allocation applied twice with it. Nothing in the runtime recorded that a parked call had already been answered. What looked like replay protection was emergent: once LangGraph consumed the pending writes, a replayed body found an empty pending set and got a `409`. That is a side effect of LangGraph's semantics, not a property B4.run enforced. - **Staleness.** An answer minted against an earlier proposal applied to the current one, because nothing marked the earlier prompt dead when the thread moved past it. #### Two layers, AND-composed | | `ThreadAccessPolicy` | The grant | |---|---|---| | Question | **Who** is this caller, and may they touch this thread at all | **Which** parked call is this answering, and is this the **first** answer | | Knows about | Headers, identity, tenancy, the ownership stamp, `resuming` | The thread, the interrupt, the graph position, the consumption record | | Knows nothing about | Which interrupt, how many times | Who the caller is | | Runs | First, always | Second, only after an allow | | Refuses with | 403/404, your policy's choice | 400/403/409, fixed by the runtime | The order is fixed and the grant check never runs first. A grant is verified **after** the access gate, so a caller the policy denies can never read a grant failure as an oracle telling them whether a guessed `interruptId` names a real parked call. The grant is deliberately **not** bound to caller identity, and your policy is deliberately never shown the grant value. The runtime has no identity model — binding to a header it does not understand would be theater — and a policy that authorized on a client-supplied value would be authorizing on input that has not been verified yet. If you want "the same human who was prompted must answer", that is a question your policy answers from your own identity, in `update`, with `req.resuming === true`. Grants also do not replace the in-process resume claim. The claim answers "is one resume in flight"; the grant answers "was this answered before". Different questions, both kept. #### Turning it on `b4.config.ts`: ```ts export default { approvals: { // "off" (default) | "optional" | "required" grants: "optional", // grantTtlMs: 86_400_000, // omitted means no expiry // grantStore: createPostgresInterruptGrantStore({ ... }), }, } ``` | Mode | Park | Resume | |---|---|---| | `"off"` (default) | No grant is minted. | No grant is required. Exactly the pre-grant behavior. | | `"optional"` | A grant is minted and disclosed whenever the run can mint one. | An interrupt that **has** a grant **requires** it. An interrupt with no grant row resumes as before. | | `"required"` | A park that cannot mint a grant aborts the turn. | A resume with no grant is refused. | Grants cover **approval parks** only — the prompts a human answers. Even under `"required"`, a [client-provided tool](https://b4.run/docs/ag-ui#client-provided-tools) call parks with no grant and is never grant-protected: the park is invisible to the client, so there is nothing to disclose a grant on. Its protection is the retained record of the call — answerable once, only by the route that issued it, only while it is outstanding and unexpired. The setting is process-wide and **ratchets up only**: one process can prepare routes for more than one app root, and a second config with a weaker setting must not be able to weaken a stricter one already in force — that would be a downgrade attack expressible as an ordinary config file. Two app roots in one process therefore share the strictest mode either of them asks for. A process that needs two different modes needs two processes. ##### The `"optional"` rule, which is load-bearing > An interrupt that **has** a grant row **requires** its grant. An interrupt with **no** grant row — one parked before you switched grants on, or parked by an invoker that could not mint — resumes exactly as it did before. The softness is **per-interrupt-age, not per-request.** Read the other way round, `"optional"` would be a bypass rather than a migration window: omit the grant, get the old path. It is a window, and it drains as the threads parked before the change settle. Threads parked before the change cannot be retrofitted. Minting a grant for a prompt already disclosed to whoever saw it proves nothing, so under `"required"` those resumes answer `409 grant_unavailable` and the prompt has to be re-parked. The residual set is small and bounded. #### The client flow ##### Out The grant reaches the client on the channels that already carry the prompt, and only those, so there is exactly one disclosure gate to reason about — the same `thread.pending_interrupts` / `thread.attach` gate, composed as AND with the parking route's middleware. | Channel | Where the grant is | |---|---| | The AG-UI `interrupt` chunk | `metadata.grant` | | `GET /threads/:thread_id/pending_interrupts` | `grant` beside `interruptId`, `resumeKey` and `value` | | The attach `state` frame | `grant` on each entry of `interrupts` | On AG-UI the grant is at `metadata.grant` and nowhere else. `metadata` is schema-defined on AG-UI's `Interrupt` and `ResumeEntry`, so a 1.0 client's enforcement stage leaves it intact in both directions; a top-level `grant` is a field the protocol guarantees to strip — on every prompt received, and from every resume entry before it is sent. The Agent Protocol endpoints (`GET /threads/:thread_id/pending_interrupts`, the attach `state` frame, `POST /threads/:thread_id/resume`) are B4.run's own wire and keep the top-level `grant` shown below. > **Info: The grant is re-readable, not read-once** > > A client that reattaches after a reload must be able to read the grant again, and reattach is a supported path. Single-use is a property of **consumption**, not of disclosure. Nothing is spent by looking. ##### Back Per entry in the resume body, not as a header — a resume body carries one entry per pending interrupt and must address the pending set exactly. On AG-UI, echo the grant back the same way it arrived, in `metadata.grant` on the resume entry: ```json { "interruptId": "perm-abc123", "status": "resolved", "payload": "once", "metadata": { "grant": "b4ag_..." } } ``` On Agent Protocol it is the top-level `grant`: ```sh 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","grant":"b4ag_..."}],"route":"/navlog#agent"}' ``` `grant` is opaque. The client echoes the string it was given and authors nothing about the decision beyond `status` and `payload`. It is optional on the type for migration only; at runtime it is required whenever the interrupt has one. A `cancelled` entry carries its grant too. A denial **is** a decision, and a re-answerable denial is a replay surface of its own, so `deny` consumes the grant exactly as `once` and `always` do. #### Every failure | Status | `code` | When | |---|---|---| | `400` | `grant_required` | Mode is `"required"`, the interrupt has a grant, and the entry omitted it. | | `403` | `grant_invalid` | The grant does not match — wrong grant, grant for another parked call, malformed, or no row under `"optional"` with a grant presented anyway. | | `409` | `grant_consumed` | Already answered. Echoes `consumedAt` and `consumedDecision`. | | `409` | `grant_expired` | `grantTtlMs` elapsed before anyone answered. | | `409` | `grant_unavailable` | Grants are on but nothing can record them, or the interrupt was parked without one and the mode is `"required"`. | | `409` | `stale_interrupt` | The thread moved past this parked call before it was answered. | `403 grant_invalid` is the **same** status, code and message for every one of its causes. That is deliberate: a caller who has already passed the thread gate must not be able to use the difference to learn whether a guessed `interruptId` names a real parked call. The endpoint is not an oracle, and it will not grow a `reason` field to make debugging easier — log server-side instead. `409 grant_consumed` echoing the decision is also deliberate, and is not a leak: the caller made that decision. It is there so a double-submitting UI renders "already approved" instead of re-prompting. The call is **not** re-executed. `409 stale_interrupt` is the existing code, reused on purpose. When a turn settles, every outstanding grant for that thread whose parked call is no longer pending is stamped void — so to a client, a voided grant and a vanished interrupt are the same event, because they are. #### Fail-closed at the park site Under `"required"`, a park that cannot mint a grant **aborts the turn**, loudly, rather than parking a prompt nobody can answer safely. The failure is `MissingApprovalGrantMinterError` and B4.run never catches it. This is the half that makes `"required"` mean anything. The minter is injected per run and injection is optional by construction — an invoker that calls the streaming entry points directly omits it, exactly as it omits `threadId`. If that absence produced a park with no grant, `"required"` would silently degrade to the old path for precisely the invokers whose missing minter it is supposed to catch. The park site is the one place that knows a grant is about to be needed, so it is the one place that can refuse. If you invoke a route outside the B4.run runtime and want grants required, inject the minter yourself or set `approvals.grants` to `"optional"` or `"off"`. #### Where consumption is recorded A B4-owned `interrupt_grants` table, added by an additive versioned migration. It does not alter `checkpoints`, `writes` or `threads`, so it is reversible by dropping one table and setting `"off"`. | Runtime | Store | |---|---| | Node, default | SQLite at `/.b4/interrupt-grants.sqlite` | | Multi-replica | `createPostgresInterruptGrantStore` from `@b4run/postgres-storage` | | Everything else | An in-process store — neither durable nor replica-safe | Set `approvals.grantStore` to override. A multi-replica deployment **must**: the in-process store cannot see another replica's consumption, which is the one thing the feature exists to record. Only a SHA-256 hash of the grant is stored, for the same reason a password is not stored — an operator with read access to the database, or a leaked backup, must not thereby be able to answer approval prompts. Consumption is a conditional `UPDATE … WHERE consumed_at IS NULL AND voided_at IS NULL`, so the row count decides the winner: atomic, durable across a restart, and correct across replicas. The table is deliberately not the checkpointer's `writes` blob. That blob is LangGraph's, it is returned to clients, and it is deleted on LangGraph's schedule rather than B4.run's — the consumption record would disappear at exactly the moment it is needed to answer a replay. Voided rows do not accumulate. Once the thread has moved past a prompt, the runtime voids its grant row — a consumed grant is voided when its resumed turn completes — and deletes it after [`approvals.grantRetentionMs`](https://b4.run/docs/configuration#approvals) (7 days by default), sweeping at most once an hour wherever it voids superseded grants; `b4 approvals prune` does the same on demand. An outstanding grant is never deleted, even after `grantTtlMs` has passed, and neither is a consumed grant whose resume never completed: in both cases the prompt is still parked, and under `"optional"` a parked prompt with no row resumes without a grant, so deleting it would weaken exactly the prompt it protects. An expired grant keeps answering `409 grant_expired` until the thread moves on and the row is voided. #### Two limitations worth stating plainly ##### At-most-once *delivery* is not exactly-once *effect* The grant is consumed immediately **before** the resume is delivered to the graph. A crash in between loses the approval and the human is prompted again, rather than risking a double application. That direction is chosen on purpose, and it is the honest statement of what this buys. It says nothing about what your tool did. A tool that half-applied a ledger write before crashing is still half-applied. **Your own idempotency key does not become redundant**, and deleting it because approvals are now single-use is the most likely way to misread this page. ##### The plaintext grant is at rest in the checkpoint The park site carries the grant in the interrupt envelope, because it lives inside LangGraph's `interrupt()` throw and has no storage handle with which to attach it later at projection time. The envelope is persisted verbatim. So the plaintext grant sits in the checkpointer's `writes` blob for as long as the prompt is parked. The hash-only grant store therefore protects the **consumption ledger**, not the checkpoint. **Anyone with read access to the checkpoint store can answer a prompt that is still parked.** Treat the checkpointer as the sensitive store it already is; grants do not change its threat model. #### What stays your application's job 1. **What a decision means.** B4.run's vocabulary is `once` / `always` / `deny` over a tool call. "Approve allocating $4,812.00 of payment P to invoice I" is not in that vocabulary and will not be. 2. **Idempotency of the effect.** See above. This is the one most likely to be misread as code you can now delete. 3. **Binding the decision to the domain object the human saw.** The runtime knows the tool call. It does not know which version of the proposal was on screen. 4. **Authority.** Whether *this* human may approve *this* amount — approval limits, four-eyes, segregation of duties. 5. **Audit in domain terms.** The table records that an approval was consumed, when, and with which decision. It does not record who, or why, or against what. #### Related - [Permissions](https://b4.run/docs/permissions): the human-in-the-loop gates that park the approvals grants bind to - [Thread Access](https://b4.run/docs/thread-access): the policy that answers who may touch a thread at all - [Configuration Reference](https://b4.run/docs/configuration): b4.config.ts reference including the full approvals block --- ### Execution Sandbox Source: https://b4.run/docs/sandbox The execution sandbox runs workspace tools in an isolated filesystem, shell and network, away from the app's local `workspace/` directory. Add a `sandbox` key to `b4.config.ts`, and B4.run routes `readFile`, `writeFile`, `editFile`, `listDir` and `runBash` through a `SandboxProvider`. B4.run ships a Docker reference implementation, and other providers can implement the same interface. The built-in providers hash an application scope with each logical thread ID to name their resources. Your application still authorizes thread access. The sandbox is the third of four [access controls](https://b4.run/docs/access-control), after [tool scoping](https://b4.run/docs/tools#scoping-a-routes-tools) and [permissions](https://b4.run/docs/permissions) and before [guarded delegation](https://b4.run/docs/subagents#delegation-policy). It limits what an allowed call can touch. #### Quickstart Install Docker, start its daemon, and add the package: ```bash npm install @b4run/sandbox ``` Then configure `dockerSandbox` in **b4.config.ts**: `b4.config.ts`: ```ts import { config } from "@b4run/cli" import { dockerSandbox } from "@b4run/sandbox" export default config({ sandbox: { provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }), network: { mode: "deny" }, // no egress; see "Network policy" env: { NODE_ENV: "production" }, resources: { memoryMb: 512, cpus: 1, timeoutMs: 120_000 }, idleTimeoutMs: 600_000, }, }) ``` Without a `sandbox` key, workspace tools keep using the app's local `workspace/` directory. `b4 check` validates the configuration and calls the provider's optional `preflight()`. For Docker, that 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 is still subject to permissions. - **Network:** `sandbox.network` states a portable policy. Enforcement depends on the provider. - **Environment:** only `sandbox.env` entries are injected. The host environment stays on the host. - **Resources:** `memoryMb` and `cpus` cap compute, and `timeoutMs` caps one command. #### Lifecycle B4.run passes the 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 keep the workspace volume. - A later turn reattaches that volume. - Thread deletion calls `destroy()`, which removes both compute and workspace data. Storage is named from a stable hash of the application scope and 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](https://b4.run/docs/sandbox/kubernetes) for that provider's reaper. Subagents share their parent's thread, so they share its sandbox too. ##### Application scope Both providers require `scope`, a stable application and environment identifier such as `code-fixer-production`. Scope and thread ID are hashed together for every container, volume, Pod, PVC and NetworkPolicy name. Case and punctuation are significant. Handles and tool context keep the original logical thread ID. Use a different scope for each independent application or environment. Keep it stable across restarts and deployments, and don't generate it on startup. Replicas using the same scope address the same storage, but scope adds no cross-process coordination. It is not authentication: authorize access to each thread and keep mutually untrusted workloads behind separate daemon or namespace boundaries. > **Warning: Breaking change: workspace resource names** > > Upgrading from providers without scope changes all resource names. Old storage is not automatically reattached, migrated, or deleted. Export any data you need before upgrading and clean up the old resources yourself. Changing scope also selects different storage. Missing or blank scopes fail immediately. ##### Per-thread workspaces Most apps give every thread the same initial workspace. When threads need different starting points (say, one repository pin per work order), make `sandbox.workspace` a function of the thread: `b4.config.ts`: ```ts export default config({ sandbox: { provider: dockerSandbox({ scope: "factory", image: "factory-builder:pinned" }), workspace: async ({ threadId, metadata, signal }) => { const task = String(metadata.task ?? "") signal.throwIfAborted() if (!/^[a-z0-9-]+$/.test(task)) throw new Error(`thread ${threadId} names no task`) // `include` is the exact list of files under `directory`, not a set of paths to admit. return { source: { directory: `tasks/${task}`, include: ["TASK.md", "src/index.ts", "src/lib.ts"] }, baseline: "git", } }, }, }) ``` Let's quickly review: - `include` must name every file under `directory`. Narrow the capture with `directory` and `excludeDirectories`, not with a partial `include` list. - The resolver runs once, when the thread is first admitted, and B4.run records its result by digest. Every later turn reads the record. Deleting the thread deletes the record, so a thread ID reused after deletion is resolved again. - A resolver that captures from the app root (`directory: "."`) must exclude `.b4` with `excludeDirectories`, because the installation's own state lives there. An excluded directory must already exist when the capture runs. That's always true at admission, but not for a static definition in a fresh clone at build time. - The resolver runs inside the thread's admission critical section. Honor `signal` and return promptly, because a hung resolver holds that thread's admission open. - Thread metadata is client input, so validate it as the example does. `b4 check` reports a resolver as "managed workspace is resolved per thread", and a built app carries a resolver marker instead of captured source. ##### Per-thread sandboxes When threads also need different images or limits (say, one toolchain per target), let `sandbox.thread` decide each thread's whole sandbox instead of `sandbox.workspace`: `b4.config.ts`: ```ts const targets: Record = { small: { image: "builder-small:1a2b3c4d5e6f", memoryMb: 1024 }, large: { image: "builder-large:6f5e4d3c2b1a", memoryMb: 8192 }, } export default config({ sandbox: { provider: dockerSandbox({ scope: "builder", images: (reference) => Object.values(targets).some((t) => t.image === reference), }), network: { mode: "deny" }, thread: async ({ threadId, metadata }) => { const target = targets[String(metadata.target ?? "")] if (!target) throw new Error(`thread ${threadId} names no known target`) return { workspace: { source: { directory: "project", include: ["package.json", "src/index.ts"] } }, environment: { image: target.image }, policy: { resources: { memoryMb: target.memoryMb } }, permissions: { allow: { bash: ["npm test"] } }, } }, }, }) ``` Let's quickly review: - `sandbox.thread` and `sandbox.workspace` are exclusive. `b4 check`, `b4 build` and startup refuse both together, and refuse any key the `sandbox` block doesn't define. - The resolver runs once, when the thread is first admitted. B4.run records the image's immutable identity in the thread's creation intent, and the image reference and policy in a record written in the same transaction. Later turns, restarts and readers use the records, so changing the resolver or `images` never changes a thread that already exists. - `images` bounds what a resolver may name. The Docker provider refuses any other reference before it runs a Docker command, and always allows its own `image`. Without `image`, every thread must name one. Narrowing `images` later doesn't re-check existing threads: each keeps the image identity recorded at its first admission, so to revoke an image, delete the threads that use it. - A thread's `resources` merge over the app's key by key, and its `env` replaces the app's. Its `network` can keep or narrow the app's but never open a network the app denies. `security` stays the app's. Network allow and deny lists and `resources.diskGb` aren't accepted per thread, because managed workspaces don't enforce them. - `permissions` gives the thread its own allow-list, on top of the app's mode and denials. See [Per-thread permissions](https://b4.run/docs/permissions#per-thread-permissions). - Every thread admitted under `sandbox.thread` gets a record, even when the resolver chose no image or policy. A thread that has a workspace but no record (it was created before the app switched to `sandbox.thread`, or its record was lost) is refused rather than run with the app's defaults: delete it to resolve it again. - A provider supports per-thread images by implementing `resolveImageEnvironment`. A provider without it refuses a thread that names an image. Kubernetes has no managed workspaces, so it refuses `sandbox.thread` entirely. `b4 check` reports "workspace, image and policy are resolved per thread", and a built app carries a thread marker instead of captured source. #### 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` | | Orphan reaping | `--init` (always on; not policy-controlled) | `--init` installs a real PID 1 in the session container. Without it, nothing reaps a descendant orphaned by a cancelled or timed-out command. The zombie holds a `--pids-limit` slot for the life of the container, and process-tree termination looks like it failed because the pid is still signalable. The portable `security` policy exposes `dropAllCapabilities`, `noNewPrivileges`, `readOnlyRootFilesystem`, `runAsNonRoot` and `pidsLimit`. You can override each one for a compatible image, but every relaxation weakens isolation. > **Warning: Bake dependencies into the image** > > The default non-root user and read-only root filesystem block runtime package installs and writes outside the workspace or scratch mounts. Install system and global dependencies when you build the image. ##### Per-command timeout `resources.timeoutMs` bounds one `runBash` call. Docker wraps the command with GNU `timeout`, and an overrun exits with code `124`. The limit is rounded up to a whole second and applies only when configured. The sandbox image must contain the `timeout` binary. `node:24-slim`, Debian and Ubuntu include it or can install it, but minimal and distroless images may not: ```ts sandbox: { provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }), resources: { timeoutMs: 120_000 }, }, ``` #### Network policy The portable policy has two modes: - `{ mode: "deny", allowlist?: [...] }` asks the provider for default-closed egress. Docker enforces this with `--network none` and ignores `allowlist`. Kubernetes reads `allowlist` as CIDR exceptions. - `{ mode: "allow", denylist?: [...] }` leaves egress open. Neither the Docker nor the Kubernetes provider enforces `denylist`. A custom provider can. When you omit it, the policy defaults to `{ mode: "allow" }`: open egress, including the cloud metadata endpoint `169.254.169.254`. On a cloud VM, that endpoint can hand the instance's credentials to any command the agent runs. Use `{ mode: "deny" }` when the sandbox does not need the network. When it does, block the endpoint outside B4.run: a host firewall or egress proxy for Docker, or the infrastructure chart's default-deny egress backstop or your own NetworkPolicy for Kubernetes. Providers map the policy differently, so read the provider's guide before assuming Docker behavior applies elsewhere. #### Kubernetes provider Configuration, prerequisites and cluster security live in [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes). #### Subagents A subagent runs under its parent's thread, so the coordinator and all of its subagents resolve to the same sandbox and workspace. #### Reading a thread's workspace from another process A trusted process outside the runtime can read a thread's workspace without touching that thread's live sandbox. For example, a controller that dispatched work to an agent can check what came back: ```ts import { dockerSandbox } from "@b4run/sandbox" import { inspectWorkspace, withWorkspaceReader } from "@b4run/workspace" // The same scope and image the worker runtime uses. const provider = dockerSandbox({ scope: "my-app", image: "node:24-slim" }) const produced = await withWorkspaceReader(provider, { threadId, signal }, (reader) => inspectWorkspace(reader, { excludeRootDirectories: [".git"], maxTotalBytes: 2 * 1024 * 1024, }), ) ``` `withWorkspaceReader` opens the reader, hands it to the callback, and always closes it. Call `provider.openWorkspaceReader()` directly only if you need to manage `close()` yourself. A reader is safe while a worker is mid-thread or idle between turns. It never calls `acquire()`, so the worker's compute stays put. It reads the workspace through a separate, ephemeral, networkless container that mounts the workspace read-only. Writes fail at the kernel, and the reader object has no write method and no shell, so nothing on it can express a mutation. Reads after `close()` are refused up front, so you never see a confusing container error. `openWorkspaceReader` is optional on `SandboxProvider`, so check for it or let `withWorkspaceReader` raise. `dockerSandbox` implements it. `kubernetesSandbox` doesn't. Its per-thread storage is a `ReadWriteOnce` PersistentVolumeClaim, which a second Pod can mount only if it happens to land on the same node. A capability that works by accident of scheduling is worse than a missing one. Two things to know before you use it: - **It is not an authorization boundary.** It's a host-side API for a caller you already trust, one that holds the provider and with it the container runtime. Naming a thread ID proves nothing about ownership, so gate access to the calling process. Since it's neither an agent tool nor an HTTP endpoint, no route, permission or thread-access policy is consulted. - **The reader runs as uid/gid `1000:1000` by default**, matching the owner of a workspace produced under the default security policy. A thread that set `security.runAsNonRoot: false` produced root-owned files. Pass the same value as `runAsNonRoot` on the input so the reader can read restrictive modes. An inspection is not an atomic snapshot. Quiesce the writer (read after the turn ends) or revalidate before acting on the inventory. Docker readers are labelled `b4.sandbox.reader=` and are removed on `close()`, including when the open itself fails. If `close()` can't remove the container, it raises and you can retry it. Deleting a thread whose workspace is managed (`sandbox.workspace` or `sandbox.thread`) also removes that workspace's readers, including one abandoned at a read deadline. Deleting a thread that uses provider storage leaves its readers in place, so a reader that's never closed outlives the thread. An operator finds leftovers by that label: `docker ps -aq --filter label=b4.sandbox.reader`. Reading needs a workspace volume with a host-visible backing path, which the default local volume driver provides. A volume that reports none is rejected. A named-volume mount would have silently recreated a deleted workspace instead of failing. #### Reading a thread's workspace over HTTP A caller with no path to the worker's app root (another host, another container) reads a thread's managed workspace through the worker's own Agent Protocol port. Turn it on in `b4.config.ts`: ```ts export default config({ sandbox: { provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }), workspace: async (thread) => definitionFor(thread), workspaceRead: "http", }, }) ``` and read with the client from `@b4run/cli/workspace`: ```ts import { readThreadWorkspace } from "@b4run/cli/workspace" const read = await readThreadWorkspace( "http://worker:4100", threadId, { root: "draft", excludeRootDirectories: [".git"] }, { headers: { authorization: `Bearer ${token}` }, expectedSourceDigest }, ) read.inspection.files // relative to `root` ``` The worker serves it with the same reader as above, inside its own process: a separate, networkless, read-only container, never the thread's session, running as the app's `security.runAsNonRoot`. The read holds the thread's run slot, so it never overlaps a turn: a run in flight answers `409 run_in_flight`, and a run started during the read gets the same. - **It needs a thread-access policy.** `b4 check`, `b4 build` and boot refuse `workspaceRead` without one, because the endpoint discloses every file under the requested root. The policy sees it as the `thread.workspace` operation, a `read`, so a denial answers the same 404 as a missing thread. - **It needs managed workspaces** (`sandbox.workspace` or `sandbox.thread`) and a provider whose managed workspaces can be read. `dockerSandbox` can. - **Limits.** `maxEntries` up to 10,000, `maxFileBytes` up to 16 MiB, `maxTotalBytes` up to 32 MiB; the defaults are `inspectWorkspace`'s. The request body is at most 64 KiB (`413 payload_too_large`). The answer is one JSON document of at most 64 MiB serialized: JSON escapes a control character to six bytes, so text within the byte limits can still be refused (`422 workspace_response_too_large`). `ignorePrefixes` drops paths from `files`, but they were read, so they count in `totalBytes`, `entries` and the limits. One read may take `sandbox.workspaceReadTimeoutMs` (default 120,000 ms), open to close; past it the read is abandoned, the run slot freed, and the answer is `504 workspace_read_timeout`. - **Refusals are named.** `404 workspace_not_found` (the thread has not run), `404 workspace_lost`, `410 workspace_expired`, `409 workspace_not_ready`, `409 workspace_conflict` (the workspace's record belongs to another installation), `409 workspace_changed` (the workspace changed while it was read; retry), `409 read_cancelled` (cancelled through `POST /threads/:thread_id/cancel`), `422 workspace_root_missing` with `root` and `kind`, `422 workspace_inspection_refused` (the workspace holds something the inspection does not admit: an executable, binary or non-UTF-8 file, a file or the whole tree over its byte limit, more entries than `maxEntries`, or an unexpected link), `400 invalid_request`, `503 workspace_unavailable` (the provider could not be reached; retry), `503 shutting_down`, `504 workspace_read_timeout`. - **The root is checked twice, not held.** Every segment of `root` is lstat'ed before the read and again after it, and a symlink or non-directory at either check is refused. A process in the thread's session that swaps a segment for a link and back between the two checks is not detected, because each read resolves its path afresh; what it could reach is bounded by the reader, a read-only, networkless container over the workspace's own volume. - **Check what you get.** The answer carries the thread's recorded `sourceDigest`. Pass the digest you expect as `expectedSourceDigest` and the client refuses an answer about another workspace; it also refuses an answer for another thread id. #### Handing a thread its workspace at creation A creator on another host can give a new thread its exact workspace without a shared filesystem. Turn it on beside a resolver: ```ts export default config({ sandbox: { provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }), stagedWorkspaces: true, thread: async (thread) => { if (!thread.staged) throw new Error("create threads with a staged workspace") return { workspace: thread.staged } }, }, }) ``` The creator uploads the files as a `SourceBundle` (from `captureWorkspaceDefinition` or `createSourceBundle` in `@b4run/workspace/node`), then names them when it creates the thread: ```bash curl -X PUT "$WORKER/workspace/sources/$DIGEST" -H "authorization: Bearer $TOKEN" --data-binary @source.json curl -X POST "$WORKER/threads" -H "authorization: Bearer $TOKEN" \ -d '{"metadata":{},"workspace":{"sourceDigest":"'$DIGEST'","environmentLinks":[],"baseline":"git"}}' ``` - **Verified, then recorded.** The upload is checked byte for byte against the digest in its path, and kept under that digest only. A create may name only a source that was uploaded: one the worker stored for another thread's admission is not offered, though its digest is public, and answers `422 workspace_source_not_held` exactly as an unknown digest does. A create is also refused, before any thread exists, when the named links cannot sit beside the upload's files (`422 workspace_invalid`), checked against the file paths recorded at upload without reading the bytes again. The thread's reference is kept in the worker's installation, never in client-visible metadata. At the thread's first run the bytes are verified again and the resolver receives the definition as `thread.staged`; what it returns is recorded by digest and never resolved again. - **Either resolver.** A `sandbox.thread` resolver returns it as `workspace` beside its image, policy and permissions, and B4.run keeps the thread's sandbox record as it does for any other thread. A function `sandbox.workspace` returns it as is. A resolver may also refuse it, or ignore it: the staged workspace is the creator's choice, like metadata. - **Returning it as is trusts the creator with more than files.** `thread.staged.environmentLinks` are symlinks the creator chose, to any absolute path in the sandbox image (`/opt/deps`, but equally `/etc` or a mounted secret), and `baseline: "git"` asks for a Git baseline. B4.run checks that links are well formed and do not collide with the files; it does not know which targets your image makes safe. Unless every creator may choose them, have the resolver check `environmentLinks` against the targets your image provides and `baseline` against what you expect, and refuse anything else. - **Refused, not ignored.** A `workspace` sent to an app without `stagedWorkspaces` is `400 workspace_not_accepted`, after the policy has allowed the create. - **It needs a thread-access policy, and turning it on means auditing your `create` handler.** An upload arrives as `workspace.source.put`, a `create` with no thread; both it and a create that names a workspace carry `requestedWorkspace`, so one rule, `if (req.requestedWorkspace)`, decides who may stage and choose a workspace. A `create` handler written before this option admits both unless it checks that field. A stamp returned on an upload is kept as its uploader, and a create sees every uploader of the source it names in `requestedWorkspace.uploadedBy`, so a policy can require that a caller choose only what it uploaded (see [Workspace endpoints](https://b4.run/docs/thread-access#workspace-endpoints)). The upload route asks the policy before it reads a byte of the body, so an unauthorized caller neither costs the worker memory nor learns whether the option is on. `b4 check`, `b4 build` and boot refuse the option without a policy. - **Limits.** An upload is at most `maxUploadBytes` (default and ceiling 96 MiB, `413 payload_too_large`), and must arrive within `uploadTimeoutMs` (default 120,000 ms; `408 upload_timeout`). All uploaded sources together are at most `maxStagedBytes` (default 1 GiB; `507 staged_quota_exceeded`). The quota is one per worker, not per uploader: uploads are content-addressed and shared by everyone who uploads the same bytes, so a caller allowed to upload can fill it until the retention window frees space. Sources a thread's admission stored, and not uploaded, do not count. In an app with the option, `POST /threads` bodies are at most 1 MiB; a larger one is answered by the policy first, so a caller it refuses sees its refusal, not the limit. An app without the option reads create bodies as before. - **Retention.** An uploaded source nothing references (no thread names it, no live workspace was made from it) is deleted once older than `retentionMs` (default 24 hours); a source that was never uploaded (one left behind by an admission whose thread is gone) is deleted at once when nothing references it. Both happen when the worker starts and before each upload; a re-upload refreshes the window. A thread's reference goes when the thread is deleted, before its row, so a failed delete leaves a thread with no staged workspace rather than one that could pass it on. A create racing a reclaim of its source answers `409 workspace_source_not_held` and leaves no thread behind: upload again and retry. - **Cost.** Verifying and storing an upload holds it several times over in the worker's memory (the body as text, the parsed bundle, its canonical JSON: about four to five times its size at the peak), so a worker takes one upload at a time and answers `429 upload_in_flight` (with `retry-after`) to a second. Lower `maxUploadBytes` on a small worker. A create that names a workspace reads no source bytes (it checks the upload's recorded file paths), and at most four run at once (`429 workspace_create_in_flight`). The bytes are read and verified once more at the thread's first run, which costs about as much as the upload did. - **Cross-origin browsers.** `server.cors.methods` does not include `PUT` by default; add it for a browser client on another origin. #### 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[] }> openWorkspaceReader?(input: OpenWorkspaceReaderInput): Promise } ``` Let's quickly review: - `acquire()` must be idempotent for a thread. - `release()` drops warm compute and keeps workspace data. `destroy()` removes both. - `preflight()` can fail a configuration before runtime, or return `warnings` when the provider can't prove a requested control. - `openWorkspaceReader()` is optional. Implement it only if your backend can attach a thread's workspace storage a second time. It must not create, replace, start or stop the thread's sandbox, it must make writes impossible, and it must reject when the thread has no workspace storage. An empty view would tell a verifier "produced nothing" when the real answer is "does not exist". 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 and reattach idempotency, per-thread isolation, release-versus-destroy storage semantics, and numeric command exit codes. If the provider implements `openWorkspaceReader`, it also checks that a reader sees what the thread produced, exposes no write or shell surface, rejects a thread with no storage, and leaves the thread's sandbox readable, writable and executable afterwards. Providers without the capability skip that block. #### Testing your agent Use `fakeSandbox()` for deterministic tests that need the provider contract without Docker: `b4.config.ts (test)`: ```ts 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 real 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. Setup is in [Kubernetes Sandbox](https://b4.run/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 set. Only a B4.run runtime entry (`b4 dev`, `b4 start` or the Node build target) constructs the configured sandbox. LangSmith platform artifacts run without it. Edge targets reject incompatible filesystem and sandbox capabilities, so workspace tools never run there without isolation. See [Deployment Options](https://b4.run/docs/deployment). #### What it is and isn't **It is** a filesystem and process boundary keyed by the provider's derived thread resource name. It also gives you explicit environment injection, CPU and memory controls, provider-specific network policy, a workspace lifecycle that outlives warm compute, and hardened Docker defaults: 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 are separate controls. Allow-mode network is open egress, since the reference providers do not enforce `denylist`, and container isolation is weaker than a microVM. For hostile multi-tenant workloads, implement a stronger provider behind the same `SandboxProvider` interface and validate its controls against your threat model. Kubernetes maps the same policy to different mechanisms and limitations. Treat [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes) as the source of truth for that provider. #### Related - [Workspace Filesystem](https://b4.run/docs/workspace): the pluggable backends a sandbox handle satisfies - [Permissions](https://b4.run/docs/permissions): approval policy for workspace and shell calls - [Tools](https://b4.run/docs/tools): which tools an agent may call - [Configuration Reference](https://b4.run/docs/configuration): the sandbox configuration shape - [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes): provider and cluster operations --- ### Kubernetes Sandbox Source: https://b4.run/docs/sandbox/kubernetes Use `kubernetesSandbox` when the B4.run runtime already runs in Kubernetes and each Agent Protocol thread needs an isolated Pod-backed workspace. It isolates each thread in a container, not a microVM. Before production, you need a dynamic volume provisioner, a compatible sandbox image and enough Pod and PVC quota. If NetworkPolicy is part of your security boundary, you also need a policy-enforcing CNI. The application process needs Kubernetes API credentials to create and exec into sandbox workloads. The sandbox Pods don't: B4.run sets `automountServiceAccountToken: false` on every provider-managed Pod. #### Install the sandbox infrastructure The `b4-sandbox-infra` chart creates the target namespace, the orchestrator ServiceAccount and RBAC, a default-deny egress backstop, resource controls, Pod Security Standard labels and a PVC reaper. Keep its Helm release, the B4.run app and application credentials in the separate `b4-app` management namespace. Sandbox resources live in `b4-sandboxes`. For a fresh release, prepare a values file that lists every planned application ServiceAccount. For an existing release, export its effective values first. Either way, `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](https://b4.run/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` defaults to `b4-sandboxes`. Review the values before you change namespace ownership or disable a control. #### Configure the provider Point the provider at the namespace the infrastructure chart manages: `b4.config.ts`: ```ts import { config } from "@b4run/cli" import { kubernetesSandbox } from "@b4run/sandbox" export default config({ sandbox: { provider: kubernetesSandbox({ scope: "my-app", image: "node:24-slim", namespace: "b4-sandboxes", }), network: { mode: "deny" }, resources: { memoryMb: 512, cpus: 1, diskGb: 2 }, }, }) ``` `storageClass` and `startupTimeoutMs` are optional. `resources.diskGb` becomes the PVC storage request, so confirm the selected StorageClass can provision it. `scope` is required and identifies the application and environment within the provider. The provider hashes scope and the exact logical thread ID into stable resource names, including NetworkPolicy selectors. Keep scope stable across deployments. Different scopes address different resources in the same namespace, but they do not replace tenant authorization, RBAC, or namespace trust boundaries. Sharing scope also does not coordinate concurrent replicas. See [Application scope](https://b4.run/docs/sandbox#application-scope) for the breaking storage-name change. 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`. The image doesn't need an `/etc/passwd` entry, but its executables and filesystem permissions must work for that identity. When they don't, set a compatible UID/GID through `security.runAsNonRoot`. #### Wire application RBAC The app Pod's ServiceAccount must be a subject of the infrastructure chart's `b4-orchestrator` RoleBinding. That Role covers only the provider's Pod, Pod exec, PVC and NetworkPolicy operations, with no Secret access. Add the management-namespace app ServiceAccount to the complete `orchestrator.subjects` list before you deploy the app, so the app is never running without authorization. Follow [Kubernetes deployment](https://b4.run/docs/deployment/kubernetes) for the ordered RoleBinding and application-chart commands. Keep these three values aligned: - `b4-sandbox-infra`'s `namespace.name` - `kubernetesSandbox({ scope: "my-app", 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 go away and their PVCs remain unreferenced. With the chart reaper enabled, that storage has a limited life. The reaper has no B4.run thread metadata and considers every unreferenced B4.run PVC eligible. The reaper runs hourly by default. Each run does two things: - It marks an unreferenced PVC that has no valid marker. - It 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. So a short reattachment entirely between scheduled runs can leave an old marker in place, and 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. If you need thread workspaces to survive longer, 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 you can tell storage-retention failures apart 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`. So deny mode is **not zero egress**: DNS remains allowed, along with the DNS-tunneling risk that comes with it. 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. Test DNS and policy behavior on your 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 these failures shows up 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 does not create a claim PVC. 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 version, and the context must exactly match the current context. The command never switches 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](https://b4.run/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 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](https://b4.run/docs/sandbox): the portable provider contract, Docker reference behavior and custom-provider testing. --- ### Deployment Options Source: https://b4.run/docs/deployment Use the **Node target** unless a platform constraint says otherwise. Choose LangSmith when that platform must run the LangGraph deployment. Choose Hono only when you need a web-standard edge runtime and your app fits its capability gate. Choose Vercel when you host on Vercel's Node 24 Fluid runtime and your app fits the same capability subset. #### Choose a target ##### Node - **Artifacts:** `.b4/build/server.mjs` and a generated `Dockerfile` - **Runtime:** the B4.run HTTP runtime (Agent Protocol, AG-UI and middleware) - **Storage:** local defaults, or injected shared stores - **Filesystem and sandbox:** both available - **Requires:** Node 24+ - **Tested by:** local runtime and container tests. Validate your proxy, stores and model path. ##### LangSmith - **Artifacts:** `.b4/build/langgraph.json` and one entry per route - **Runtime:** LangGraph entries run by the platform, without B4.run's HTTP server or middleware - **Storage:** provided by the platform - **Filesystem and sandbox:** no B4.run sandbox manager or HTTP filesystem surface - **Requires:** the generated config currently says Node 22, while B4.run requires Node 24+ - **Tested by:** you, on the platform ##### Hono - **Artifacts:** `.b4/build/modules.edge.mjs`, `stores.mjs`, `app.mjs` and `wrangler.toml` - **Runtime:** the web-standard B4.run HTTP runtime (Agent Protocol, AG-UI and middleware found at build time) - **Storage:** generated request-scoped Postgres stores, or stores you compose by hand - **Filesystem and sandbox:** filesystem, shell, workspace, long-term memory and sandbox surfaces are gated - **Requires:** no Node runtime for the emitted worker - **Tested by:** local workerd and Node round trips, which don't prove a live provider deployment ##### Vercel - **Artifacts:** a `.vercel/output/` Build Output API tree (the runtime function, plus any `static/`, functions and routes from `build.vercel`) and a reconciled `vercel.json` - **Runtime:** the same web-standard runtime as Hono, on Vercel's Node 24 Fluid runtime - **Storage:** generated request-scoped Postgres stores over `DATABASE_URL` - **Filesystem and sandbox:** the same gate as Hono - **Requires:** the Vercel `nodejs24.x` function runtime - **Tested by:** a gated CI lane that deploys real previews. Validate your project's auth, regions and provider path. Node and LangSmith are the defaults, and Hono and Vercel are opt-in. Specifying build targets replaces the defaults. It doesn't add to them: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["node", "hono"] }, }) ``` #### Validate and build Run the checks in this order, so problems with the app show up before you inspect artifacts: ```bash b4 check b4 build b4 verify ``` `b4 check` validates the authored app and the selected targets' constraints. `b4 build` emits the selected artifacts. `b4 verify` repeats the app, route, typegen, dependency, environment and runtime-readiness phases against the resulting project. Green commands are a good start, but you still need to test the deployed authentication, routing, storage and provider path. #### Target guides - [Node and Docker](https://b4.run/docs/deployment/node): Build and run the full B4.run HTTP runtime. - [Kubernetes](https://b4.run/docs/deployment/kubernetes): Install a built Node image with the b4-app chart. - [LangSmith](https://b4.run/docs/deployment/langsmith): Deploy generated LangGraph entries with platform-owned authentication. - [Edge and Hono](https://b4.run/docs/deployment/edge): Assess the edge capability gate and compose the fetch runtime. - [Vercel](https://b4.run/docs/deployment/vercel): Emit a Build Output API function and deploy it prebuilt from CI. #### Production foundations Packaging leaves data durability, replica coordination and outer security to you. Review these before you expose any target: Related: - [Production Topology](https://b4.run/docs/production-topology): Process-local coordination, health probes, and replica requirements. - [Persistence and Tenancy](https://b4.run/docs/persistence): Choose durable stores, ownership, retention, and tenant isolation. - [Security Architecture](https://b4.run/docs/security-architecture): Place authentication and authorization around the whole service. #### What B4.run does not do B4.run emits deployment artifacts. Provisioning infrastructure, hosting the app and managing secrets are up to your target platform. #### Self-hosting Use [Node and Docker](https://b4.run/docs/deployment/node) 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 can't import a package, declare every package the emitted artifacts import where the host installs or bundles production dependencies. - If local protocol tests pass but the target fails, test the target's real transport, authentication layer, environment injection and durable stores. Passing Node or Hono HTTP tests tell you nothing about LangSmith, and a local workerd run tells you nothing about a live edge provider. #### Related See [Production Foundations](#production-foundations) before you expose a deployment. --- ### Node and Docker Source: https://b4.run/docs/deployment/node I recommend the Node target for production. It runs B4.run's complete HTTP runtime, so it's the one to choose when you need Agent Protocol, AG-UI, middleware, filesystem-backed capabilities or an execution sandbox. #### Prerequisites - Use Node 24 or newer. B4.run packages require it. - Keep `@b4run/cli` in production `dependencies`, because the emitted server imports it at runtime. - Run `b4 build` on the host before you build an image. The generated Dockerfile copies `.b4/build` into the image and runs the server from there. - Put authentication, tenant authorization and network restriction around the full service before you expose it. For an npm-managed app, make the CLI a production dependency like this: ```bash npm install @b4run/cli --save ``` #### Select the Node target B4.run emits Node and LangSmith when `build.targets` is absent. To build only Node, list it: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["node"] }, }) ``` #### Emitted files `b4 build` writes: - `.b4/build/modules.mjs` holds static imports for the discovered routes, tools, state, route memory and middleware. - `.b4/build/server.mjs` loads that manifest and calls `serveRuntime()` on the production listener. - `Dockerfile` is a marker-managed `node:24-slim` image definition. If the root `Dockerfile` still carries B4.run's generated marker, the next build refreshes it. If it has no marker, B4.run leaves it alone and writes the generated version 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 your existing file. Keep `.b4/build` in the context, because the generated image runs its `server.mjs`: `.dockerignore`: ```text .env .env.* .git .github node_modules **/node_modules coverage *.log .DS_Store ``` Now emit a fresh `.b4/build` on the host and build the image: ```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 fine for a local smoke test. In production, put the service behind an authenticated proxy or network policy. The generated Dockerfile uses `COPY . .`, so every unignored file in the build context can end up in an image layer. That includes a secret that a later Dockerfile instruction deletes. `docker run --env-file .env` injects runtime values, but it does not undo a secret already baked into an image layer. Review the final context and image history in CI. Pass production secrets through the runtime or orchestrator, and keep them out of the build context. The generated image runs `npm ci --omit=dev || npm install --omit=dev`. Its copy step tolerates an optional pnpm lockfile, but the install always uses npm. If your production install needs pnpm, Yarn or stricter lockfile behavior, write your own Dockerfile. The image's command is `node .b4/build/server.mjs`, so don't exclude `.b4/build` in `.dockerignore`. > **Warning: The workspace is read-only in the generated image** > > 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 you need local workspace writes, write your own Dockerfile that adjusts ownership (for example, `COPY --chown=1000:1000 . .` or an explicit `chown` for `/app/workspace`). Then check that the result is still least-privilege. #### Environment and secrets Pass model credentials, database URLs and provider configuration at runtime. For local Docker smoke tests, `--env-file .env` is convenient. In production, use the orchestrator's secret mechanism, and keep secret files out of the image. `src/middleware.ts` is execution middleware. It isn't service-wide authentication: health, thread management and state routes, cancellation and memory-candidate management include paths that bypass it. Your outer authentication and tenant checks must cover the whole service, exempting only probes you mean to make public. #### Filesystem and sandbox The Node runtime can use local `.b4` stores and filesystem-backed capabilities, and it can build the configured [execution sandbox](https://b4.run/docs/sandbox). Each of these persists separately: - `.b4` inside a container stays local to that container, unless the deployment mounts durable storage there or swaps in shared stores. - Sandbox workspaces follow the selected sandbox provider's volume lifecycle. - Shared Postgres checkpoints, threads and permissions leave long-term memory and workspace files local. Those need their own shared stores. Choose those stores in [Persistence and Tenancy](https://b4.run/docs/persistence), and coordinate replicas as described in [Production Topology](https://b4.run/docs/production-topology). #### Health and shutdown `GET /healthz` returns a process-level success response. Treat it as **liveness**, not dependency readiness. The handler never queries the model, database or sandbox provider. In an embedded fetch/Hono assembly with `requestStores`, stores are built before route dispatch, so even a health request can fail before the handler runs. That still isn't a complete dependency check. The generated `.b4/build/server.mjs` does not currently install signal handlers. `serveRuntime()` handles signals only when a caller opts in with `installSignalHandlers: true`. A larger host can handle signals itself and call `await runtime.close()` in its shutdown path. Plan rollout grace periods and request draining around this. #### 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 building the image. 3. Test the built image on loopback, including Agent Protocol and AG-UI streaming. 4. Inject secrets at runtime, and authenticate and restrict the whole service. 5. Configure durable stores, backups and retention for every kind of state 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 - **The container can't 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`. - **Your custom Dockerfile wasn't replaced:** B4.run keeps unmarked root files. Look in `.b4/build/Dockerfile` for the generated one. - **The health probe is green but requests fail:** test the actual database, model, sandbox, authentication and route path. `/healthz` checks none of them. - **Cancellation or the one-run gate behaves inconsistently across replicas:** route each thread to the process running it, or add distributed serialization and cancel routing. #### Related - [Kubernetes](https://b4.run/docs/deployment/kubernetes): Install the built image with the b4-app chart. - [Persistence and Tenancy](https://b4.run/docs/persistence): Select and operate every durable store. - [Production Topology](https://b4.run/docs/production-topology): Design probes, draining, and replica coordination. - [Security Architecture](https://b4.run/docs/security-architecture): Protect the full HTTP surface. --- ### Kubernetes Source: https://b4.run/docs/deployment/kubernetes To deploy on Kubernetes, build the Node target image first, then install the `b4-app` chart. The chart runs an image you've already built, and it reads nothing from `b4.config.ts`. #### Prerequisites Build and publish a Node image as described in [Node and Docker](https://b4.run/docs/deployment/node). Add the secret-safe `.dockerignore` from that guide first, and 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. Sandbox resources live in `b4-sandboxes`. Before the first install, prepare the complete intended cross-namespace subject list: `b4-sandbox-infra-values.yaml`: ```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 ``` You need both charts: the application chart runs the app, and the sandbox infrastructure chart sets up the sandbox side. See [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes) for the provider and cluster-side security setup. #### 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 there is process **liveness**, not dependency readiness. It touches no store and never queries the model, Postgres or the sandbox provider. The runtime also serves `GET /readyz`. It probes the durable stores (threads, checkpointer, permissions) and answers `503` naming each failing dependency. The chart uses one path for all three probes. So point `healthPath` at `/readyz` only if you want every probe, liveness included, to fail when the database is unreachable. Neither endpoint checks the model or sandbox provider, so add your own check if rollout safety needs one. Some stores are built while the Node runtime starts up, so a store failure at boot can stop the listener from starting at all. You'll see that as a startup failure, before `/healthz` ever answers. #### Environment and secrets Set ordinary environment entries through `env` or `envFrom`. For an existing Kubernetes Secret, use `secretName`: ```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, and you create it. Put model credentials and `DATABASE_URL` in an operator-managed Secret, and apply the tenant and authentication controls described in [Security Architecture](https://b4.run/docs/security-architecture). Shared Postgres stores and local `.b4` files have different lifetimes. Configure each kind of state with [Persistence and Tenancy](https://b4.run/docs/persistence) before you rely on restarts or replicas. #### Filesystem durability The `b4-app` chart mounts only an `emptyDir` at `/tmp`. The app's `.b4` directory lives in the container, so local SQLite checkpoints, threads, permissions and other `.b4` data are lost when a Pod is replaced. Use external durable stores for any state that must survive replacement. Sandbox-provider workspaces have their own volume lifecycle, separate from the app Pod's `/tmp` mount. #### ServiceAccount setup 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 chart's default ServiceAccount in the management namespace. Add that planned ServiceAccount as a cross-namespace subject of the sandbox infrastructure chart's orchestrator Role **before** you install the application. A RoleBinding subject is a name reference, so it can refer to a future ServiceAccount. Granting access first avoids a Ready-but-sandbox-broken window, where the app Pod is running but its 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. If the release has no extra subjects yet, that section looks like this: `b4-sandbox-infra-rbac-values.yaml`: ```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. `sandboxNamespace` is informational. Changing it alone leaves RBAC and sandbox Pod placement as they were. An app without `kubernetesSandbox` needs no Kubernetes API credentials. Install it with its own ServiceAccount and turn off token mounting: ```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 app never calls the Kubernetes API through `kubernetesSandbox`. If the app needs some unrelated Kubernetes API permission, grant it separately. Keep the sandbox orchestrator Role for sandbox work. #### 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 one-run-per-thread gate and the cancellation registry stay process-local. So more than one replica requires guaranteed thread-aware routing to one owning process, or distributed per-thread serialization plus cancel routing. An HPA changes the replica count and a PodDisruptionBudget limits disruption. Neither one coordinates B4.run runs or sends a cancel request to the right process. Keep the one-replica default until you have that coordination. See [Production Topology](https://b4.run/docs/production-topology) for request routing, cancellation and failure-mode design. #### Rollouts and shutdown The generated Node `server.mjs` leaves `serveRuntime` signal handlers off. The runtime has a drainable `close()` path, but the generated entry doesn't currently call it on `SIGTERM`. Set realistic termination grace periods, test streaming requests through a rollout, and write your own Node entry when you need coordinated signal handling. #### 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 you install. The chart exposes image, probe, ServiceAccount, environment, ingress, autoscaling, disruption-budget and security-context values. Meeting B4.run's replica and persistence requirements is still up to you. Related: - [Node and Docker](https://b4.run/docs/deployment/node): Build the image consumed by this chart. - [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes): Configure the Kubernetes sandbox provider and infrastructure. - [Persistence and Tenancy](https://b4.run/docs/persistence): Externalize every kind of state that must survive replacement. - [Production Topology](https://b4.run/docs/production-topology): Design thread-aware routing and cancel routing. --- ### Vercel Source: https://b4.run/docs/deployment/vercel The Vercel target deploys your app to Vercel's Node 24 runtime on Fluid compute. It emits a Build Output API tree under `.vercel/output/`, and `vercel deploy --prebuilt` uploads that tree as-is. It serves the same edge subset of B4.run as the [Hono](https://b4.run/docs/deployment/edge) target. That means no sandbox or workspace tooling, and the stores are durable Postgres rather than local SQLite. If you need a full filesystem or an execution sandbox, use [Node and Docker](https://b4.run/docs/deployment/node). #### Fit check We test the emitted function three ways: - We inspect the Build Output API tree structurally. - We import the bundled function and drive it with Web `Request` objects in a Node 24 process. - A gated `vercel-native` CI lane creates real preview deployments through the pinned Vercel CLI (`58.9.0`), drives stateful and streaming Agent Protocol traffic against them, and removes them. That lane proves one source-built and one prebuilt deployment on a hosted Neon database. Test your own project's authentication, regions, quotas and model-provider path. Before you choose the target, confirm all of these: - The app can run without sandbox, workspace, shell, route long-term memory and tool-output offloading. - A Postgres pool opened and disposed per request fits your database environment. - Every authored route, tool, state module, middleware module and transitive dependency bundles into a single self-contained function directory. - You'll test the deployed project itself, rather than trusting a green build. `b4 check` applies the same `B4_E1005` capability gate to `vercel` as to `hono`. Every gate in [What the edge cannot serve](https://b4.run/docs/deployment/edge#what-the-edge-cannot-serve) applies unchanged. #### Select the target `vercel` is opt-in. Setting `build.targets` replaces the Node and LangSmith defaults, so list every target you still want: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["node", "vercel"] }, }) ``` Declare the packages the emitted function imports. The build prints a notice when any of `@b4run/cli`, `@b4run/postgres-storage`, `@neondatabase/serverless` or `hono` is missing from the app's `package.json`. Add them to `dependencies` so the bundler can resolve them: ```bash npm install @b4run/postgres-storage @neondatabase/serverless hono ``` The generated stores also import `@b4run/postgres-storage/node`, which brings in `pg` as its own dependency. You don't need to declare `pg` yourself. Then validate and build: ```bash b4 check b4 build b4 verify ``` The generated app templates already ignore `.vercel/`. In an existing app, add it to `.gitignore`. `vercel.json` stays at the root and is committed. #### Emitted artifacts The target writes one Build Output API tree at `.vercel/output/` and reconciles one root `vercel.json`. A bare build (no `build.vercel`) emits these files: - **`.vercel/output/config.json`** is Build Output API `version: 3` with a single catch-all route, `{ "src": "/(.*)", "dest": "/b4" }`, that sends every path to the function. - **`.vercel/output/functions/b4.func/.vc-config.json`** is the Node function metadata: `handler: "index.mjs"`, `launcherType: "Nodejs"`, `runtime: "nodejs24.x"`, `supportsResponseStreaming: true`. - **`.vercel/output/functions/b4.func/index.mjs`** is the bundled function. It's a Hono catch-all around `createRuntimeFetchHandler`, with the static route manifest, the per-request Postgres store factory, every model-provider package and the app's serialized config inlined. - **`vercel.json`** is the root project configuration, created when missing. An existing file is never modified. When it lacks a required setting, a reference copy goes to `.b4/build/vercel.json`. It isn't emitted when reconciliation is [turned off](#opt-out-for-a-prebuilt-only-project). [`build.vercel`](#compose-with-a-frontend) adds to that tree: a `static/` directory, a `functions/.func/` per declared function, and the routes that order them. The runtime function keeps the same `b4.func` name it has in a bare build. The function directory is the whole deployable. `b4 build` bundles the generated runtime with esbuild for `node24`, then verifies that: - every non-builtin import resolves inside `b4.func` - no symlink escapes it - the bundle contains no `node:module` loader or non-literal dynamic import If a dependency can't be bundled, the build fails and names the missing specifier. Install it as a runtime dependency and rebuild. The output is published atomically. The tree is staged under `.vercel/.b4-vercel-/`, validated, then renamed over `.vercel/output`. A prior output is backed up first and restored if publication fails. The rest of `.vercel/` (for example the `project.json` that `vercel link` writes) is left alone. Vercel deploys from `.vercel/output`, so that's the default. To publish the tree somewhere else, set `build.vercel.outDir` in `b4.config.ts`, or pass `b4 build --out-dir

` for one run. The flag wins over the config value. The path resolves relative to the app root. A directory that would contain the app root is rejected before anything is written, and `--out-dir` is an error when `vercel` isn't a configured target. Point it at a staging directory when a later step, rather than `build.vercel`, combines the function with static assets before deploying. Unlike the Hono target, this target bundles the intermediate `modules.edge.mjs`, `stores.mjs` and `app.mjs` into `index.mjs` and removes the staging directory. To see what shipped, inspect the bundle itself. #### `vercel.json` reconciliation Unless you [turn it off](#opt-out-for-a-prebuilt-only-project), the target requires two settings in the root `vercel.json`: `vercel.json`: ```json { "$schema": "https://openapi.vercel.sh/vercel.json", "buildCommand": "node node_modules/@b4run/cli/dist/index.js build", "fluid": true } ``` - `buildCommand` makes a Git-connected project, or a source `vercel deploy`, run `b4 build` on Vercel. The remote build then produces the same `.vercel/output` tree. The explicit `node` entry sidesteps package-manager-specific binary shims. - `fluid: true` is required, because the generated stores assume one function instance can serve several requests. The file value applies to old and new projects alike, whatever the Dashboard defaults are. The reconciliation rules are: 1. **No root file:** the target creates it with the content above and reports it as an artifact. 2. **Root file has both settings:** the file is left untouched. `buildCommand` counts when, after collapsing whitespace, it equals the command above. 3. **Root file is missing a setting:** the file is yours, so it stays as it is. The recommended content is written to `.b4/build/vercel.json`, and the build prints a warning naming both files and the missing setting. The build still succeeds. 4. **`fluid: false`:** the build fails. The setting conflicts with the supported lifecycle. 5. **Invalid JSON:** the build fails with the parse error, because Vercel cannot consume the file either. Extra settings in the root file are preserved and remain authoritative. ##### Opt out for a prebuilt-only project A prebuilt flow never runs `buildCommand`, so a file that only satisfies the reconciler is dead weight. Turn reconciliation off: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["vercel"], vercel: { reconcileVercelJson: false }, }, }) ``` `b4 build` then ignores `vercel.json` entirely, drops it from the artifact list, and no longer fails on a committed `fluid: false`. The build prints a line saying reconciliation is off. Reconciliation stays on unless the flag is `false`. A near miss fails with `B4_E1003`, so it can't quietly leave reconciliation on. These all count as near misses: - a non-boolean value - a non-object `build.vercel` - an unknown key inside `build.vercel` - `reconcileVercelJson` placed directly on `build`, outside `build.vercel` The function is still built for Fluid compute. With the flag off, nothing in your source enforces that, so enable Fluid in the Vercel project settings. #### `DATABASE_URL` and Neon The function builds its checkpointer, thread store and permission store per request from `DATABASE_URL`, read through `process.env` at invocation time. A missing value fails the request with a message naming the variable. The URL is read at runtime, so the same artifact deploys to preview and production with different databases. The easiest setup is the [Neon integration on the Vercel Marketplace](https://vercel.com/marketplace/neon), also on the project's **Storage** tab. It creates a Neon account if you need one, provisions a Neon project, and adds `DATABASE_URL` (the pooled connection string) plus `DATABASE_URL_UNPOOLED` and the `PG*` components to the selected environments. The generated stores read only `DATABASE_URL`. Any other Postgres works too. For a non-Neon host, the stores pick a pooled `pg` connection automatically. Set the variable under **Settings → Environment Variables** and redeploy, because runtime variables are captured per deployment. Model-provider keys and base URLs (`OPENAI_API_KEY`, `OPENAI_BASE_URL`, and so on) are read the same way. Keep secret literals out of `b4.config.ts`, because the JSON-representable part of that file is inlined into the bundle. The first request in each function instance runs the store migrations under a Postgres advisory lock. Later requests in that instance skip them. ##### Keep preview and production apart By default, the stores use the `public` schema and the `b4` table prefix, so every deployment of the project writes the same `public.b4_*` tables. On Vercel, that means preview deployments share production's threads and checkpoints. `B4_PG_SCHEMA` takes a lowercase identifier or a `$NAME` reference to another variable, so one project environment variable separates them: ```bash B4_PG_SCHEMA=$VERCEL_ENV ``` Vercel sets `VERCEL_ENV` on every deployment, so previews migrate and write `preview.b4_*` while production gets `production.b4_*`. `B4_PG_TABLE_PREFIX` works the same way for the table prefix. The migration pass creates the schema when it's missing, and each instance tracks its cold-start migration per database, schema and prefix. An unset value keeps `public.b4_*`. A malformed one fails the request and names the variable, so it never quietly falls back to `public`. See [Namespace preview and production](https://b4.run/docs/deployment/edge#namespace-preview-and-production) for the full table. #### `maxDuration` and streaming The runtime function's `.vc-config.json` carries `handler`, `launcherType`, `runtime` and `supportsResponseStreaming`, plus `maxDuration` when `build.vercel.maxDuration` sets one. The output validator rejects any other key. If you leave `maxDuration` out, the project default applies (**Settings → Functions → Function Max Duration** in the Vercel Dashboard). With Fluid compute that default is 300 seconds on every plan, and Pro and Enterprise projects can raise it to 800 seconds. Set `build.vercel.maxDuration` to keep the ceiling in the repository rather than the dashboard. The runtime function runs the agent, so it's the one a long tool-using run outgrows. Vercel terminates agent runs that stream longer than the effective duration. A function declared under `build.vercel.functions` takes its own `maxDuration` and `supportsResponseStreaming`. Note, a `vercel.json` `functions` block has no effect on a prebuilt Build Output tree. Streaming needs no extra configuration. Agent Protocol `runs/stream` and AG-UI responses are `text/event-stream` bodies. They reach the browser incrementally because the runtime function's `.vc-config.json` always carries `supportsResponseStreaming: true`. Without that flag, Vercel's Node launcher buffers the whole body. A deployed UI then shows nothing until the run finishes, and then everything at once. If you see that, check that the flag survived whatever produced the tree. A function you declare under `build.vercel.functions` has to ask for it. The native lane verifies that a prebuilt deployment delivers events incrementally, with the periodic `: ping` keepalive comments the runtime emits between them. Disconnecting a streaming client only detaches that viewer. The run continues, and the generated stores dispose their pool only after it settles. Use `POST /threads/:thread_id/cancel` to stop work, as described in [Client disconnect](https://b4.run/docs/dev-server/agent-protocol#client-disconnect). #### Compose with a frontend A bare build emits one function behind a catch-all route. That's all a standalone agent service needs. An app that also ships a SPA or its own API routes needs more, and `build.vercel` describes the rest of the tree so `b4 build` alone produces the whole deployable: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["vercel"], vercel: { static: { dir: "../web/dist", spaFallback: "index.html" }, functions: { api: { entry: "src/api.ts", maxDuration: 30, supportsResponseStreaming: true }, }, routes: [{ src: "/api/(.*)", dest: "/api" }], }, }, }) ``` That build publishes `static/` from `static.dir`, a `functions/.func/` for each declared function, and a `config.json` whose routes run in Build Output order: your `routes`, then `{ handle: "filesystem" }`, then the runtime function, then the SPA fallback. Two details matter for a frontend: - **The runtime function is named `b4.func`.** In the Build Output API, a function named `index` is also served at `/`, so it would shadow `static/index.html`. `functionName` overrides the name, but `functionName: "index"` together with `static` fails the build, so you never publish a tree whose root is shadowed. - **The runtime keeps its own rooted paths.** With a SPA fallback, the runtime route is scoped to its own surfaces (`/healthz`, `/readyz`, `/threads`, `/agui`, `/memory`), and every other path falls through to the fallback document. The runtime has no base-path option, so compose by path prefix and leave the request path as it is. `b4 build` validates the composed tree before publishing it: - `config.json` must be version 3 and still route to the runtime function. - Each route must be a shape the Build Output API accepts. - Every `functions/*.func` must be a self-contained Node function whose dependencies resolve inside its own directory. Every path `build.vercel` names is checked before `.vercel/` is touched. A missing `static.dir` or function `entry` fails with the offending `build.vercel.*` key, and you're never left with a half-written tree. ##### Assembling the tree yourself If your frontend build can't be described this way, assemble the tree in your own step, after `b4 build` and before `vercel deploy --prebuilt`. Two things support this: - `build.vercel.outDir` publishes the runtime tree to a staging directory. Your assembler copies from there into the real `.vercel/output`, and never edits the directory Vercel deploys from in place. - The output validator checks only that `config.json` is version 3 and has a route reaching the runtime function. It accepts extra routes and extra top-level keys, so a composed config still validates. The validator runs on the tree the target stages. Your assembler's output comes later, so getting the final `config.json` right is up to you. Or deploy the agent as its own Vercel project and point the frontend at its origin. `server.cors` in `b4.config.ts` controls cross-origin access. #### Prebuilt CI flow A prebuilt deployment uploads `.vercel/output` without a remote build, so CI controls the Node and B4.run versions that produced the artifact: ```bash b4 build vercel deploy --prebuilt --yes ``` `vercel deploy --prebuilt` needs the project linked. In CI, set `VERCEL_TOKEN`, `VERCEL_ORG_ID` and `VERCEL_PROJECT_ID` as secrets, and the CLI skips the `vercel link` step. The committed `vercel.json` is read from the working directory. Runtime environment variables come from the project, not the pipeline. Here's a GitHub Actions workflow: `.github/workflows/deploy.yml`: ```yaml name: Deploy to Vercel on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest env: VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 - run: npm ci - run: npx b4 check - run: npx b4 build - run: npx vercel@58.9.0 deploy --prebuilt --yes --target production ``` Drop `--target production` to create a preview deployment. Pin the CLI version like we do in the B4.run repository, because the prebuilt format is an agreement between your artifact and a specific CLI. A Git-connected project is the alternative. Push a branch, and Vercel runs the committed `buildCommand` on a clean checkout. In that flow you commit neither `.b4/` nor `.vercel/output/`. #### `/healthz` and `/readyz` `GET /healthz` is pure liveness. It builds no stores and answers `200 {"status":"ready"}` whenever the function serves HTTP, even with `DATABASE_URL` unset or the database unreachable. It tells you the function is up, and that's all. `GET /readyz` covers the database. It builds the request's stores and makes a real query against each of threads, checkpointer and permissions, running lazy migrations on the way. It answers `200 {"status":"ready","checks":{…}}` when all three respond, or `503 {"status":"not_ready","checks":{…}}` naming each failing dependency. A `DATABASE_URL` missing from the Vercel project shows up as a failing `requestStores`, because the per-request factory itself threw. Hit `/readyz` after a deploy, and check it when a deployment serves traffic but every run fails. Two caveats carry over from [Production Topology](https://b4.run/docs/production-topology#health-and-readiness): - It skips the model provider and the memory store. - Unlike every other endpoint, it reports the real cause. Hostnames, ports, database names and driver error codes appear in the body (connection-string credentials are redacted). Middleware and thread access gate neither probe, so keep both behind the same outer access controls as the rest of the service. #### Test the bundle locally The function is a default-exported Web Fetch API handler, so it runs under plain Node 24. You don't need a Vercel emulator or a WebSocket proxy. The generated stores pick a driver per request. A `.neon.tech` host uses `@neondatabase/serverless`, and every other host, including a local Postgres, gets a pooled `pg` connection. Point `DATABASE_URL` at a local database and import the bundle: ```bash DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app \ node --input-type=module -e ' const entry = "./.vercel/output/functions/b4.func/index.mjs" const { default: app } = await import(entry) const ready = await app.fetch(new Request("http://localhost/readyz")) console.log(ready.status, await ready.text()) const created = await app.fetch(new Request("http://localhost/threads", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", })) console.log(created.status, await created.text()) ' ``` If `functionName` renames the function or `build.vercel.outDir` moves the tree, adjust the path to match. Setting `B4_PG_DRIVER` to `neon` or `pg` overrides that detection. To rehearse the WebSocket path a Neon deployment really uses, put [`neondatabase/wsproxy`](https://github.com/neondatabase/wsproxy) in front of the database and set `B4_PG_WS_PROXY`. It takes `host:port`, optionally prefixed with `ws://` or `wss://`. Only `wss://` keeps TLS on the socket. A value with a path, a query or any other scheme is rejected by name before a pool opens, because the driver appends its own `/v1?address=` path. Setting `B4_PG_WS_PROXY` alongside `B4_PG_DRIVER=pg` is a configuration error, so one value never quietly wins. Two proxy details are easy to get wrong. The proxy must be reachable at the address you name. And `DATABASE_URL` must name the Postgres host as the proxy sees it, which may differ from how your shell sees it. The proxy dials the database, and the function only dials the proxy. [Database drivers and `B4_PG_WS_PROXY`](https://b4.run/docs/deployment/edge#database-drivers-and-b4_pg_ws_proxy) documents the full selection order and the `hono` differences. `selectPostgresDriver` and `normalizeWsProxy` are exported from `@b4run/cli/fetch` for hand-composed store factories. To point a real client at it, mount the same handler in a Node HTTP server with `@hono/node-server`'s `serve({ fetch: app.fetch })`. #### Deploy checklist 1. Run `b4 check` and resolve every `B4_E1005` violation. 2. Declare `@b4run/cli`, `@b4run/postgres-storage`, `@neondatabase/serverless`, `hono` and every model-provider package in `dependencies`. 3. Commit `vercel.json` with the `buildCommand` and `fluid: true` settings, or set `build.vercel.reconcileVercelJson: false` and enable Fluid in the project settings. 4. Set `DATABASE_URL` and provider credentials in the project's environment variables for each deployment environment, plus `B4_PG_SCHEMA` when preview and production share a database. 5. Set the project's default function max duration to cover your longest streamed run. 6. Put outer authentication and tenant authorization around every rooted B4.run path. 7. Deploy, then check `GET /readyz` and drive `POST /threads`, a streamed run and a second request in the same instance against the deployment URL. Related: - [Edge and Hono](https://b4.run/docs/deployment/edge): The shared capability gate and per-request store model. - [Persistence and Tenancy](https://b4.run/docs/persistence): Understand request-scoped and durable stores. - [Security Architecture](https://b4.run/docs/security-architecture): Protect every rooted endpoint. - [Node and Docker](https://b4.run/docs/deployment/node): Use the full runtime when the edge gate is too tight. --- ### Edge and Hono Source: https://b4.run/docs/deployment/edge The Hono target deploys your app to web-standard runtimes such as Cloudflare Workers. Use it only when your app passes B4.run's edge capability gate and you can live with request-scoped Postgres and no filesystem. If you don't need an edge runtime, I recommend [Node and Docker](https://b4.run/docs/deployment/node). #### Fit check We test the emitted bundle with local workerd and with a Node Hono round trip. That's not a live Cloudflare deployment test. Your provider's limits, bindings, authentication, networking and database path are yours to test. Before you choose Hono, confirm all of these: - The app can run without sandbox, workspace, shell, route long-term memory and tool-output offloading. - Request-scoped WebSocket Postgres through `@neondatabase/serverless` fits your database environment. - The host can pass the same incoming `Request` object through Hono routing. - Every authored route, tool, state module, middleware module and transitive dependency is web-standard and edge-compatible. - You'll test the deployed provider itself, rather than trusting a successful bundle. `B4_E1005` checks only B4.run-known capabilities. Your code and its dependency graph go unchecked, so arbitrary Node built-ins can pass `b4 check` and then fail during bundling or deployment. Bundle and run the real application artifact under the target's conditions. #### Select Hono Hono is opt-in. Setting `build.targets` replaces the Node and LangSmith defaults, so list every target you still want: `b4.config.ts`: ```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: - **`.b4/build/modules.edge.mjs`** is the edge manifest of static routes, tools, state, middleware and capabilities. Its transitive authored import graph is not guaranteed to be free of Node built-ins. - **`.b4/build/stores.mjs`** is the request-scoped factory for the Postgres checkpointer, thread store and permission store. - **`.b4/build/app.mjs`** is a Hono catch-all around `createRuntimeFetchHandler`. - **`wrangler.toml`** is a scaffold whose `main` is `.b4/build/app.mjs`. The emitted runtime imports `@b4run/cli`, `@b4run/postgres-storage`, `@neondatabase/serverless` and `hono`, plus the model-provider packages found at build time. Declare every package the application bundles. The build prints a notice when any of the four runtime packages is missing from `package.json`. The `vercel` variant of `stores.mjs` also imports `@b4run/postgres-storage/node`, which brings in `pg` as its own dependency, so you don't declare `pg` yourself. B4.run writes a root `wrangler.toml` only when there isn't one. An existing file stays as it is, whether it's an earlier scaffold or your own. When you've written your own, the target puts its scaffold in `.b4/build/wrangler.toml` so you can compare them. #### Compose through `@b4run/cli/fetch` `@b4run/cli/fetch` is the web-standard entry point. It has no filesystem discovery or SQLite fallback. So a hand-composed entry must supply everything the generated `app.mjs` does: the edge manifest, serialized config, stores, environment binding and a static model-provider importer. I recommend deploying the generated `.b4/build/app.mjs`. The JavaScript below is only a **lifecycle/composition skeleton** for a host that has to replace that entry. It leaves out the generated static provider importer and the serialized configuration and environment code. Copy those from the generated entry before you deploy your own version: `b4-edge.mjs`: ```js 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 rooted namespace baked into `modules.edge.mjs`, currently `/`. It's an internal identity, not a public URL prefix. If it doesn't match the manifest, the handler's cache and thread identity drift away from the manifest's. The generated `app.mjs` also seeds the static model importer found at build time and the serializable runtime environment. Reproduce both when you replace it. Use static imports, because the edge bundler can't follow a variable dynamic import. #### Compose the Hono router B4.run endpoints are rooted, so mount the generated or hand-built B4.run app at `/`: `host.mjs`: ```js 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)` as written. The generated environment lookup is keyed by the original `Request`, and Hono's request-rebuilding mount helper would replace that object. Mounting under a prefix won't give B4.run a base path either. The runtime serves the rooted `/healthz`, `/readyz`, `/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. If a later request reuses an idle module-scope pool, it can hang until the runtime cancels it. So the generated `stores.mjs` creates one `@neondatabase/serverless` pool and three Postgres stores per request. It disposes the pool only after the response body, and any run that request started, have settled. The incoming `Request` is also the identity that joins Hono's environment binding to B4.run's `requestStores` callback, so 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 still falls short of Node's permission behavior. The generated path skips `permissionsStore.load()`, and it leaves out the resolved mode and the allow/deny rules seeded from config. So persisted interactive grants are never loaded. Wire the request stores yourself when those controls matter. The emitted factory supplies no long-term memory store. Code that reaches for a missing required store fails with `B4_E5301`. It never falls back to local SQLite. The generated stores use the `public` schema and the `b4` table prefix unless the deployment sets the bindings below. That produces `public.b4_*` tables with no application namespace. If separate applications or trust zones share a database, give each its own schema or prefix. You can also wire the stores yourself, passing a unique `schema` or `tablePrefix` consistently to `postgresCheckpointer`, `createPostgresThreadsStore` and `createPostgresPermissionsStore`. Your wiring must still keep the generated path's workerd constraints: - per-request pools and disposal - migration coordination - environment binding from the original `Request` - static model-provider imports - serialized runtime config - permission hydration and policy decisions Leave the generated `stores.mjs` alone, because rebuilds replace it. #### Namespace preview and production Say one database is attached to both the preview and production environments of a Vercel project. Without namespacing, both share one set of `public.b4_*` tables. The generated `stores.mjs` reads two bindings per request, through the same lookup as `DATABASE_URL`, to choose where that request's tables live: | Binding | Store option | Default | |---|---|---| | `B4_PG_SCHEMA` | `schema` | `public` | | `B4_PG_TABLE_PREFIX` | `tablePrefix` | `b4` | Each value is either a lowercase identifier matching `^[a-z_][a-z0-9_]*$` or a `$NAME` reference, which resolves to the value of the variable `NAME` read the same way. The reference form lets one setting serve every environment: ```bash B4_PG_SCHEMA=$VERCEL_ENV ``` A preview deployment then migrates and writes `preview.b4_*`, and a production deployment writes `production.b4_*`. Vercel sets `VERCEL_ENV` on every deployment. `B4_ENV`, or any other variable you set per environment, works the same way: through a binding on Workers, and through the environment on Node or Bun. The migration pass creates the schema if it's missing, and an isolate tracks its cold-start migration per database, schema and prefix. If you leave both bindings unset, the defaults apply and an existing deployment keeps using its `public.b4_*` tables. A bad value fails the request with an error naming the binding. That covers a value that isn't an identifier, a reference to an unset variable, and a reference whose value isn't an identifier. It does not fall back to `public`, because the binding exists to stop a typo from quietly writing production data into the shared default schema. #### Database drivers and `B4_PG_WS_PROXY` The generated `stores.mjs` reads three values per request, from the host's bindings first and the process environment second: - **`DATABASE_URL`** (`hono`, `vercel`) is the Postgres connection string. It's required. - **`B4_PG_WS_PROXY`** (`hono`, `vercel`) names a WebSocket-to-Postgres proxy for `@neondatabase/serverless` to dial in place of the database host. The format is `host:port`, optionally prefixed with `ws://` or `wss://`. `wss://` keeps TLS on the WebSocket, and a bare value or `ws://` turns it off. The driver appends its own `/v1?address=` path, so a value with a path, a query, or another scheme such as `http://` is rejected by name before any pool opens. Empty means unset. - **`B4_PG_DRIVER`** (`vercel` only) is `neon` or `pg`, and overrides the host detection below. `pg` is refused on the `hono` target, where an isolate has no TCP sockets. On the `hono` target every request goes through `@neondatabase/serverless`, because a WebSocket pool is the only durable Postgres path proven on workerd. That driver can't speak plain TCP. So a non-Neon database (a local Postgres included) is reachable only through a WebSocket proxy such as [`neondatabase/wsproxy`](https://github.com/neondatabase/wsproxy), named in `B4_PG_WS_PROXY`. The `vercel` target is a Node function with real TCP, so its `stores.mjs` chooses a driver per request: 1. `B4_PG_DRIVER`, when set. 2. Otherwise `@neondatabase/serverless` when `B4_PG_WS_PROXY` is set, so a working proxy setup keeps working. 3. Otherwise `@neondatabase/serverless` when the `DATABASE_URL` host ends in `.neon.tech`, and a pooled `pg` connection for every other host. Both pools are opened per request and ended when the request settles, and both run the same cold-start migration pass. Setting `B4_PG_WS_PROXY` together with `B4_PG_DRIVER=pg` is a configuration error, reported by name, so one value never quietly wins. ##### Run the Vercel bundle locally The `pg` fallback makes the built function testable offline. Point `DATABASE_URL` at a local Postgres and import the bundle from a small host script, with no proxy in between: ```bash DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app \ node --input-type=module -e ' const { default: app } = await import("./.vercel/output/functions/b4.func/index.mjs") const response = await app.fetch(new Request("http://localhost/readyz")) console.log(response.status, await response.text()) ' ``` The runtime function is `b4.func` for every build, or whatever `functionName` names it. `build.vercel.outDir` moves the whole tree, so adjust the leading path when you set it. A reachable database answers `200` and names each store it probed: ```json {"checks":{"checkpointer":{"status":"ok"},"permissionsStore":{"status":"ok"},"threadsStore":{"status":"ok"}},"status":"ready"} ``` An unreachable one answers `503` and names the failure, with the connection string's credentials redacted: ```json {"checks":{"requestStores":{"error":"connect ECONNREFUSED 127.0.0.1:5432 (ECONNREFUSED)","status":"failed"}},"status":"not_ready"} ``` Use `/readyz` for this check. `/healthz` is process liveness. It never builds the request's stores, so it answers `200` whether or not `DATABASE_URL` resolves. To exercise the WebSocket path a Neon deployment uses, run `neondatabase/wsproxy` in front of the database and set `B4_PG_WS_PROXY=127.0.0.1:`. The `hono` bundle under `@hono/node-server` needs this setup, because it has no `pg` path. The repository's Docker-gated round trips cover both routes. #### What the edge cannot serve `b4 check` and the Hono build report every B4.run-known target violation together as `B4_E1005`. At request time, the fetch guard rejects an unsupported configured runtime, so nothing is dropped quietly. Of course, passing the gate says nothing about Node-only APIs in your own code or its transitive dependencies. | 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 can't be serialized into the build | | Exec backend | `backends.exec` | A live backend object can't be serialized into the build | | 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. The agent rewrites it through `writeFile` and B4.run re-reads it every turn, so a read-only copy would only do half the job. The gates above cover surfaces that would otherwise be quietly replaced or dropped, or fail only on first use. #### Skills, `plan.md`, and route `memory.md` are bundled Route [skills](https://b4.run/docs/skills), a route [`plan.md`](https://b4.run/docs/planning) and a route `memory.md` are static files. For the `hono` and `vercel` targets, `b4 build` reads them and inlines their contents into `modules.edge.mjs` beside their route. At request time they're 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 just as they do under `b4 dev`. Their contents are frozen at build time, so an edit takes effect on the next build. The build caps each bundled marker at 32 KiB for a `SKILL.md` or `memory.md` and 64 KiB for `plan.md` (the last two match the runtime's existing limits). Both `b4 build` and `b4 check` fail with `B4_E1005`, naming every oversized file, before any artifact is written. A manifest might record skill names with no bodies for them, because it was built before this behavior existed or composed by hand. B4.run detects that at boot and fails every request with `B4_E1005`, so the skills never vanish quietly. Rebuild with `b4 build`. A [thread-access policy](https://b4.run/docs/thread-access) works on the edge. `src/thread-access.ts` is bundled into the manifest as a static import and runs just as it does under `b4 dev`. The disk probe happens at build time, so nothing at request time depends on the filesystem. Say you deploy a manifest built before the app had a policy, next to a newer entry point. 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 part of `b4.config.ts` into `.b4/build/app.mjs`. Functions, class instances and store handles are stripped, but ordinary strings stay. So keep secret literals out of build-time config fields, because anyone who can read the bundle can read them. Use host environment variables, Wrangler secrets or provider bindings. For Workers, set the database URL as a secret: ```bash wrangler secret put DATABASE_URL ``` #### The `vercel` target `vercel` emits the same web runtime as `hono`, bundled into one self-contained Vercel [Build Output API v3](https://vercel.com/docs/build-output-api) function, with the same capability gate. Select it the same way (`build: { targets: ["node", "vercel"] }`). The [Vercel guide](https://b4.run/docs/deployment/vercel) covers it in full. The build writes: - **`.vercel/output/config.json`** holds the Build Output routes, `version: 3`. - **`.vercel/output/functions/b4.func/`** is the runtime function: `.vc-config.json` (`nodejs24.x`, `supportsResponseStreaming: true`) and a bundled `index.mjs`. - **`vercel.json`** is the recommended root config (`buildCommand`, `fluid: true`), written only when the app has none. A failed build leaves the previous output in place. Deploy the tree with `vercel deploy --prebuilt`. ##### Static assets, extra functions, and routes Most apps ship a frontend and their own endpoints beside the runtime. Describe them in `build.vercel`, and `b4 build` produces the whole deployable tree: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["vercel"], vercel: { static: { dir: "../web/dist", spaFallback: "index.html" }, functions: { api: { entry: "src/api.ts", maxDuration: 30, supportsResponseStreaming: true }, }, routes: [{ src: "/api/(.*)", dest: "/api" }], }, }, }) ``` Let's quickly review the keys: - `static.dir` is copied into `.vercel/output/static/`, with symlinks dereferenced. It's relative to the app root. - `static.spaFallback` names a file inside `static.dir`. It's served, as the last route, for every path nothing else claims. - `functions.` is bundled with esbuild from `entry` into `functions/.func/index.mjs`, with a `.vc-config.json` holding `runtime` (default `nodejs24.x`), `maxDuration` and `supportsResponseStreaming`. Each name is one path segment. - `routes` come first, before the filesystem phase. Vercel route keys pass through, but a `handle` entry is rejected because the build manages the phases. - `functionName` is the runtime function's name. It defaults to `b4`. The emitted `config.json` routes are ordered: your `routes`, then `{ handle: "filesystem" }`, then the runtime function, then the SPA fallback. Without a SPA fallback the runtime route stays the catch-all `/(.*)`. With one, the runtime is scoped to the surfaces it owns, `/(healthz|readyz|agui|threads|memory|workspace)(/.*)?`, so every other path reaches the SPA document. The Build Output API also serves a function named `index` at `/`, so it would shadow a static `index.html`. That's why the runtime function is named `b4.func` and routed from `/b4`, whether or not the build emits static assets. That way, a deployment that adds a frontend later keeps its function name. Setting `functionName: "index"` beside `static` fails the build. The runtime function always declares `supportsResponseStreaming: true`, and you can't turn it off. It answers `/agui/:routeId` with `text/event-stream`, or `application/vnd.ag-ui.event+proto` when the request's `accept` admits it, and `/threads/:id/runs/stream` with `text/event-stream`. Without that flag, Vercel's Node launcher buffers the whole response, and the browser gets nothing until the run finishes. The Vercel guide covers [how the composed tree is validated](https://b4.run/docs/deployment/vercel#compose-with-a-frontend). #### 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, and `B4_PG_SCHEMA` where preview and production share a database. 5. Put outer authentication and tenant authorization around all rooted B4.run paths. 6. If you use generated stores, validate permission mode, static policy, hydration and refresh. 7. Drive Agent Protocol, AG-UI streaming, cancellation, and request-store disposal on the deployed host. #### What is proven, and what is not Our tests prove three things about the emitted module graph: - It bundles without Node built-ins from B4.run's own code. - It runs against local workerd in a gated lane. - It completes a Node Hono round trip. That's not a live Cloudflare deployment, and it says nothing about Vercel, Deno or Bun behavior. You still need to validate provider quotas, compatibility settings, WebSocket reachability, bindings and production authentication on your deployment. Related: - [Embed the Runtime](https://b4.run/docs/embedding): Own handler, store, and shutdown lifetimes. - [Persistence and Tenancy](https://b4.run/docs/persistence): Understand request-scoped and durable stores. - [Security Architecture](https://b4.run/docs/security-architecture): Protect every rooted endpoint. - [Node and Docker](https://b4.run/docs/deployment/node): Use the full runtime when the edge gate is too tight. --- ### LangSmith Source: https://b4.run/docs/deployment/langsmith Choose the LangSmith target when LangSmith, or another LangGraph deployment platform, must handle transport and execution. This target emits graph entries for the platform to run, without B4.run's HTTP server. #### Select the target B4.run emits LangSmith by default alongside Node. To emit only the platform artifact: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["langsmith"] }, }) ``` #### Build output Run the usual 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. Here is a representative config: `.b4/build/langgraph.json`: ```json { "graphs": { "/navlog#agent": "./.b4/build/navlog.ts:graph" }, "dependencies": ["."], "env": ".env.example", "node_version": "22" } ``` Each graph key is `#`. Use the full key, such as `/navlog#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 skip agent materialization. Their generated entry imports the route's `workflow`, `graph` or `chain` export and re-exports it as `graph`. Test every route kind you deploy on the platform itself. B4.run's HTTP scenario tests never see 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, and `.env` otherwise. The platform build reads this path. Review the selected file before you deploy, and keep secret values in the platform's secret manager, out of Git. #### 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. The merge is shallow, so a user `graphs` map is replaced as a whole. #### Platform authentication and missing B4.run surfaces Configure authentication and tenant authorization on LangSmith or your hosting platform. The generated target does not include B4.run HTTP `middleware`, the AG-UI server, the B4.run `sandbox` manager or B4.run's Agent Protocol thread-management server. Agent routes still get policy-aware graph construction. If you need the HTTP or sandbox surfaces, deploy [Node and Docker](https://b4.run/docs/deployment/node), or [Edge and Hono](https://b4.run/docs/deployment/edge) if your app passes its capability gate. #### Node version mismatch The emitter currently writes `node_version: "22"`, but B4.run packages require Node 24 or newer. As of this writing, that mismatch is unresolved. B4.run isn't known to run correctly on Node 22, so confirm the platform's supported Node versions before you deploy. #### 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` take precedence by design. - **The platform can't find an assistant:** use the full `#` key from generated `graphs`. - **HTTP middleware didn't run:** these entries don't include it. Enforce authentication on the platform. - **Local Node HTTP tests pass but platform calls fail:** exercise the platform transport and its environment/build process directly. Related: - [Deployment Options](https://b4.run/docs/deployment): Compare the build targets. - [Node and Docker](https://b4.run/docs/deployment/node): Run B4.run's complete HTTP runtime. - [Security Architecture](https://b4.run/docs/security-architecture): Put authentication where requests are really served. - [Scenario Testing](https://b4.run/docs/testing): Exercise route behavior before platform validation. --- ### Persistence and Tenancy Source: https://b4.run/docs/persistence B4.run's Node runtime stores its state in local SQLite files by default. That's fine for one process, but tenancy is up to your application. B4.run never infers a tenant from a thread id. #### What B4.run persists The runtime writes six kinds of state, each with its own store, key and lifecycle. ##### Checkpoints LangGraph checkpoints and pending writes. - **Default:** `.b4/checkpoints.sqlite` - **Shared option:** `postgresCheckpointer` through [`checkpointer`](https://b4.run/docs/configuration#checkpointer) - **Keyed by:** `thread_id`, checkpoint namespace and checkpoint id, with no tenant key. - **On thread deletion:** the runtime awaits `deleteThread` after deleting the metadata, when the saver supports it. - **Responsibility:** your app picks the saver, and the runtime writes route state. ##### Thread metadata - **Default:** `.b4/threads.sqlite` - **Shared option:** `createPostgresThreadsStore` through [`threadsStore`](https://b4.run/docs/configuration#threadsstore) - **Keyed by:** `thread_id`. Metadata can record application fields, but the id itself proves no owner. - **On thread deletion:** the thread row is removed first. - **Responsibility:** your app authorizes threads. The runtime maintains status and route metadata. ##### Permission decisions - **Default:** `.b4/permissions.json` - **Shared option:** `createPostgresPermissionsStore` through [`permissions.store`](https://b4.run/docs/configuration#permissionsstore) - **Keyed by:** tool or gate key and pattern, scoped to neither a thread nor a tenant by default. - **On thread deletion:** they stay. - **Responsibility:** your app sets the policy and decides how stores are separated. ##### Long-term memory - **Default:** `.b4/memory.sqlite` - **Shared option:** a separate `MemoryStore`, such as `pgvectorMemoryStore`, through [`memory.store`](https://b4.run/docs/configuration#memory) - **Keyed by:** the route-declared memory scope: `workspace`, `route`, and any `tenant`, `user` or `agent` dimensions your app supplies. - **On thread deletion:** they stay. Memory APIs and your retention policy manage them. - **Responsibility:** your app derives namespaces and manages the lifecycle. ##### Workspace files - **Default:** `/workspace/` through the local filesystem backend - **Shared option:** an application-supplied [`backends.filesystem`](https://b4.run/docs/configuration#backends) - **Keyed by:** the workspace path, with no thread or tenant partition. - **On thread deletion:** arbitrary workspace files stay. - **Responsibility:** your app and its tools. ##### Sandbox volumes - **Default:** none until [`sandbox`](https://b4.run/docs/configuration#sandbox) is configured. The location is provider-specific. - **Shared option:** a durable provider volume, such as a Docker named volume or a Kubernetes PVC. - **Keyed by:** `threadId`, which is the provider lookup key. - **On thread deletion:** sandbox destruction removes the thread volume. Idle reap and shutdown release compute but keep the volume. - **Responsibility:** the configured sandbox provider. > **Warning: A shared database is not tenant authorization** > > A caller can supply a route value, thread id or memory scope value. Storing that value doesn't prove the caller owns it. Authenticate at the service edge, and authorize every read, write, run, cancellation and deletion against tenant data your application controls. #### Choose local or shared stores Local SQLite and file stores suit development, or one long-lived Node process with durable local disk. When compute becomes ephemeral, or several processes need the same records, move the three durable runtime stores. Each one moves on its own: - [`checkpointer`](https://b4.run/docs/configuration#checkpointer) for checkpoints - [`threadsStore`](https://b4.run/docs/configuration#threadsstore) for thread metadata - [`permissions.store`](https://b4.run/docs/configuration#permissionsstore) for runtime permission grants `@b4run/postgres-storage` implements all three over Postgres. Long-term memory is a separate [`memory.store`](https://b4.run/docs/configuration#memory) with its own schema, retrieval and retention needs. `@b4run/memory-pgvector` is one shared option. ##### Generated schema and table prefix The generated `hono` and `vercel` request stores put checkpoints, threads and permissions in the `public` schema under the `b4` table prefix unless the deployment says otherwise. Those `public.b4_*` tables have no application namespace. A tenant field in application input doesn't partition them, and two apps pointed at one database with the defaults share them. Two runtime settings namespace a deployment for you. `B4_PG_SCHEMA` selects the schema and `B4_PG_TABLE_PREFIX` selects the prefix. Each takes a lowercase identifier or a `$NAME` reference to another environment variable. For example, `B4_PG_SCHEMA=$VERCEL_ENV` puts a Vercel project's preview and production deployments in the `preview` and `production` schemas of one shared database. See [Namespace preview and production](https://b4.run/docs/deployment/edge#namespace-preview-and-production). If several applications or trust zones share a database, give each application its own schema or prefix. Set it through those bindings, or through store wiring that passes `schema` or `tablePrefix` consistently to all three Postgres stores. Hand-composed wiring must preserve the generated edge lifecycle and bundling constraints: - per-request pools and disposal - migration coordination - environment binding from the original `Request` - static provider imports - serialized config - explicit permission policy and hydration Note, the generated `stores.mjs` is replaced on every rebuild. ##### Permission caching A running process caches 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 fills its in-memory map with `load()`, and that cache does not auto-refresh across replicas. So a shared permissions table does not automatically give you instant invalidation. When grants or revocations added elsewhere must reach running replicas, the application owns the refresh. That could be a reload schedule, rebuilding the handler, or another mechanism you control. #### Tenant ownership Derive tenant and user scope from identity your service has already verified. Keep a server-side record that ties that identity to each thread and to every other resource that carries a namespace. Check it before you accept an id from a caller. Route parameters, request bodies, thread ids and memory scope strings are addressing inputs, not authentication. Keep four things separate: the verified principal, the tenant ownership record, the storage namespace and the authorization decision. `memory.resolveScope` receives only `{ routePath, appRoot }`. It gets no verified request identity or middleware context. If a memory namespace needs per-request identity, wire it from code that really has that identity, and test the isolation yourself. #### What deleting a thread removes `DELETE /threads/:thread_id` runs in order, and it's 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. Once the checkpoint step succeeds (or is skipped as unsupported), the runtime destroys the thread's sandbox state and volume, if a sandbox manager is configured. A saver without `deleteThread` is skipped. Errors from a supported saver propagate. By then the metadata is already gone, and the error can prevent sandbox cleanup. An HTTP `204` means every attempted step completed. It doesn't mean a cross-store transaction committed. Global permission decisions, long-term memory, arbitrary workspace files, application database rows and data held by other services all stay. Deleting an account takes an application-level inventory and a workflow that covers every store involved. Plan for reconciliation of partial deletions, an idempotent retry path for checkpoint and sandbox cleanup, and audit records that tell a missing metadata row apart from a completed cleanup. > **Warning: Quiesce before delete** > > Always quiesce the thread before you delete it. `DELETE /threads/:thread_id` does not cancel an active run and does not wait for one. A live route can make later checkpoint writes after cleanup, and sandbox destruction can race tools that are still running. > > Stop new work with thread-aware routing or distributed coordination. Route `/threads/:thread_id/cancel` to the owning process, and confirm the run is settled before you send `DELETE`. A successful cancel response or delivered abort signal does not prove route completion, because route code can ignore cancellation or still be unwinding. A 204 confirms only the sequential cleanup calls completed. Concurrent or later writes can still happen. See [Production Topology](https://b4.run/docs/production-topology#add-replicas-safely). #### Backup, restore, encryption, and retention Pick backup and restore procedures for each backend, and rehearse restoring thread metadata and checkpoints together. A restored thread row without its checkpoint history can't reproduce the earlier route state. Workspace files, sandbox volumes and long-term memory each need their own backup plan. B4.run doesn't encrypt stored values at the application level, and it has no account-erasure transaction across these stores. Postgres rows are plaintext application data unless your app or infrastructure adds encryption. Protect database credentials, storage volumes, backups and access logs to match. Set retention per data class. Checkpoint history, thread records, permission grants, long-term memory, workspace output, sandbox volumes and backups rarely need the same window. Write down which component expires or deletes each one, and how failed cleanup is retried and audited. #### Migration checklist 1. Inventory every store listed above, 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 you copy local data with backend-native tooling or an application-specific migration. B4.run has no local-to-Postgres migration command. 5. Configure [`checkpointer`](https://b4.run/docs/configuration#checkpointer), [`threadsStore`](https://b4.run/docs/configuration#threadsstore), [`permissions.store`](https://b4.run/docs/configuration#permissionsstore), and [`memory.store`](https://b4.run/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 don't distribute the active-run gate or the cancellation registry. #### Related - [State](https://b4.run/docs/state): route state and checkpoint behavior - [Long-term Memory](https://b4.run/docs/memory/long-term): typed records, namespaces, governance, and retention - [Permissions](https://b4.run/docs/permissions): runtime decisions and stores - [Workspace Filesystem](https://b4.run/docs/workspace): local and custom filesystem backends - [Production Topology](https://b4.run/docs/production-topology): replicas, routing, health, and shutdown - [Security Architecture](https://b4.run/docs/security-architecture): authentication and tenant authorization --- ### Production Topology Source: https://b4.run/docs/production-topology Start with one B4.run process, and add infrastructure only when you've measured a need for it. Shared persistence, replica coordination, streaming, readiness and shutdown are separate design decisions. #### Start with one process The smallest production shape is the Node runtime on a durable local disk. One process runs the HTTP listener and its in-memory run registry. Default SQLite files hold checkpoints and thread metadata, and the default permission store is 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. With one process there are no cross-replica races, because every run for the service reaches the same process. #### Where state lives - **Active-run gate, abort controllers, cancellation registry, resume claims and the in-session thread-to-route map** are process-local memory in one runtime handler. No other process can see, serialize or cancel its active run. - **Checkpoints, thread metadata and runtime permission grants** use local SQLite and file stores by default, or configurable shared durable stores. Shared records survive replacement compute, but they don't coordinate active execution. - **Typed long-term memory** uses its own local SQLite store or a shared memory backend, outside the three runtime Postgres stores. Your app decides its namespace and retention. - **`workspace/` files** use the local filesystem backend by default. Ephemeral or replica-local disks drift apart unless your app supplies durable shared storage. - **Sandbox workspaces** are provider-managed per-thread volumes. Release and destroy behavior depends on the provider, and route requests must reach the intended volume. - **Middleware context and per-request stores** last for one request or run. Never use them as cross-request or cross-replica state. See [Persistence and Tenancy](https://b4.run/docs/persistence) for each store's lifecycle. #### Move to ephemeral compute Before you swap local disk for 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. Then decide whether workspace files and sandbox volumes must survive process or pod replacement. Where the host allows it, use one pool that your app creates and closes. Handle idle pool errors, and close the runtime handler before you end the pool. An edge runtime may need request-scoped pools instead. There, follow the generated Hono store lifetime and never reuse request-bound sockets across invocations. #### Add replicas safely Replicas need 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 in the path, as in `/threads/:thread_id/runs/stream` and `/threads/:thread_id/cancel`. Route every operation for an active thread (cancellation included) to the instance running it, or put a distributed coordinator in front of execution. A persisted `busy` status is metadata. It's not a safe lock. AG-UI sends its `threadId` in the request body to `/agui/:routeId`, so a plain path-based load balancer can't derive thread affinity from it. Use a gateway that understands the request, a trusted affinity token set before dispatch, or another serialization design you can test under concurrent requests. #### Streaming and proxy behavior Agent Protocol runs outlive their viewer. If the client disconnects from `/runs/stream` or abandons `/runs/wait`, the run keeps going until it completes, is 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 through unbuffered, keep heartbeat traffic, and allow timeouts long enough for your turns. Test disconnects at the browser, the proxy and the runtime, as well as with a direct local request. #### Health and readiness The runtime serves two probes on every target (Node, Hono, Vercel). They answer different questions: - `GET /healthz` is **liveness**: the process is up and serving HTTP. It touches no store and never calls a per-request store factory. A deployment with `DATABASE_URL` unset, or with an unreachable database, still answers `200 {"status":"ready"}`. So a passing `/healthz` does not prove that Postgres, model providers, sandbox infrastructure or any other configured backend can serve a turn. - `GET /readyz` is **readiness**: the request's durable stores (threads, checkpointer, permissions) each run a real query against their schema, running lazy migrations on the way. It answers `200 {"status":"ready","checks":{...}}` when every store answers, or `503 {"status":"not_ready","checks":{...}}` naming each failing dependency: `requestStores` when the per-request factory itself threw (for example, `DATABASE_URL` is missing on Vercel), otherwise the store's name. Connection-string credentials are redacted from failure messages, and the cause is logged to stderr once per distinct failure. `/readyz` skips the model provider, the sandbox provider and the memory store. If your rollout depends on anything beyond the durable stores, add your own check for it. Every other endpoint answers failures with an opaque message. `/readyz` reports the real cause so the probe can name the dependency. Credentials are stripped from connection strings, but hostnames, ports, database names and driver error codes do appear in the body. Middleware and thread access gate neither probe, so keep both behind the same outer access controls as the rest of the service. #### Shutdown and rollouts The runtime handler's `close()` stops accepting requests and aborts shutdown-aware work. Then it waits up to 30 seconds for response bodies, active runs and request-store disposal. After that it releases active sandboxes, keeping provider volumes as the provider defines. Work that ignores cancellation can outlive this bounded drain. Injected stores and pools stay yours to close. Await `close()` first, so in-flight routes don't write through a pool that has already ended. `b4 start` opts into SIGINT/SIGTERM handling. `serveRuntime` leaves signal installation off by default, 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 write a host entry that receives platform signals, awaits `close()`, and exits the way your supervisor expects. #### Kubernetes implications An HPA adds or removes replicas. A PodDisruptionBudget limits how many pods voluntary disruption takes down at once. Both can improve availability, but neither one serializes a thread, routes cancellation, drains a B4.run handler or shares local disk. On Kubernetes, combine: - an application-level readiness probe - a termination grace period longer than your drain budget - a signal-aware entry point - verified thread affinity or coordination Then exercise rolling updates with a live Agent Protocol stream, an AG-UI disconnect, a cancel request and a pod replacement while the shared stores stay available. #### Related - [Persistence and Tenancy](https://b4.run/docs/persistence): where each data class lives and who handles deletion - [Security Architecture](https://b4.run/docs/security-architecture): protect every exposed runtime surface - [Deployment Options](https://b4.run/docs/deployment): current build targets and artifacts - [Embed the Runtime](https://b4.run/docs/embedding): own handler and shutdown lifetimes --- ### Observability Source: https://b4.run/docs/observability Observability shows what an agent actually did on a run: its prompts, its tool calls, and where the time and tokens went. You can trace runs to LangSmith, or watch the dev server's live SSE stream. #### LangSmith tracing B4.run runs on LangGraph, which sends traces to LangSmith when tracing is enabled. One agent run produces a nested trace under a single root run. It includes every LLM call (prompts, token counts and latency), every tool call (input and output) and every subagent child run. You don't need to add any instrumentation code to your routes. #### Enabling tracing Put your `LANGSMITH_API_KEY` in `.env` (or export it in your shell). When `b4 dev` loads the environment and finds `LANGSMITH_API_KEY` with no `LANGCHAIN_TRACING_V2`, it sets `LANGCHAIN_TRACING_V2=true` for you: `.env`: ```bash LANGSMITH_API_KEY=lsv2_... LANGCHAIN_PROJECT=my-research-agent # optional: names the project in LangSmith ``` **To opt out**, set `LANGCHAIN_TRACING_V2=false` in your shell or `.env`. The auto-enable only fills unset variables, 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 stays `success`, and the agent continues once the interrupt is resolved. A real error has status `error` and an exception string in the error field. #### Live SSE streaming (no account required) No LangSmith account? You can use the dev server's `runs/stream` endpoint, which streams every event in real time over SSE. 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](https://b4.run/docs/dev-server/agent-protocol) for the full event-type table and connection instructions. #### Related - [Configuration Reference](https://b4.run/docs/configuration): b4.config.ts reference, including the env key for the .env file path - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): /runs/stream SSE endpoint and event types --- ### Inspector Source: https://b4.run/docs/inspector The Inspector is a browser UI for looking at 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**, where you browse, search and govern the app's [long-term memory](https://b4.run/docs/memory/browse) records. #### 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. It takes these 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. It 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's 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` just as the dev server does. The default SQLite store, the [pgvector backend](https://b4.run/docs/memory/retrieval#postgres-backend-pgvector) and custom `MemoryStore` implementations all work unchanged. The inspector runs as a separate process, so two caveats apply: - It constructs a **second store instance** from your config. For SQLite that's a second connection to the same database file. For Postgres it's a second connection pool. Both are safe, but count it against connection limits. - The store must be **constructible from config alone**. If a store closes over live objects created elsewhere in your server process, the inspector can't re-create it. #### The Memory panel - **Browse and filter:** every record across namespaces and statuses (not only candidates), with facet counts by status, kind, namespace and source type. - **Search:** ranked search that matches recall. It runs the same IDF-weighted keyword ranking as the agent's `recall` tool. When your config declares a [vector embedder](https://b4.run/docs/memory/retrieval#semantic-recall-opt-in) and the provider key is available (for example via `--env-file`), it runs 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 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](https://b4.run/docs/memory/episodes) 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 always excludes them, because it mirrors what the agent's `recall` can see. To debug retention, the list API accepts `includeExpired=1` to reveal rows that are past their TTL but not yet pruned. > **Info: Headless equivalent** > > The [`b4 memory`](https://b4.run/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, so it's never reachable from the network. - Every API route verifies the `Host` header and rejects state-changing requests with a foreign `Origin`. This protects 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](https://b4.run/docs/memory/browse): administrative filtering, ranking, and governance - [CLI Reference](https://b4.run/docs/cli): b4 inspect flags and the b4 memory workflow - [Configuration Reference](https://b4.run/docs/configuration): the b4.config.ts memory block - [Troubleshooting](https://b4.run/docs/troubleshooting): common errors by symptom, with the fix for each --- ### Troubleshooting Source: https://b4.run/docs/troubleshooting Let's go through the problems people hit most often, listed by what you see. Each entry gives the cause, the fix and a link to the page with the details. When an error carries a `B4_Exxxx` code, the CLI prints it with a docs link, and [Error Codes](https://b4.run/docs/errors) lists every code. A good first step for almost any problem is `b4 verify`. It checks the app, its routes, generated types, dependencies and your runtime in one call: ```bash b4 verify ``` #### `b4` says it could not find `b4.config.ts` You see `Could not find b4.config.ts from `. The CLI walks up from the current directory looking for a directory that holds both `b4.config.ts` and `package.json`, and it didn't find one. In a scaffolded app, the B4.run app lives in `server/`, not at the workspace root. Run the command from the app directory, or point at it with `--cwd`: ```bash b4 check --cwd server ``` See [CLI Reference](https://b4.run/docs/cli). #### `b4 check` fails with `B4_E1006` You see `must set "type": "module"` followed by `[B4_E1006]`. B4.run loads `b4.config.ts` and every route `index.ts` as ES modules. Without `"type": "module"` in the app root's `package.json`, Node compiles them as CommonJS. Add the field to the app root's `package.json`: ```json { "type": "module" } ``` See [Upgrading](https://b4.run/docs/upgrading#the-app-root-must-declare-type-module). #### `b4 check` reports a route with no recognisable export You see `Route entry has no recognisable export` followed by `[B4_E1007]`. Every directory under the app directory that has an `index.ts` is a route. That file must have a default export of `agent(...)`, or export exactly one of `agent`, `workflow`, `graph` or `chain`. If the message says the file was loaded as CommonJS, a nested `package.json` is missing `"type": "module"`. Fix the export, or add `"type": "module"` to the `package.json` the message names. If the directory isn't a route, prefix its name with `_` so discovery skips it. See [CLI Reference](https://b4.run/docs/cli#b4-check). #### `b4 check` finds fewer routes than you expect You see `B4.run app is valid: 0 routes discovered.` or a route is missing from the list. Discovery only looks inside the app directory. That's `src/app` unless `appDir` in `b4.config.ts` says otherwise. It only treats a directory as a route when it contains a file named `index.ts`, and it skips any directory whose name starts with `_`. Check the route list and the paths it resolved: ```bash b4 routes ``` Then move the route under the app directory, rename its entry file to `index.ts`, or fix `appDir`. See [Routes](https://b4.run/docs/routes) and [Configuration Reference](https://b4.run/docs/configuration#appdir). #### A model provider package is missing You see `Provider "anthropic" requires @langchain/anthropic. Install it with: npm install @langchain/anthropic [B4_E4001]`. The install command matches the package manager you ran B4.run with. Each model provider is a separate LangChain package. B4.run imports it when it builds the model, and the import fails when the package isn't installed. Install the package the message names: ```bash npm install @langchain/anthropic ``` `b4 verify` also lists missing packages as a warning before you hit this at runtime. See [Agents](https://b4.run/docs/agents). #### The model call fails for lack of an API key `b4 verify` prints `Warning: Missing environment variables: OPENAI_API_KEY.`, or the first model call fails with an authentication error. `b4 verify` works out which key each provider needs from the models your routes use. `b4 dev` reads keys from `./.env` in the app root by default. In production, keys must come from the shell or the hosting platform. Add the key to `.env` in the app root: ```bash OPENAI_API_KEY=sk-... ``` A local Ollama model needs no key. See [Configuration Reference](https://b4.run/docs/configuration#env). #### An unknown model id warning You see `[b4:models] [B4_E4002] model "" is not a known model id.` The model id isn't in B4.run's list for that provider. It's a warning, and the run proceeds anyway. Usually it's a typo. When B4.run knows a close match, the message suggests it. If the id is new and real, you can ignore the warning. See [Agents](https://b4.run/docs/agents). #### The dev server port is already in use You see `Fatal dev session error: Port 3002 is unavailable`. Another process already listens on that port. It's often an earlier `b4 dev` that didn't exit. The scaffolded server runs `b4 dev --port 3002`, and the Workbench runs `next dev -p 3010`. Stop the other process, or pick another port: ```bash lsof -i :3002 b4 dev --port 3003 ``` Without `--port`, `b4 dev` picks a free port and prints the URL. If you move the server off 3002, set `B4_SERVER_URL` in `web/.env` so the Workbench can find it. To move the Workbench off 3010, change `-p 3010` in `web/package.json`. See [Dev Server](https://b4.run/docs/dev-server). #### The Docker sandbox is unavailable `b4 check` or `b4 verify` says the Docker daemon is not reachable because `docker version` failed. `b4 check` tags it `[B4_E1002]` and `b4 verify` tags it `[B4_E2002]`. Your `b4.config.ts` configures a sandbox provider, and B4.run runs its preflight before it trusts it. The Docker provider's preflight runs `docker version`, which fails when the daemon is down. Start Docker Desktop (or the Docker daemon), then confirm it answers: ```bash docker version b4 verify ``` See [Execution Sandbox](https://b4.run/docs/sandbox#quickstart). #### An approval never resumes the run The run pauses on a permission prompt, and nothing happens after you answer it. The run stays parked until a `POST /threads/:thread_id/resume` request answers every pending interrupt on the root thread. The body must be `{ resume, route }` and nothing else, with one entry per pending `interruptId`. A partial, stale, duplicate or extra set returns `409`, and a second resume while one is in flight returns `409` with `resume_in_progress`. List what's still pending, then answer all of it in one request: ```bash curl -sS "$BASE_URL/threads/$THREAD_ID/pending_interrupts" ``` This also works after a page reload, when the client no longer has the stream that carried the `interrupt` event. See [Permissions](https://b4.run/docs/permissions#resuming-an-interrupted-run) and [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol#interrupt-and-resume). #### `b4 verify` fails You see `Verify failed:` followed by the reason. `b4 verify` runs five checks: `app`, `routes`, `typegen`, `deps` and `runtime`. Missing packages and environment variables in `deps` are warnings. The others fail the command. The reason names the failing check, and a `[B4_Exxxx]` footer follows when the failure has a code. Get the full report to see which check failed: ```bash b4 verify --json ``` Then fix the failure with the matching entry on this page. See [CLI Reference](https://b4.run/docs/cli#b4-verify). #### Your Node version is too old `b4 verify` fails with `Node is below the required floor 24.0.0.`, or `create-b4-app` says `B4.run requires Node 24+`. B4.run needs Node 24 or later. Node 24 bundles npm 11 and ships `node:sqlite` without a flag. Switch to Node 24 and reinstall: ```bash nvm install 24 nvm use 24 npm install ``` Update your CI runners and container base images too. See [Upgrading](https://b4.run/docs/upgrading#node-24-is-the-minimum). #### Route names or tool types are out of date in your editor TypeScript doesn't know a new route, or a tool's input type is stale in `scenarios()`, `.mockTool()` or `.expectTool()`. Those types come from `.b4/b4.generated.d.ts`, which `b4 typegen` writes. `b4 dev`, `b4 run`, `b4 test` and `b4 build` regenerate it before they execute, so your editor's types only go stale when you change a tool without running any of them. Regenerate the types: ```bash b4 typegen ``` Run it after a fresh clone, before CI, or to refresh your editor after changing a tool signature. See [CLI Reference](https://b4.run/docs/cli#b4-typegen). #### A module fails with `does not provide an export named` A route, tool or `b4.config.ts` fails to load with an ESM named-export error. There are two usual causes. An older `@langchain/core` got hoisted, or a CommonJS dependency is imported with named bindings. B4.run prints the package it failed on and the likely cause. Find a duplicate `@langchain/core` with: ```bash npm ls @langchain/core ``` For a CommonJS package, use a default import and destructure it. See [CLI Reference](https://b4.run/docs/cli#troubleshooting-imports). #### Related - [Error Codes](https://b4.run/docs/errors): every B4_Exxxx code and where it is documented - [CLI Reference](https://b4.run/docs/cli): b4 check, b4 verify, b4 typegen, and b4 dev - [Upgrading](https://b4.run/docs/upgrading): breaking changes that surface as new errors - [Dev Server](https://b4.run/docs/dev-server): ports, restarts, and the local runtime --- ### Upgrading Source: https://b4.run/docs/upgrading B4.run is pre-1.0 and the API is still moving. So pin a version, read what changed, and upgrade on purpose. This page covers the upgrade workflow and the changes that need action, labeled with the version each one landed in. The full list of changes lives on GitHub. #### 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, using [Changesets](https://github.com/changesets/changesets)' fixed-group mode. If `@b4run/cli` is at `0.10.0`, so is every other package in that release. You don't need a compatibility matrix. Install matching versions and you're done. The two Helm charts (`b4-sandbox-infra`, `b4-app`) track the same version through their `appVersion` field, but they're published separately as OCI artifacts. See [Kubernetes](https://b4.run/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. A changeset is a short Markdown file that says what changed and why. You can read them in two places: - **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. It's the fastest way to scan what changed between two versions. - **Per-package `CHANGELOG.md`:** each package under `packages/*/CHANGELOG.md` has its own changelog, generated from the same entries but scoped to that package. > **Info: Read the entries between your current and target version** > > Skim every version between the one you're on and your target, not only the latest. An 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 ##### Step 1: Check the current version ``` npm ls @b4run/cli ``` or check the `@b4run/*` entries in your `package.json`. ##### Step 2: Read the release notes Open [GitHub Releases](https://github.com/cacheplane/b4run/releases) and read every entry between your current version and the target version. ##### Step 3: Bump and reinstall Bump every `@b4run/*` dependency in `package.json` to the same target version, then reinstall. ##### Step 4: Regenerate and verify ``` b4 typegen b4 verify b4 test ``` `b4 verify` checks config, routes, typegen, dependencies, and runtime readiness. `b4 test` re-runs your scenarios against the new version. See [CLI](https://b4.run/docs/cli) and [Testing](https://b4.run/docs/testing). #### Pinning Pin `@b4run/*` packages to a single version (no `^` or `~` ranges) until the project reaches 1.0. A floating range lets an unreviewed bump land in CI or production before anyone reads the notes. #### Changes by version Newest first. Each change names the release it landed in, and the two that need no action say so. ##### `@b4run/ag-ui` has one folder per framework; the stylesheet is `@b4run/ag-ui/styles.css` Landed in the first release after **0.13.1**. Action required if you import the activity stylesheet, or the CopilotKit connector from a pre-release build. The activity stylesheet moved from `@b4run/ag-ui/react/styles.css` to `@b4run/ag-ui/styles.css`, one sheet for every kit; `./react/styles.css` is gone. Change the import: ```ts import "@b4run/ag-ui/styles.css" ``` The React CopilotKit connector (`B4Activity`, `useB4ChatSlots`, `useB4Turns`, `useB4ActivityContext`) is at `@b4run/ag-ui/react/copilotkit`, not `./copilotkit`. The Angular activity kit ships in the same package: `@b4run/ag-ui/angular`, with its connectors at `./angular/events` and `./angular/copilotkit`; Angular and `@copilotkit/angular` are optional peer dependencies. See [AG-UI and Web Clients](https://b4.run/docs/ag-ui#an-angular-client). ##### The legacy activity cards are gone; `@b4run/ag-ui/react` is the activity kit Landed in the first release after **0.13.1**. Action required if you render B4.run activity with the 0.13 cards, renderers or subagent hook. Removed from `@b4run/ag-ui/react`: `PlanActivityCard`, `ActivityChecklist`, `SubagentPanel`, the `classNames`/`components` props with `B4ActivityClassNames`, `B4ActivityComponents`, `B4TodoRowProps` and `B4ToolRowProps`, `cx`, `planActivityContentSchema`, and `useSubagentRuns` with `SubagentEventSource`. Removed from `@b4run/ag-ui/view` (and their `./react` re-exports): `reduceSubagentRuns`, `EMPTY_SUBAGENT_RUNS`, `SubagentRun`, `SubagentRunsState` and `SubagentToolCall`. Removed from `@b4run/ag-ui/react/copilotkit`: `b4ActivityRenderers` and `b4PlanActivityRenderer`, so drop `renderActivityMessages={b4ActivityRenderers}` from ``. `isSubagentMessage` now comes from `@b4run/ag-ui/view` only. What replaces them: wrap `` (or ``) in `B4Activity` from `@b4run/ag-ui/react/copilotkit` and spread `useB4ChatSlots()` onto it. Each turn renders as one `TurnActivity`: its tool steps, its plan (the `b4.plan` snapshots), its reasoning, and each subagent as a step with the child's own steps nested. A parked interrupt renders as an `ApprovalCard`, so remove your own `useInterrupt` for B4.run permission prompts (CopilotKit has one interrupt slot, and `B4Activity` fills it). A host with its own transcript uses `useB4Turns(agent)` with `TurnActivity` and `ApprovalCard`; a non-React client folds events with `reduceTurns` from `@b4run/ag-ui/view`. To restore a thread after a reload, give the CopilotKit runtime `createB4AgentRunner(InMemoryAgentRunner, { url, fetch })` from `@b4run/ag-ui/copilotkit-runtime`. The stylesheet now sits in `@layer b4-activity` and styles only the kit. The `.b4-activity*` rules and the `--b4-activity-gap`, `-font-size`, `-margin`, `-padding`, `-header-weight` and `-badge-bg` tokens are gone; the design tokens `--b4-activity-surface-alt`, `-running-bg`, `-failed-bg`, `-primary`, `-on-primary`, `-radius-card`, `-radius-pill` and `-font-mono` are new, and `-running` (`#2563eb` → `#b45309`), `-failed` (`#9f1239` → `#b91c1c`), `-complete` (`#15803d`), `-radius` (`10px` → `8px`), `-surface`, `-border`, `-text` and `-muted` are re-valued. Dark mode no longer follows the OS by default; it follows the host (`.dark`, `[data-theme="dark"]`, or `data-b4-theme="dark"` on the root). Action required if your app relied on OS-driven dark: set `data-b4-theme="auto"` on the root element, or override the tokens in your own CSS. See [AG-UI › Consuming it from a web UI](https://b4.run/docs/ag-ui#consuming-it-from-a-web-ui). ##### Threads restore their activity from `GET /threads/:thread_id/turns` Landed in the first release after **0.13.1**. Action required if you read `GET /threads/:thread_id/state` to rebuild a transcript, or parse `values.messages` yourself. Every tool call's `ToolMessage` now carries a complete `additional_kwargs.b4_step` (status, start and settle times, the permission decision, and the display's icon, label and sources), the `task` tool returns a `ToolMessage` with `b4_subagent`, and the runtime stamps `b4:turn` on the head checkpoint when a run ends. A call that a permission or argument constraint denied persists and restores as a `denied` step, not a failed one. `GET /threads/:thread_id/turns` rebuilds the same `TurnsView` a live AG-UI client builds, approvals included, so prefer it over parsing `/state`. `/state.created_at` is now the checkpoint's time. Threads written before this release do not restore through `/turns`. See [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol#restoring-a-threads-activity). ##### AG-UI `TOOL_CALL_RESULT.content` is the tool's output Landed in the first release after **0.13.1**. Action required only if an AG-UI client parsed `TOOL_CALL_RESULT.content` as a serialized LangChain `ToolMessage` (`{ lc, type, id, kwargs: { content } }`). `content` is now the text the model received: the tool's return value, or the error text for a tool that threw. Drop any `kwargs.content` unwrapping, and parse JSON only if your tool returns JSON. Permission interrupts also gain `toolCallId`, `subagentRunId` for a child's tool, command or memory gate, and `responseSchema` (`once`/`always`/`deny`); these are additive. See [AG-UI](https://b4.run/docs/ag-ui#inbound-input). ##### Subagents are AG-UI `SUBAGENT_*` events, not a `b4.subagent` activity Landed in the first release after **0.13.1**. Action required for AG-UI clients that rendered the `b4.subagent` activity. The `b4.subagent` activity is gone, with `B4_SUBAGENT_ACTIVITY_TYPE`, `B4SubagentActivityContent`, `SubagentActivityCard`, `b4SubagentActivityRenderer` and `subagentActivityContentSchema`. A subagent is now `SUBAGENT_STARTED` (its `subagentRunId` is the `task` tool-call id), everything it does arrives as the ordinary AG-UI events tagged `subagentRunId`, and it closes with `SUBAGENT_FINISHED` or `SUBAGENT_ERROR` before the run ends. The `task` tool call is an ordinary tool call again, with its own frames and result. In React, `B4Activity` shows each subagent as a step in its parent's turn, with the child's own steps nested (see [the activity kit entry](#the-legacy-activity-cards-are-gone-b4runag-uireact-is-the-activity-kit)). `GET /agui/{routeId}` advertises `multiAgent`. See [AG-UI › Subagents](https://b4.run/docs/ag-ui#subagents). ##### Subagent stream events carry the root shapes Landed in the first release after **0.13.1**. Action required only if you read `subagent.*` events off `/runs/stream` or the attach digest. `subagent.message { call_id, chunk }` is now `subagent.token { call_id, …identity, data, messageId }`, and `subagent.tool_call` / `subagent.tool_result` carry `name` (not `tool`) under the model's tool-call `id` (not the execution run id). New: `subagent.reasoning`, `subagent.message_end`, `subagent.tool_call_args`, and `parent_call_id` / `description` on `subagent.start`. `@b4run/testing`'s `RunResult.subagents` reads the new shapes. See [Streaming output](https://b4.run/docs/recipes/stream-output#sse-event-types). ##### `reasoning` is keyed by provider Landed in the first release after **0.13.1**. Action required if an `agent()` route sets `reasoning`. `agent({ reasoning: { effort } })` is now `agent({ reasoning: { openai: { effort } } })`, and Anthropic routes gain `reasoning: { anthropic: { budgetTokens } }`. The flat `effort` is an unknown key and fails the route when its model is built, as does a block for a provider the route does not resolve to. Before, a misplaced setting was silently ignored (and the OpenAI effort itself never reached the request). Setting `openai.summary` moves the route to the OpenAI Responses API and streams the summary as AG-UI `REASONING_*` events; `GET /agui/{routeId}` advertises `reasoning.supported` accordingly. See [Reasoning](https://b4.run/docs/reasoning-effort). ##### The app root must declare `"type": "module"` Landed in **0.8.35**. `b4 check`, `dev`, `build`, `verify`, `routes`, and `typegen` now fail with `B4_E1006` when the app root's `package.json` lacks `"type": "module"`, where they used to run. Add the field: ```json { "type": "module" } ``` Apps scaffolded by `create-b4-app` already have it. An app that never set it was in one of two states, and both are fixed by the same line: - **Routes exported with `export default agent(...)` were silently dropped.** `b4 check` reported `0 routes discovered` and exited 0, because the route modules were compiled as CommonJS and their default export arrived wrapped in the ESM/CommonJS interop object. - **Routes exported as `export const workflow` (or `graph`/`chain`) appeared to work.** Node's CommonJS lexer hoists those into named exports, so discovery found them. But `b4.config.ts` was loaded through the same interop wrapper, so every setting on it was silently ignored and the defaults applied. A route `index.ts` that exports nothing B4.run recognises is now also an error (`B4_E1007`) naming the file. Before, discovery silently skipped the directory. If such a directory isn't a route, prefix its name with `_` to keep it out of route discovery. ##### The `vercel` target's function is now `b4.func` Landed in **0.8.35**. `b4 build` with the `vercel` target publishes its runtime function at `functions/b4.func/` and routes `/(.*)` to `/b4`. It was `functions/index.func/` routed to `/index`, except in a build that configured `build.vercel.static`, which already used `b4`. Why? In the Build Output API, a function named `index` is also served at `/`, where it shadows a static `index.html`. That hazard comes with the name in every build, so we moved the name off the root. An app that adds a frontend later doesn't have to rename its function. The function itself is the same, and anything that goes through the CLI keeps working. What breaks is anything that reads the published tree by path: - A deploy script that copies or inspects `functions/index.func`. - An external assembler that composes the tree after the build. - CI that imports the built entry for a smoke check. - A hand-written `config.json`, or an assertion, naming the `/index` route. **Migrating depends on whether you ship static assets.** Without static assets, you can pin the old name: ```ts // No static assets: you can keep the old published path indefinitely. export default config({ build: { targets: ["vercel"], vercel: { functionName: "index" } }, }) ``` With static assets, the same setting fails the build: ```ts // With build.vercel.static: this FAILS the build, by design. A function named // "index" would shadow the static root, so the build refuses it and names the // conflict. Update whatever reads the tree instead. export default config({ build: { targets: ["vercel"], vercel: { functionName: "index", static: { dir: "../dist/react" } }, }, }) ``` An app that ships a frontend with the runtime must move to the new path. An explicit `index` shadows the static root just as the old default did, so allowing it would bring back the original bug where it does the most damage. Either way, `functionName` accepts one path segment of letters, digits, `_` or `-`, and `b4 check` rejects anything else before the build writes. ##### Docker and Kubernetes sandboxes now require a `scope` Landed in **0.8.32**. Action required if you use the Docker or Kubernetes sandbox provider. Both providers now take a required `scope`: a stable application and environment identifier such as `my-app-production`. Every container, volume, Pod, PVC and NetworkPolicy name is a hash of the scope and the thread ID, which prevents collisions the old thread-name normalization allowed: ```ts provider: dockerSandbox({ scope: "my-app-production", image: "node:24-slim" }), ``` Resource names changed, so existing sandboxes aren't reattached, migrated or deleted. Export any data you need before you upgrade, then clean up the old resources yourself. Scope isn't authorization: your application still decides who can use a thread. See [Execution Sandbox](https://b4.run/docs/sandbox#application-scope). ##### Skills, `plan.md`, and route `memory.md` now work on edge targets Landed in **0.8.30**. No action required. 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. Files over the build's per-marker cap fail the build with `B4_E1005` by name (`b4 check` applies the same limits). The cap is 32 KiB for `SKILL.md` (a build-only cap) and `memory.md`, and 64 KiB for `plan.md`. The last two match the runtime limits. See [Skills, `plan.md`, and route `memory.md` are bundled](https://b4.run/docs/deployment/edge#skills-planmd-and-route-memorymd-are-bundled). ##### `toolOutput` is now gated off the `hono` target Landed in **0.8.22**, first published in **0.8.24**. 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. Your app didn't change. The gate did. [Tool-output offloading](https://b4.run/docs/context-management) spills oversized tool results to a file under `workspace/` and hands the model a pointer to it. An edge runtime has no filesystem to spill to, so the feature never worked on that target. It slipped through because it was the only gated feature whose config is plain JSON. The other gated keys are live objects that the build strips. `toolOutput` was inlined into the bundle and then ignored at runtime. A green build with a worker that silently never offloads is worse than a failed build. You have two options. 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, so the gate lets it through. **Node deployments are unaffected.** The gate is specific to the `hono` build target, and the matching runtime check (below) can't fire on Node at all. ##### Gated features now fail loudly at request time, not just at build time Landed in **0.8.22**, first published in **0.8.24**. `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, 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](https://b4.run/docs/deployment/edge#what-the-edge-cannot-serve). This closes a gap the build gate couldn't. 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 meets the build gate. **Node apps are safe here, whatever they configure.** The check short-circuits before it reads a single config key whenever the runtime supplied filesystem fallbacks, and every Node entry point always supplies them. An absent sandbox on Node still degrades as documented. On the edge, the affected config was already doing nothing. ##### `@b4run/postgres-storage` now requires a `pool` on its main entry Landed in **0.8.21**. `connectionString` has moved to a new `@b4run/postgres-storage/node` subpath. Through `0.8.19` the main entry accepted either and built its own `pg` pool from a connection string. Now it imports `pg` for **types only**, so the package links on a runtime with no TCP sockets. That's what makes the [`hono` edge target](https://b4.run/docs/deployment/edge) possible. `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. You have 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 }) ``` > **Warning** > > 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, so the process > exits. Idle connections get dropped routinely (server restart, failover, > `idle_session_timeout`), so a routine Postgres blip becomes an outage. Earlier > versions built the pool and attached this listener for you. Once you own the > pool, you own its error handling: the stores 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 works the same in both shapes. `close()` ends a pool the store built, and an injected pool is left alone (`ownsPool`, defaulting to `false`). This shipped as a **patch** even though it breaks, which is one more reason to [pin a version](#pinning) and read the notes. ##### Node-only imports moved to `/node` Landed in **0.8.17**. If your tooling imports filesystem or process-backed helpers, change the import specifier. The symbols stay the same: - 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 stay on each package's main entry. ##### `kind: "reflection"` is now accepted Landed in **0.8.17**. [Memory distillation](https://b4.run/docs/memory/distillation) wired the `reflection` kind. `defineMemory({ kind: "reflection" })` and the generated `remember` tool now accept it. Before, 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 additive. ##### Node 24 is the minimum Landed in **0.8.16**. 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. Then reinstall with that runtime and run `b4 verify`. Node 24 also supplies the npm and unflagged `node:sqlite` versions the scaffold expects. ##### MemoryStore now requires `prune` Landed in **0.8.15**. [Episodic memory](https://b4.run/docs/memory/episodes) 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, with `since` inclusive and `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 stores implement all of this, and `runMemoryStoreConformance` checks a custom one. ##### MemoryStore now requires `browse` and `stats` Landed in **0.8.14**. The `MemoryStore` contract gained two required methods to power the [Inspector](https://b4.run/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), where it used to be 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, so you no longer get two active rows. Approving an identical duplicate dedupes it. #### Related - [CLI Reference](https://b4.run/docs/cli): b4 verify and b4 typegen for post-upgrade checks - [Scenario Testing](https://b4.run/docs/testing): scenario coverage to re-run after an upgrade - [Deployment Options](https://b4.run/docs/deployment): the node, langsmith, and hono build targets, and the Helm charts' own versioning - [FAQ](https://b4.run/docs/faq): the pre-1.0 stability note this page backs - [Troubleshooting](https://b4.run/docs/troubleshooting): common errors by symptom, with the fix for each --- ### Recipes Overview Source: https://b4.run/docs/recipes Each recipe answers one *how do I do X?* question in a single sitting. You get the goal, the code, the gotchas, and a link to the concept page if you want to go deeper. Concept pages explain the pieces, and recipes show them wired together. For route-level agent behavior, start with [Memory](https://b4.run/docs/memory), [Planning](https://b4.run/docs/planning), [Skills](https://b4.run/docs/skills), [Subagents](https://b4.run/docs/subagents), and [Reasoning](https://b4.run/docs/reasoning-effort). Those features don't have single recipes yet. #### Runnable developer agent The [code-fixer walkthrough](https://github.com/cacheplane/b4run/blob/main/examples/code-fixer/server/WALKTHROUGH.md) shows a complete agent that reproduces a failure, edits an isolated workspace, runs independent verification, scores the result, and pauses before local export. The example owns the fixture and the verification policy, and B4 supplies the runtime. Its README covers setup and a replay mode that makes no model call. #### Build - [Build a Flight Planner](https://b4.run/docs/recipes/flight-planner): a VFR flight planner with weather and performance subagents, planning, memory and a map Workbench. - [Add a Tool](https://b4.run/docs/recipes/add-a-tool): author a tool and call it with full types from a route. - [Typed State](https://b4.run/docs/recipes/typed-state): declare route state shapes and defaults. - [Retry Transient Model Calls](https://b4.run/docs/recipes/retry-flaky-tools): recover from transient model/provider failures. - [Dispatch from a Route](https://b4.run/docs/recipes/dispatch-from-route): call one route from another. #### Integrate - [Auth Middleware](https://b4.run/docs/recipes/auth-middleware): add execution authentication. The thread management/state, cancellation, memory-candidate, and health routes need outer host/proxy authentication. - [Stream Output](https://b4.run/docs/recipes/stream-output): send incremental responses with `runs/stream`. - [Flight Planner Web UI](https://b4.run/docs/recipes/flight-planner-web-ui): wire a CopilotKit client to the flight-planner demo over AG-UI. #### Test These guides cover testing: - [Scenario Testing](https://b4.run/docs/testing) - [Agent Test Harness](https://b4.run/docs/testing-agents) - [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures) #### Deploy These guides cover each supported deployment path: - [Deployment Options](https://b4.run/docs/deployment) - [Node and Docker](https://b4.run/docs/deployment/node) - [Kubernetes](https://b4.run/docs/deployment/kubernetes) --- ### Add a Tool Source: https://b4.run/docs/recipes/add-a-tool A tool is a TypeScript file in a route's `tools/` directory. Let's add a `lookup` tool to a `/research` workflow and call it with full types: `src/app/(public)/research/state.ts`: ```ts import { z } from "zod" export default z.object({ query: z.string(), count: z.number().default(0), }) ``` `src/app/(public)/research/tools/lookup.ts`: ```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 } } ``` `src/app/(public)/research/index.ts`: ```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 - **Types regenerate for you.** The dev server runs typegen on reload, and so do `b4 run`, `b4 test` and `b4 build`. Run `b4 typegen` yourself to refresh editor types. Use `b4 check` to validate the app without writing files. - **Two discovery locations.** B4.run finds tools in `/tools/` (only that route can use them) and in `src/tools/` (every route can). Put a tool in `src/tools/` when several routes need it. - **Declare types anywhere.** B4.run reads input and output types written inline, declared as local aliases or interfaces, or imported from another module. Keep the root input an object so B4.run can emit the model-facing tool schema. - **Plain JSON in, plain JSON out.** Tool inputs and outputs are serialized between the model and your code, so skip `Date`, `Map`, and class instances. - **In `agent` routes, the model calls tools.** It picks them by name once `b4 build` binds them. The example uses a `workflow` route to show the typed `ctx.tools` call. #### Related - [Tools](https://b4.run/docs/tools): the full authoring contract - [Routes](https://b4.run/docs/routes): the four route kinds and their tool semantics - [Typed State](https://b4.run/docs/recipes/typed-state): wire dynamic segments into state - [Generated route types](https://b4.run/docs/api/generated-routes#tools): RouteTools and generated tool signatures - [SDK Reference](https://b4.run/docs/api/sdk#b4runsdk-1): RuntimeContext and route-authoring types --- ### Typed State Source: https://b4.run/docs/recipes/typed-state A `state.ts` file gives a route typed state. Here's a `/hello/[tenant]` workflow whose state includes its tenant: `src/app/(public)/hello/[tenant]/state.ts`: ```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), }) ``` `src/app/(public)/hello/[tenant]/index.ts`: ```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" } } ``` B4.run fills route state from the input, never from a concrete path like `/hello/acme`. - **Parse workflow input yourself.** A workflow route receives its input as is. Calling `state.parse(input)` validates it and fills in fields declared with `.default(...)` when the caller leaves them out. - **This workflow imports and parses the schema directly,** so it can keep `tenant` required. For agent-state discovery, B4.run validates `{}` to extract defaults. If you reuse this schema for an agent route, make every top-level field accept missing input, or discovery skips the schema. - **State is JSON-serializable.** It's serialized on every `runs/wait` and `runs/stream` call, so use primitives, plain objects, and arrays. #### Related - [State](https://b4.run/docs/state): the full state contract, reducers, and merge rules - [Routes](https://b4.run/docs/routes): pathname conventions and dynamic segments - [Add a Tool](https://b4.run/docs/recipes/add-a-tool): read state inside a tool via ctx - [Generated route types](https://b4.run/docs/api/generated-routes#state): RouteState

and generated state contracts - [SDK Reference](https://b4.run/docs/api/sdk#b4runsdk-1): RuntimeContext and route-authoring types --- ### Auth Middleware Source: https://b4.run/docs/recipes/auth-middleware Let's add execution authentication with one middleware file. It rejects route-execution requests that don't carry a valid bearer token, and it passes the verified user id to every tool call in that run: `src/middleware.ts`: ```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 } ``` `src/app/(private)/account/[tenant]/tools/loadProfile.ts`: ```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 } } ``` `src/app/(private)/account/[tenant]/index.ts`: ```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 - **The tool name is the file name.** The file is `tools/loadProfile.ts`, so you call `ctx.tools.loadProfile(...)`. A file named `tools/load-profile.ts` would be `ctx.tools["load-profile"](...)`, so stick with camelCase file names. - **There's one global middleware.** `src/middleware.ts` runs once per route-execution request, before the route. For per-route policy, branch on `req.routeId`. - **`reject` short-circuits.** The route never runs, and the body of `reject(status, body?)` becomes the HTTP response. - **`allow(context)` flows to tools.** Whatever you pass becomes `ctx.middleware` on every tool call for that request. Call `allow()` with no arguments and it's `undefined`. - **Throwing returns 500.** Any uncaught error becomes HTTP 500. Return an explicit `reject(401, ...)` with a real body so callers get a useful response. - **Check where you deploy.** This middleware runs under `b4 dev`, the Node B4.run HTTP runtime (`b4 start` or the generated `server.mjs`), and Hono builds. The generated LangSmith graph entries leave B4.run's HTTP middleware out, so on LangSmith you'll enforce authentication in the platform itself. > **Warning: This is execution auth, not service auth** > > 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 the probes you mean to make public. See [Security Architecture](https://b4.run/docs/security-architecture) for which endpoints each layer covers. #### Related - [Middleware](https://b4.run/docs/middleware): the full middleware contract and MiddlewareRequest shape - [Tools](https://b4.run/docs/tools): how ctx.middleware reaches tool implementations - [Security Architecture](https://b4.run/docs/security-architecture): outer authentication and endpoint coverage --- ### Stream Output Source: https://b4.run/docs/recipes/stream-output A research run can take a minute or more. Let's stream its output as it arrives by calling `/threads/:id/runs/stream` and reading the SSE frames: `scripts/stream-research.ts`: ```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: "/navlog#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 it to the 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.token, subagent.tool_call, // subagent.tool_result, subagent.end, etc. console.log(`[${eventType}]`, payload) } } } ``` #### SSE event types Every event frame is `event: \ndata: \n\n`. Parse the `event:` line to tell the frame types apart. Every `subagent.*` payload carries the child's identity, written `…identity` below: `subagent` (its name), `route_id` and `depth`. Apart from that identity, a child event has exactly the shape of the root event of the same name. | Event type | Payload shape | Notes | |---|---|---| | `chunk` | `string` | Streamed text fragment from the model. | | `tool_call` | `{ name: string, input: unknown }` | The model is calling a tool. | | `tool_result` | `{ name: string, output: unknown }` | A tool returned a result. A tool that threw still emits one, and `output` is the `status: "error"` ToolMessage the model receives. | | `plan_update` | plan object | Emitted after a [Planning](https://b4.run/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` | `{ call_id, parent_call_id?, subagent, route_id, depth, description? }` | A subagent started. `parent_call_id` names the call that dispatched it when the parent is itself a subagent. | | `subagent.token` | `{ call_id, …identity, data, messageId }` | Text from the child, framed like root `chunk`s: `messageId` is the child's model invocation. | | `subagent.reasoning` | `{ call_id, …identity, data, messageId }` | The child's visible reasoning, when its route streams it. | | `subagent.message_end` | `{ call_id, …identity, messageId }` | The child's model invocation ended. | | `subagent.tool_call` | `{ call_id, …identity, id, name, input }` | Tool call inside a subagent, under the model's tool-call id. | | `subagent.tool_call_args` | `{ call_id, …identity, id, name, delta }` | Streamed arguments for that call, for display only. | | `subagent.tool_result` | `{ call_id, …identity, id, name, output }` | The result, under the same id. | | `subagent.usage` | `{ call_id, …identity, provider?, model?, usage_metadata }` | One child model call's token counts. | | `subagent.end` | `{ call_id, …identity, 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`, and the body is `{ route, input }`. Get a thread id from `POST /threads` first. - **The response is `text/event-stream`.** Parse the `event:` and `data:` lines in each frame, or use a client like `eventsource-parser`. - **Heartbeats are comments.** A `: ping` heartbeat has no `event:` or `data:` line, so clients skip it. - **Use `/threads/:id/runs/wait` when you don't need progress.** It returns one JSON result, which is less to parse. Pick `runs/stream` when partial output is useful: long agent reasoning, token-by-token text, intermediate workflow states, [planning](https://b4.run/docs/planning) updates, or [subagent](https://b4.run/docs/subagents) activity. - **Retry never repeats a token.** Each model call is retried only before its response starts. A response that fails after part of it has streamed isn't retried; the error comes through the stream. See [Retry](https://b4.run/docs/retry). #### Related - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): protocol endpoints and the assistant-id format - [Planning](https://b4.run/docs/planning): plan_update events - [Subagents](https://b4.run/docs/subagents): subagent.* events - [Retry](https://b4.run/docs/retry): streaming retry semantics - [Deployment Options](https://b4.run/docs/deployment): building deployable route artifacts --- ### Retry Transient Model Calls Source: https://b4.run/docs/recipes/retry-flaky-tools Providers fail now and then with rate limits and 503s. Let's make an agent route retry those, give a flaky tool its own retry loop, and turn retry off where failing fast matters: `src/app/(public)/summarize/[doc]/index.ts`: ```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.", }) ``` `src/app/(public)/summarize/[doc]/tools/fetch-doc.ts`: ```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") } ``` `src/app/(public)/billing/[invoice]/index.ts`: ```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 - **Retry is per route.** Each `agent()` declares its own `retry`, so different routes can have different policies. - **Each model call retries on its own.** `maxAttempts` is the attempts per model call, not per run. A failure in a later model call of the tool loop sends only that call again; tools that already ran don't run again. - **Only temporary failures retry.** Server errors (`5xx`), network errors, timeouts and rate limits are retried. A quota `429`, an invalid API key, a missing model and a malformed request fail immediately. - **`baseDelay` paces capacity rate limits.** A `429` with no `Retry-After` waits `min(baseDelay * 2^n + jitter, 10s)` before the next attempt. A `429` whose `Retry-After` is 60 seconds or less is waited out by LangChain; one over 60 seconds isn't retried, and the run fails at once. LangChain paces the other retries with its own backoff, starting at 1 to 2 seconds. - **`maxAttempts: 1` disables retry.** If you omit `retry`, the default is `3`. - **A response that fails partway isn't retried.** Retries happen before the model's response starts, so no token is ever sent twice. Once part of a response has streamed, an error comes through the stream. - **Tool retry lives in the tool.** Agent retry covers model and provider calls. For a tool, write the loop yourself, as `fetch-doc.ts` does above. #### Related - [Retry](https://b4.run/docs/retry): what each retry layer covers, and the backoff - [Routes](https://b4.run/docs/routes): agent() and the other route kinds - [Stream Output](https://b4.run/docs/recipes/stream-output): the streaming retry caveat in context - [SDK Reference](https://b4.run/docs/api/sdk#agent-and-agentconfig): agent() and the RetryConfig type --- ### Dispatch from a Route Source: https://b4.run/docs/recipes/dispatch-from-route Sometimes one route needs to hand work to another. For example, a coordinator agent might pass a research step to a specialist. There are two ways to do it, depending on where the other agent lives. #### Idiomatic: use `task()` with subagents When both agents live in the same project, use B4.run's built-in subagent dispatch. Add a `subagents/researcher/` directory next to your coordinator route, and the runtime 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 ``` Here is the subagent: `src/app/(public)/research/subagents/researcher/index.ts`: ```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.", }) ``` The coordinator's model then emits a `task` tool call like this one (you don't write this call yourself): ```ts task({ subagent: "researcher", input: "Find the latest LLM benchmark results for 2025.", }) ``` B4.run wires up the child route, runs it in the same process, and returns its final output as the tool result. See [Subagents](https://b4.run/docs/subagents) for the full convention and discovery rules. #### Advanced: cross-service dispatch To call an agent in a **different service**, such as a separately deployed B4.run project or any AP-compatible endpoint, use plain HTTP: `src/app/(public)/orchestrator/[job]/index.ts`: ```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}`) } // `null` when the remote route returned nothing, so narrow before reading a // property. `?.` guards the property, not the object it hangs off. const remoteState = (await res.json()) as { readonly messages?: readonly { readonly content?: unknown }[] } | null const lastMessage = remoteState?.messages?.at(-1) const summary = typeof lastMessage?.content === "string" ? lastMessage.content : "" return { ...state, summary } } ``` ##### Notes on cross-service dispatch - **The dev port changes.** `b4 dev` picks a random port unless you pass `--port `, so pass `B4_RUNTIME_URL` through the environment, or fix the port with `b4 dev --port 3001`. - **AP body shape.** The request body is `{ route, input }`, where `route` is the `assistant_id` string (for example `"/research#agent"`) and `input` is the route's state payload. - **Forward headers when needed.** Middleware runs on every `runs/wait` and `runs/stream` call, so if the target route's middleware expects auth headers, pass them on the inner `fetch`. - **Cancellation stays local.** Cancelling or shutting down the parent leaves the remote Agent Protocol run going. Dropping the wait request only disconnects a viewer. To pass cancellation along, keep 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.** It takes the same body and returns SSE. See [Stream Output](https://b4.run/docs/recipes/stream-output). #### Related - [Subagents](https://b4.run/docs/subagents): idiomatic task() dispatch and discovery rules - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): /runs/wait envelope and the assistant-id format - [Routes](https://b4.run/docs/routes): route entries and the typed RuntimeContext - [Stream Output](https://b4.run/docs/recipes/stream-output): incremental responses from a dispatched route - [SDK Reference](https://b4.run/docs/api/sdk#b4runsdk-1): RuntimeContext for route execution --- ### Build a Flight Planner Source: https://b4.run/docs/recipes/flight-planner Learn how to build a VFR flight planner with subagents, planning, memory and a web UI. This recipe starts from the navlog template. It picks up where [Getting Started](https://b4.run/docs/getting-started) leaves off. If you haven't built the hello agent yet, start there. #### What will we build? The template is a VFR flight planner for a Cessna 172N at `/navlog`. It briefs the weather from live aviationweather.gov data (no key), sends a `weather` and a `performance` subagent out, computes the navlog in code from the 1978 172N POH tables, and files an ICAO flight plan only when the pilot asks and a person approves. A browser client, the B4.run Workbench, sits in front of it. The unit tests and evals need no API key. Live runs need a real model and an OpenAI API key. #### Create the app First, scaffold the app and install its dependencies: ```bash npm create b4-app@latest my-navlog -- --template navlog cd my-navlog npm install ``` The `--template navlog` flag picks the flight-planner template instead of the default. You need Node.js 24 or later and npm 11. #### Look at the agents The app is an npm workspace with two packages. `server/` is the B4.run app, and `web/` is the Workbench. The root `npm install` installs both. The planning route lives in `server/src/app/navlog/`. Let's look at the coordinator, one of its subagents and the app config: `server/src/app/navlog/index.ts`: ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", // A plan fans out: recall → plan → two subagents → compute → brief. That // legitimately exceeds LangGraph's default 25 super-steps. recursionLimit: 100, description: "A VFR flight planner for a Cessna 172N: briefs weather, looks up POH performance, computes the navlog in code, and files a flight plan on request.", tools: { deny: ["runBash"], approve: [{ tool: "fileFlightPlan", allowAlways: false }] }, systemPrompt: `You are a VFR flight-planning assistant for a Cessna 172N. Given a request: 1. Start with \`recall({ query: "aircraft profile and pilot preferences" })\`. The profile holds the tail number, cruise RPM and usable fuel. Use what the pilot says in the request over the profile. For anything neither gives, do not stop to ask: use the POH defaults (usable fuel 40 gal for standard tanks, or 50 gal when the pilot says long-range tanks; "full tanks" means all usable fuel; cruise 2400 RPM) and list each default under Assumptions. When the pilot states aircraft facts the profile lacks, \`remember\` them. 2. Parse the request into departure, destination, optional waypoints, cruise altitude, departure time (UTC) and people on board. Ask only when the departure, the destination or the cruise altitude cannot be determined; ask for the departure time only when none is given. Never ask whether a clock time means the next occurrence: it does. If the pilot did not say how many people are on board, assume 1 and say so in the reply. Departure time: an ISO 8601 UTC instant such as 2026-10-06T14:00:00Z; a UTC clock time such as 1400Z, which means the next occurrence; or, when the pilot names the day, "today 1400Z" or "tomorrow 1400Z". Call \`resolveDeparture({ departure })\` with it exactly as given, keeping any day the pilot named, and use the departureUtc it returns everywhere after: for the weather subagent, for computeNavlog and under Assumptions. Never work out a date or hours ahead yourself. 3. Record the legs as todos. 4. Call \`lookupAirport\` for each airport you have not already looked up. 5. Dispatch \`task({ subagent: "weather", input: "" })\` and \`task({ subagent: "performance", input: "" })\`. The weather subagent estimates the ETA from the coordinates. 6. Call \`computeNavlog\` with the waypoints, the altitude, departureUtc as the departure time, the aircraft profile, personsOnBoard and one wind entry per leg from the weather brief. Never do navigation arithmetic yourself. 7. Save the navlog with \`writeFile({ path: "reports/-.md", content: "" })\`. 8. If a chart would help, \`renderChart({ title, series })\` with fuel remaining by checkpoint. 9. Reply with the planning answer below, and nothing else. 10. When the pilot states a durable preference or an aircraft fact, call \`remember({ data, content })\`. 11. File a flight plan only when the pilot asks; see Filing. Planning answer. Short and scannable, under about 180 words, in this order: - "Bottom line: GO|CAUTION|NO-GO — ". Use the weather brief's verdict unless performance makes it worse: a reserve under 45 minutes (reserveOk false) is NO-GO. - "Watch for:" up to 3 bullets, each a hazard with its altitude and its valid window relative to the flight, or "none during the flight". Leave out advisories that expire before departure or start after arrival; mention them in one trailing clause at most. If the navlog's departure or final ETA moves an advisory into or out of the flight, judge it by the navlog's times. A freezing level below the cruise altitude means cruising in below-freezing air; say so plainly. - "Numbers:" distance; ETE, which is takeoff to landing, the navlog's total time; fuel burned, which already includes 1.1 gal for start, taxi and takeoff (say so once, and never tell the pilot to add it again); fuel at landing; reserve as hours and minutes (4:24). Write plain numbers with units, never tool field names such as reserveOk. Cite the POH cruise figure once inline, as [poh/.md, Figure N]. - "Assumptions:" the departure instant in UTC with its date; people on board ("1 assumed — tell me if different" when the pilot did not say); any POH default you used for usable fuel or cruise RPM; and, when the weather brief says so, that the forecast is preliminary. - One closing line offering to file the plan, try another altitude, or re-brief closer to departure. The Workbench shows the navlog table and the weather, so never repeat the leg table. Filing. When the pilot asks to file, call \`fileFlightPlan({ flightPlan })\` once with the flightPlan from the latest computeNavlog. The runtime asks the pilot to approve that call; never ask for approval yourself. After it returns, reply in 2 to 4 lines: - "Recorded the flight plan for →, departing Z . This demo records it in the workspace; it does not transmit to Flight Service." - One line reading the key items correctly: item 15 is cruise speed, level and route (N0109VFR DCT is 109 kt true airspeed, VFR, direct), and item 16 is the destination and the total estimated elapsed time (KDLH0125 is KDLH, 1 h 25 min). Never ask for approval again after the tool ran, and never say the plan was or will be transmitted. If the pilot denied the call, say the plan was not recorded. Never write tool-call syntax such as recall({...}), a to-do list or its statuses, a list of what you ran, workspace file paths, or a citations section in a reply.`, }) ``` `server/src/app/navlog/subagents/performance/index.ts`: ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", description: "Looks up Cessna 172N POH performance for the actual fields: takeoff and landing distances, the cruise row to use, and climb figures, with figure citations.", // A subagent sees every authored tool by default; allow only re-adds // withheld capability tools. Deny the weather subagent's tools and the // parent's, so each child does its own job and nothing else. tools: { allow: ["readDoc", "lookupAirport"], deny: [ "getMetar", "getTaf", "getWindsAloft", "getAdvisories", "computeNavlog", "fileFlightPlan", "renderChart", "runBash", "writeFile", "editFile", ], }, systemPrompt: `You are a performance planner for a Cessna 172N. Given airports, a cruise altitude and a cruise RPM: - \`lookupAirport\` each airport for field elevation and runways. - \`readDoc\` the tables you need: poh/takeoff-distance.md, poh/landing-distance.md, poh/cruise-performance.md, poh/time-fuel-distance-to-climb.md, and regs/vfr-cruising-altitudes.md when the altitude looks wrong for the direction of flight. - Report takeoff distance at the departure field elevation and the given temperature (standard if none is given), landing distance at the destination, the cruise row (RPM, %BHP, KTAS, GPH) at the cruise pressure altitude, and the climb time, fuel and distance. Interpolate between rows and say so. - Assume standard temperature when no temperature is given; never ask the pilot for it. - Cite every number as [poh/.md, Figure N]. Never invent a number the tables do not support.`, }) ``` `server/b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ appDir: "src/app", toolOutput: { offloadThresholdChars: 12000, previewLines: 10, }, memory: { writes: "candidate", }, }) ``` The coordinator plans the flight, sends the `weather` and `performance` subagents out, and calls `computeNavlog` for every number on the navlog. The tools are shared in `server/src/tools/`, and each subagent's `tools` scope keeps it to its own. `fileFlightPlan` asks a person before each call. The config keeps tool output inline up to 12,000 characters and makes new memories wait for review. Threads persist to SQLite by default, so they survive a restart. A few more files shape the agent: ``` server/src/app/navlog/ plan.md # seeds each thread's todo list memory.md # route-specific prompt guidance memory.ts # typed cross-session memory subagents/weather/ # METARs, TAFs, winds aloft, advisories subagents/performance/ # POH tables for the actual fields skills/brief-weather/ # loaded on demand: go/no-go brief skills/poh-lookup/ # loaded on demand: reading and citing the POH evals/navlog-quality.eval.ts server/src/tools/ computeNavlog.ts # the navlog, computed in code fileFlightPlan.ts # records an ICAO flight plan, after approval getMetar.ts getTaf.ts getWindsAloft.ts getAdvisories.ts lookupAirport.ts resolveDeparture.ts # turns "tomorrow 1500Z" into one UTC instant readDoc.ts # reads a POH table or regulation excerpt renderChart.ts # draws a bar chart the user sees server/src/lib/ # the math, POH tables and parsers the tools call server/workspace/ AGENTS.md # prompt guidance injected every turn poh/ regs/ # the POH tables and regulation excerpts ``` - `plan.md` turns on [planning](https://b4.run/docs/planning). Its checklist seeds each thread's todos. - `workspace/AGENTS.md` and the route's `memory.md` add persistent prompt guidance. - `memory.ts` defines typed [memory](https://b4.run/docs/memory) records that the agent reads with `recall` and writes with `remember`. - The agent loads [skills](https://b4.run/docs/skills) under `skills/` by name when it needs them. #### Verify and test Run these from the workspace root. Each script delegates to the package that owns it: ```bash npm run typegen npm run check npm run typecheck ``` `typegen` writes `server/.b4/b4.generated.d.ts`. `check` validates routes, tools and configuration without writing files: ``` B4.run app is valid: 3 routes discovered. - /navlog (agent) - /navlog/subagents/performance (agent) - /navlog/subagents/weather (agent) ``` Next, run both packages' tests: ```bash npm test ``` The server tests are unit tests of the navlog math, the POH tables, the weather parsers and the tools, with `fetch` stubbed. The web tests cover the Workbench. Neither needs an API key. Then run the quality eval: ```bash npm run eval ``` The eval plans three flights. Its scorers require both subagents, a well-formed navlog whose totals add up with a legal reserve, a POH citation, no filing unless asked, and a passing quality grade from a model judge. Scripted fixtures cover the agent and the judge, so the eval runs without an API key; the weather tools still reach aviationweather.gov. #### Run it live These fixture-backed runs prove the app works without a key. To plan a new flight, we need a real model. Copy the server's environment example, add your API key, run the preflight and start the dev server: ```bash cp server/.env.example server/.env # Add your OPENAI_API_KEY to server/.env npm run verify npm run dev:server ``` `verify` checks the app, its types, its dependencies, Node and the provider environment. The dev server listens on `http://127.0.0.1:3002` and serves Agent Protocol and AG-UI. In a second terminal, ask for a flight: ```bash cd server echo '{"messages":[{"role":"user","content":"Plan a VFR flight from KSTP to KRST at 4500 feet, departing 1400Z, in N738ZU (172N, 2400 RPM, 50 gal usable), and save the navlog."}]}' | npx b4 run /navlog --url http://127.0.0.1:3002 ``` The navlog lands in `server/workspace/reports/`. Thread state is saved to `server/.b4/checkpoints.sqlite`, so threads survive dev-server restarts. #### See it in a UI The Workbench is a chat UI over the same agent. Leave the server running and start it in another terminal: ```bash npm run dev:web ``` Open [http://localhost:3010](http://localhost:3010) and choose **Plan a flight**. You'll see the plan card and the `weather` and `performance` subagent cards update as the agent works, tool cards, the flight-plan approval and a panel for reviewing memory. The Workbench talks to B4.run over [AG-UI](https://b4.run/docs/ag-ui) and holds no model credentials. Those stay in `server/.env`. To build your own client, follow [Flight Planner Web UI](https://b4.run/docs/recipes/flight-planner-web-ui). The scaffold also installs the [Inspector](https://b4.run/docs/inspector). Open it in a third terminal to review the records the agent saves with `remember`: ```bash npx b4 inspect --cwd server ``` When you're ready to ship, compare [Deployment Options](https://b4.run/docs/deployment) and follow [Node and Docker](https://b4.run/docs/deployment/node) for the default self-hosted path. #### Related - [Subagents](https://b4.run/docs/subagents): hand work to a specialist - [Planning](https://b4.run/docs/planning): seed and track a todo list - [Memory](https://b4.run/docs/memory): remember across threads --- ### Flight Planner Web UI Source: https://b4.run/docs/recipes/flight-planner-web-ui B4.run's navlog demo, a VFR flight planner, ships with a [CopilotKit](https://docs.copilotkit.ai) web client that talks to the server over [AG-UI](https://b4.run/docs/ag-ui). Let's walk through how it's wired. The client is a workbench, not a chat widget. CopilotKit's stock `` sits in a floating dock over a route map, inside B4.run's ``, so the streamed brief, the turn's plan, subagent and tool steps, and the approval card all appear in message order, while the map, the navlog sheet, and the weather strip read the same turns from outside the chat. This recipe covers the application wiring. For the endpoint, events, threading, and interrupt outcomes, see [AG-UI and Web Clients](https://b4.run/docs/ag-ui). #### What you'll build The demo lives in `examples/navlog`: 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 ``, with B4.run's chat slots (`useB4ChatSlots`) swapped in. - **Activity:** one `TurnActivity` per turn: a summary line, then the plan, the `weather` and `performance` subagents (nested, with their own plan and tool steps), and each tool call as a step. `computeNavlog` and `renderChart` get their own opened views. - **Map, sheet and weather strip:** the route, the navlog legs, and the weather chips are derived from the activity turns (`useB4ActivityContext`), not from a second subscription. - **Suggestions:** three discovery prompts on the empty chat. - **Permissions:** the kit's `ApprovalCard` owns approve and deny, rendered in the chat where the run stopped. - **Input:** ``'s own input, with an image attachment when the route's model takes one. It waits while an approval is open. - **Media:** images, audio, video, and documents in user messages and tool results, plus a notice for each part the model never saw. The activity steps only display progress. Interrupts are resolved through the standard AG-UI fields described on the protocol page. The Workbench also reviews durable memory. A small panel reaches B4.run through an allowlisted, same-origin proxy. See [Approve memory candidates](#approve-memory-candidates) below. #### Run it ##### Step 1: Configure the server ```bash cd examples/navlog/server cp .env.example .env # set OPENAI_API_KEY here, not in the web app ``` ##### Step 2: Start both apps ```bash cd examples/navlog pnpm install pnpm dev # B4.run server on :3002, web client on :3010 ``` Open [http://localhost:3010](http://localhost:3010) and choose **Plan a flight**. #### Connect the client to B4.run The Next.js runtime route registers a `B4HttpAgent`, an AG-UI `HttpAgent` that also reports the route's [capabilities](https://b4.run/docs/ag-ui#capabilities), pointed at the encoded B4.run assistant id. Register it under CopilotKit's `default` agent id so every hook binds without per-component wiring: `examples/navlog/web/app/api/copilotkit/[...path]/route.ts`: ```ts import { B4HttpAgent } from "@b4run/ag-ui/client" import { createB4AgentRunner } from "@b4run/ag-ui/copilotkit-runtime" import { CopilotRuntime, createCopilotRuntimeHandler, InMemoryAgentRunner, } 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("/navlog#agent")}` const handler = createCopilotRuntimeHandler({ runtime: new CopilotRuntime({ agents: { default: new B4HttpAgent({ url: agUiUrl }) }, // Restores a thread on `connect` from B4.run's storage. runner: createB4AgentRunner(InMemoryAgentRunner, { url: b4Url }), }), basePath: "/api/copilotkit", }) export const GET = handler export const POST = handler ``` `examples/navlog/web/app/page.tsx`: ```tsx // Excerpt: the provider tree. Thread state and its handlers live in the same // file, over `app/lib/thread-source.ts`. ``` The catch-all route is required. It exposes CopilotKit's V2 REST and SSE paths under `/api/copilotkit/*`. Setting `useSingleEndpoint={false}` makes the browser start with `GET /api/copilotkit/info` rather than send the legacy method envelope to the base URL. Only the B4.run server has `OPENAI_API_KEY`. The web runtime holds no model credential. > **Warning: Register the agent as default** > > 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. Two pieces of that tree matter most, plus the suggestions: - `defaultThrottleMs={100}`: the re-render throttle defaults to *unthrottled*, and a full planning 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. - `` renders nothing. It publishes the starter prompts into CopilotKit's suggestion registry, and `` shows them on an empty thread. #### The workbench shell `AppShell` is the only component that talks to the agent. It calls `useAgent()` with no arguments. That unscoped form takes its thread from the surrounding chat configuration, so ``, `B4Activity`'s interrupt handling, `useSuggestions`, and the shell all resolve the same agent and thread. The workbench lives inside ``. The key matters: B4Activity's pending approval card is cleared only by a new run, so an activity that outlived a thread switch would show the previous thread's approval on the next one. The key also restarts the turns from empty and remounts the chat, which connects the new thread. `examples/navlog/web/app/components/NavlogChat.tsx`: ```tsx // Inside B4Activity: the stock chat with B4.run's slots. const slots = useB4ChatSlots() ``` `useB4ChatSlots` renders each turn as a `TurnActivity` and a parked interrupt as an `ApprovalCard`. The example's `NavlogChat` adds echo-free prose, named input controls, and the image attachment below. `renderStep` on `B4Activity` (`app/components/StepViews.tsx`) gives `computeNavlog` a one-line totals view with a link to the sheet, and `renderChart` its image. Run failures still 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 the shell turns into a banner above the chat. Threads are local to the browser. B4.run's server can create and fetch a thread by id, but it has no endpoint to list threads. So the rail keeps its own list in `localStorage` behind a `ThreadSource` interface (`examples/navlog/web/app/lib/thread-source.ts`), and the app skips CopilotKit's `useThreads`. The rail holds ids, titles and recency only. The map and the sheet sit outside the chat but inside `B4Activity`. `useB4ActivityContext()` returns the same turns the chat renders, and pure selectors (`app/lib/navlog-selectors.ts`, `weather-selectors.ts`) pick the latest `computeNavlog` result and `weather` brief from them. ##### Restoring a thread `` calls `connect` when it mounts, and the runtime route's runner (`createB4AgentRunner`) answers it by replaying `GET /threads/:id/events` from B4.run's checkpoints as AG-UI events, so a restored thread and a live one go through the same path: the chat messages, the turns, and a parked approval all come back, with the media the thread carried. The browser never reads thread state itself, so the proxy has no thread routes. A brand-new thread has nothing to replay and restores as an empty chat. See [Restoring a conversation after a reload](https://b4.run/docs/ag-ui#restoring-a-conversation-after-a-reload) for the runner and the per-request identity it needs. To restyle the app, edit `examples/navlog/web/app/theme.css`. It defines the palette as CSS variables and re-exports it as Tailwind tokens through `@theme inline`. That's why the app's utilities read `bg-wb-surface`, `border-wb-border`, and `rounded-wb`. #### Attach an image The attach control appears only when the route's model takes an image. `AppShell` reads that from the route's [capability document](https://b4.run/docs/ag-ui#capabilities): `examples/navlog/web/app/components/AppShell.tsx`: ```tsx const capabilities = useCapabilities() const canAttachImages = capabilities?.multimodal?.input?.image === true ``` There is no browser call to make here. In the browser, the agent is CopilotKit's runtime proxy, and it carries what the runtime's `/info` sync fetched through `B4HttpAgent.getCapabilities()`. `useCapabilities()` returns `undefined` until that handshake lands, which keeps the control hidden until then. `NavlogChat` hands `` an `attachments` prop with `enabled: canAttachImages`, the accepted image types, and a size cap. The chat reads a picked file into an inline `image` part and sends it with the message; a text-only turn stays a plain string. `examples/navlog/web/app/components/NavlogChat.tsx`: ```tsx const attachments = { enabled: canAttachImages, accept: ATTACHABLE_IMAGE_TYPES.join(","), maxSize: MAX_ATTACHMENT_BYTES, onUploadFailed: (failure) => setAttachNotice(attachmentFailureText(failure)), } ``` It accepts PNG, JPEG, GIF and WebP images, the types OpenAI's vision input takes, and refuses any file over 4 MB with one dismissible line above the chat. The cap matters because every turn sends the whole conversation to `/agui`, whose request body limit is 8 MiB, so one large image would break every turn after it. ##### Media in the chat Tool steps draw their media with `app/components/MediaParts.tsx` (user messages carry theirs through ``): an `` for an image, the native players for audio and video, a download chip for a document, and a chip naming the handle for a provider `file` source, which the browser can't load. A `url` source is loaded only when it's `http(s)`, because tool results are model-driven. Each part's MIME type has to match what the part claims to be. An image part whose type isn't `image/*` renders as a chip, and so does audio or video with the wrong type. Only PDF, CSV, Markdown and plain-text documents get a download link, and the downloaded file is named from the type (`document.pdf`), never from the part's filename, which is shown only as the label. A tool result's media arrives on the step as `parts`, live and after a restore, so `ChartStepView` (`app/components/StepViews.tsx`, registered for `renderChart` through `B4Activity`'s `renderStep`) draws it inside the opened step with `MediaParts`. ##### Parts the model didn't see When the model or its provider can't take a part, B4.run drops it and emits a `CUSTOM` event named [`b4.content_parts_dropped`](https://b4.run/docs/ag-ui#multimodal-content). `AppShell` subscribes with `onCustomEvent` and shows each event as a muted line in the dock above the chat, such as `1 content part was not sent to the model: image (tool_result_media_unsupported)`. CopilotKit keeps no record of `CUSTOM` events, so the notices live only in the shell's state. Switching threads clears them, and a reload or a restored thread doesn't bring them back. ##### Media after a restore The replay carries media. A user message that held an attachment comes back as AG-UI content parts, and a tool that returned parts keeps the full list in the tool message's `additional_kwargs.b4_content_parts`, which the replay sends as the tool result's parts. The UI shows what the tool produced, even where the model's own copy held only text. A user image the adapter dropped is a different case. It never reached the model, so it isn't in the checkpoint, and it doesn't come back when the thread is restored. ##### A chart the model doesn't see The navlog server ships `renderChart` (`server/src/tools/renderChart.ts`). Given a title and up to 12 `{ label, value }` pairs, it returns two parts: a one-line text summary and an inline SVG bar chart. `examples/navlog/server/src/tools/renderChart.ts`: ```ts return [ { type: "text", text: summary }, { type: "image", source: { type: "data", value: base64(svg), mimeType: "image/svg+xml" } }, ] ``` The user sees the chart in the opened `renderChart` step. The model, `gpt-5-mini`, sees only the summary, because media in a tool result never reaches an OpenAI model. So every `renderChart` call also produces a dropped-part notice. That's expected: the notice is telling you the truth about what the model saw. #### Render plan and subagent activities The navlog example gets the plan and the subagents from `B4Activity`: each turn's `TurnActivity` shows the plan as a step and nests the `weather` and `performance` subagents with their own plan and tool steps, from the same AG-UI events. Choose **Plan a flight** on the empty chat to watch them update before the brief. A client that doesn't use `B4Activity` gets the same view from the same events. `useB4Turns(agent)` from `@b4run/ag-ui/react/copilotkit` (or `reduceTurns` from `@b4run/ag-ui/view`, with no React) folds the agent's event stream into turns, and `TurnActivity` from `@b4run/ag-ui/react` renders one: ```tsx import "@b4run/ag-ui/styles.css" import { useB4Turns } from "@b4run/ag-ui/react/copilotkit" import { TurnActivity } from "@b4run/ag-ui/react" import { useAgent } from "@copilotkit/react-core/v2" function Activity() { const { agent } = useAgent() const { turns } = useB4Turns(agent) return turns.turns.map((turn) => ) } ``` The plan is a step in its turn, "Made a plan · 2 of 4 done", with the checklist inside. A subagent is a step too, "Asked weather" over the subagent's description, with the child's own plan and tool steps nested under it. The stylesheet carries no inline styles, so without that import the kit renders as bare markup. To restyle it, override the `--b4-activity-*` custom properties in your own CSS, as the navlog example does in `app/theme.css`. React and `@copilotkit/react-core` are optional peer dependencies of `@b4run/ag-ui`, and only the `./react` and `./react/copilotkit` subpaths need them. A server that uses the root or `./sse` entry installs nothing extra. Plan snapshots replace `b4:plan:${runId}` (the root agent's) or `b4:plan:${call_id}` (a subagent's, tagged `subagentRunId`) and carry the complete todo list; the plan step shows every todo. The plan activity is the whole presentation of `writeTodos`: when a `writeTodos` call produced its activity, B4.run's AG-UI adapter emits no tool call/result events for that call. A `task` call is an ordinary tool call, and the turn shows it as the subagent step it starts. If the plan activity cannot be produced (no tool-call id, a malformed payload), the adapter fails open and the ordinary tool events survive; the view still places a plan step for the call, empty until a snapshot arrives. The live planning flow uses the B4.run server's model key. The automated browser check only proves V2 transport selection. #### 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`](https://b4.run/docs/configuration#server). Without it, 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**. `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 and nothing else. Thread history isn't proxied: the runtime route's runner reads it server to server. Anything else is rejected with 403 and never forwarded. That keeps `POST /threads/:id/resume` and the rest of the agent surface out of reach in a template every B4.run developer copies. > **Info: Allowlist before you extend** > > Running, resuming, and cancelling a thread are left off the list on purpose. > Those go through CopilotKit's own runtime route. Add a route to the allowlist > only when the browser really needs it, and prefer reads. Note, the proxy > forwards with no authentication and the example installs no `threadAccess` > policy. The allowlist limits which routes are reachable, not who can reach > them, so anything that can reach the Next app and holds a thread id can > restore that thread through the runtime route unless the runner's `fetch` > carries a per-request caller. 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`, which is a hard delete on the server. The panel loads on mount and again at the end of every run. `remember()` lands mid-run, so a memory proposed in the answer you are reading should be reviewable without a reload: `examples/navlog/web/app/components/MemoryPanel.tsx`: ```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 only reviews candidates. It lists at most three and counts the rest. Browsing, searching, and editing stored memories is still the job of the [`b4 memory`](https://b4.run/docs/memory/long-term) CLI. > **Info: Deep runs need a higher recursion limit** > > The coordinator plans, dispatches subagents, and makes many tool calls. The > navlog route sets `recursionLimit: 100` so a full run can exceed LangGraph's > default step ceiling. #### Related - [AG-UI and Web Clients](https://b4.run/docs/ag-ui): The adapter, endpoint, and event contract - [Getting Started](https://b4.run/docs/getting-started): Scaffold an agent locally - [Subagents](https://b4.run/docs/subagents): How the coordinator dispatches subagents internally - [Long-term Memory](https://b4.run/docs/memory/long-term): candidates, approval, and recall --- ### Blueprints Source: https://b4.run/docs/blueprints A blueprint is a guide for adding an integration or a runnable agent example to your B4.run app. `b4 add ` fetches the guide and prints it. You hand it to your coding agent (Claude Code, Cursor, and so on), and the agent applies it to your project: it installs dependencies, creates files, and wires them in. Blueprints are Markdown files served from `b4.run`, and your agent applies them as ordinary code in your app. They cover external integrations and complete agent examples built from B4.run routes and tools. For framework *patterns*, like writing a tool or typing state, see [Recipes](https://b4.run/docs/recipes). #### 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 code-fixer # install a runnable developer-agent example b4 add # apply a third-party blueprint from any URL ``` Pipe the output to your coding agent, or paste it in. `b4 add` only prints the guide. Your agent makes the changes, and you review them. To use a self-hosted catalog (or a local dev server), set `B4_BLUEPRINTS_URL`. #### Code-fixer agent example `b4 add code-fixer` prints a guide for installing the example from a pinned source commit with B4.run 0.11.0 packages. The agent repairs a real, historical CLI defect in a Docker workspace, verifies the patch independently, and asks for your approval before exporting it locally. You'll need Docker, Git, and Node 24. The verification tests use scripted model responses, so you don't need a model API key. If your app's sandbox or permission settings conflict with the example, the guide installs it into a sibling app. It keeps your edits and records where the installation came from. Read the [installation guide](https://b4.run/blueprints/code-fixer.md) for setup and verification. #### 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** (`agents`, `observability`, `retrieval`, `deploy`). The category is used for listing only, never in the command. Only `description` is required in the frontmatter: ```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 a guide written for an agent. Open with the intent and scope ("you are adding X, and it does Y"). Then cover the prerequisites, inspecting the project, installing dependencies, creating the file(s), wiring them in, configuring the environment, and verifying. Mark the first line of the main generated file with the blueprint and version that produced 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 assume them. --- ### Configuration Reference Source: https://b4.run/docs/configuration `b4.config.ts` configures your B4.run app. Every `B4Config` field is optional, but the file is required, because the CLI finds your app by looking for it next to `package.json`. Node loads it once, and `b4 dev` restarts its child runtime when you edit it. Filesystem-free runtimes receive a config object and never read the file. Run [`b4 verify`](https://b4.run/docs/cli) before you serve the app. It checks the config, routes, Node version, provider credentials and sandbox preflight. #### Complete annotated example You can copy this file as written. Uncomment an instance-valued field once you've imported or built its implementation: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ appDir: "src/app", // Set writable to false to make workspace/AGENTS.md read-only guidance. agentsMd: { writable: true }, // 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, }, approvals: { grants: "off", // grantTtlMs: 86_400_000, // grantStore: sharedInterruptGrantStore, }, // 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"], // vercel: { outDir: "dist/vercel" }, // where the "vercel" target publishes (default .vercel/output) }, // Cross-origin access. Omit it and the runtime sends no `Access-Control-*` // header, so a browser on another origin cannot call it. // `localhost` and `127.0.0.1` are different origins to a browser. server: { cors: { origins: ["https://app.example.com"] }, }, // sandbox: { // provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }), // network: { mode: "deny" }, // // network: { mode: "deny", allowlist: ["10.0.0.0/8"] }, // Kubernetes CIDRs; Docker ignores it // // network: { mode: "allow" }, // open egress, cloud metadata endpoint included // 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, retry: { maxAttempts: 3 }, 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"`. This is the directory, relative to the app root, where B4.run looks for route `index.ts` files. Keep it inside the app root. There's no environment variable override. See [Routes](https://b4.run/docs/routes) for the layout. ##### `agentsMd` ```ts agentsMd?: { writable?: boolean } ``` Default: `writable: true`. B4.run injects `workspace/AGENTS.md` into every agent route's system prompt on each turn. By default it's the agent's [memory](https://b4.run/docs/memory#workspace-profile-agentsmd): the prompt tells the model to update the file with `writeFile` when it learns something worth keeping. Set `agentsMd: { writable: false }` when the file is shared, reviewed house style that one thread mustn't change for every other. The prompt then presents it under `# Project guidance` as read-only, maintained by the app's authors, and tells the model not to modify it. This only changes the prompt. It doesn't block a write, so enforce it with a `FilesystemMiddleware` that refuses writes to `AGENTS.md`, composed over `backends.filesystem` (see [Workspace middleware](https://b4.run/docs/workspace#middleware)). That middleware doesn't see shell commands, so also deny or gate `runBash` for routes that can reach the workspace. `b4 check` and route preparation reject, with `B4_E1010`, an `agentsMd` that isn't an object (`agentsMd: false` included), any key other than `writable` (a typo such as `writeable` included), and a `writable` that isn't a boolean. A misspelled or mistyped key inside `agentsMd` is an error, not a silent fallback to writable. ##### `backends` ```ts backends?: { filesystem?: FilesystemBackend exec?: ExecBackend } ``` Defaults: the local filesystem and a local child-process executor. A sandboxed thread uses the sandbox's filesystem and executor. Every other thread uses the backends you configure here, or the local defaults. Live backend objects can't be serialized into a static edge build. See [Workspace Filesystem](https://b4.run/docs/workspace) for backend composition and the path jail. ##### `permissions` ```ts permissions?: { mode?: "interactive" | "non-interactive" | "bypass" allow?: Readonly> deny?: Readonly> store?: PermissionsStore } ``` Without a custom store, the defaults are `mode: "interactive"`, empty `allow` and `deny` maps, and `.b4/permissions.json` for saved interactive decisions. `B4_PERMISSIONS_MODE` overrides `permissions.mode`. A deny match beats an allow match. The reserved `tool`, `subagent` and `clientTool` 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. Only an interactive **Always** decision saves an allow entry. See [Permissions](https://b4.run/docs/permissions) for the modes and the interrupt/resume lifecycle. ###### `permissions.store` A `PermissionsStore` you supply replaces the file-backed store, and B4.run loads it before first use. Your store decides its own mode and allow/deny policy, so B4.run ignores the sibling `permissions.mode`, `allow`, `deny` and `B4_PERMISSIONS_MODE`. In an embedded runtime, a store passed at boot wins over this field. ##### `approvals` ```ts approvals?: { grants?: "off" | "optional" | "required" grantTtlMs?: number grantRetentionMs?: number grantStore?: InterruptGrantStore } ``` `grants` decides whether a parked approval carries a single-use grant that must be echoed on resume. Default `"off"`, which is the pre-grant behavior. Under `"optional"` an interrupt that **has** a grant requires it while one parked without a grant resumes as before; under `"required"` a resume with no grant is refused and a park that cannot mint one aborts the turn. The setting is process-wide and ratchets up only, so two app roots in one process share the strictest mode either asks for. `grantTtlMs` is omitted by default, meaning no expiry — a human approval may legitimately sit overnight. `grantStore` defaults to SQLite at `.b4/interrupt-grants.sqlite` on node and to a non-durable in-process store elsewhere, so a multi-replica deployment must supply one. See [Approval Grants](https://b4.run/docs/approval-grants). `grantRetentionMs` is how long a grant record voided because the thread moved past its prompt is kept before the runtime deletes it: `604800000` (7 days) by default, a positive integer of at most one year, anything else fails the boot. A consumed grant is voided when its resumed turn completes. The sweep runs wherever the runtime voids superseded grants, at most once an hour per store. An outstanding grant, or a consumed grant whose resume never completed, is never deleted, however old, because a parked prompt with no grant row resumes ungated under `"optional"`. [`b4 approvals prune`](https://b4.run/docs/cli#b4-approvals) runs the same pass by hand. A `grantStore` you supply is checked at boot while grants are on and must implement every store method, `prune` included. ##### `checkpointer` ```ts checkpointer?: BaseCheckpointSaver ``` Default: SQLite at `.b4/checkpoints.sqlite`. A checkpointer passed at boot wins, then this field, then the default. Checkpoints hold graph state. Thread metadata lives in `threadsStore`. Read [Persistence and Tenancy](https://b4.run/docs/persistence) before you share or delete state across replicas. ##### `threadsStore` ```ts threadsStore?: ThreadsStore ``` Default: SQLite at `.b4/threads.sqlite`. A thread store passed at boot wins, then this field, then the default. It holds Agent Protocol thread metadata. Graph state lives in `checkpointer`, so configure both when replicas share threads. See [Persistence and Tenancy](https://b4.run/docs/persistence) for what each store holds and the order to delete in. ##### `env` ```ts env?: string ``` Default: `"./.env"`, relative to the app root. Only `b4 dev` and `b4 inspect` load it, and they check `--env-file` first, then `config.env`, then the default. `b4 verify` checks the resolved file without loading it. `b4 start` ignores this field, so production variables come from the shell or your hosting platform. The LangSmith artifact picks its own file: `.env.example` when it exists, and `.env` otherwise. Its JSON records an env-file path, not variable names, and it ignores `config.env`. See [Deployment Options](https://b4.run/docs/deployment) for how each target gets its environment. ##### `toolOutput` ```ts toolOutput?: { offloadThresholdChars?: number previewLines?: number maxBytes?: number ttlMs?: number gcThrottleMs?: number noOffloadTools?: readonly string[] } ``` On a Node runtime with a workspace filesystem, the 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 `readFile` and `listDir` are always exempt. Each value you set replaces just that default. Offloaded data lives under `workspace/tool-outputs/`. It keeps large outputs out of the context window, and it gets cleaned up. A non-empty block fails on the filesystem-free Hono target (`B4_E1005`). See [Context Management](https://b4.run/docs/context-management) for retrieval and cleanup. ##### `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-loaded `gpt-tokenizer` `o200k_base` counter, and the built-in one-call summarizer. Each field you set replaces its default. Summarization needs a model, so set `summarization.model` if the route doesn't have one. See [Context Management](https://b4.run/docs/context-management) for when history gets compressed and what's kept word for word. ##### `build` ```ts build?: { targets?: readonly BuildTargetName[] vercel?: { functionName?: string maxDuration?: number static?: { dir: string; spaFallback?: string } functions?: Readonly> routes?: readonly { src: string; dest?: string; [key: string]: unknown }[] outDir?: string reconcileVercelJson?: boolean } } ``` Default: `["node", "langsmith"]`. A `targets` array you set replaces that list rather than adding to it. `"hono"` and `"vercel"` are the opt-in edge targets. Pick only targets whose runtime supports the features you use. See [Deployment Options](https://b4.run/docs/deployment). `BuildTargetName` is the union `"node" | "langsmith" | "hono" | "vercel"`. It's exported from `@b4run/core` along with the `BUILD_TARGET_NAMES` tuple it comes from, so a misspelled target fails to type-check before you ever run `b4 build`. `vercel` describes what the `"vercel"` target publishes next to the runtime function: - a static directory, with an optional SPA fallback document, - extra Node functions, each bundled from an `entry`, and - routes placed ahead of the filesystem phase. Paths resolve from the app root. The runtime function is named `b4`, because the Build Output API serves a function named `index` at `/`, where it would shadow a static `index.html`. `functionName` overrides the name, and `"index"` next to `static` fails the build. See [the `vercel` target](https://b4.run/docs/deployment/edge#the-vercel-target) for the published tree and route order. `vercel.maxDuration` is the runtime function's `maxDuration` in seconds, a positive integer. Leave it out and `.vc-config.json` omits it, so Vercel applies the project default. The runtime function runs the agent, so a long tool-using run hits the limit there first. Each function under `vercel.functions` sets its own. `validateVercelOutput` requires the published value to match the configured one. `vercel.outDir` is where the Build Output tree is published, relative to the app root. It defaults to `.vercel/output`, which is where Vercel deploys from. Point it elsewhere when a later step composes the tree before deploying. `b4 build --out-dir

` overrides it for one run. Each build replaces the directory wholesale, so B4.run rejects an `outDir` that contains the app root. `vercel.reconcileVercelJson` (default `true`) controls whether the `"vercel"` target keeps the app-root `vercel.json` in line with what it needs: a `buildCommand` that runs `b4 build`, and `fluid: true`. Set it to `false` for a prebuilt flow, where CI runs `vercel deploy --prebuilt` and there's no Vercel Git integration. Vercel never runs `buildCommand` in that flow, so the target stops requiring, writing or inspecting a committed `vercel.json`. Keep fluid compute on in the Vercel project settings, because the build no longer catches a committed `fluid: false`. One resolver validates all of `build.vercel`. Both `b4 check` and `b4 build` fail with `B4_E1003` on any near miss: - a `vercel` value that isn't an object, - an unknown key inside it, - a `reconcileVercelJson` other than `true` or `false`, or - the flag placed directly on `build`. A prebuilt app sets `build: { targets: ["vercel"], vercel: { reconcileVercelJson: false } }`. ##### `sandbox` ```ts sandbox?: { workspace?: WorkspaceDefinition thread?: ThreadSandboxResolver workspaceRead?: "http" workspaceReadTimeoutMs?: number stagedWorkspaces?: | boolean | { maxUploadBytes?: number retentionMs?: number maxStagedBytes?: number uploadTimeoutMs?: number } 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 run on the host backends. If you add a sandbox, `provider` is required. The manager then defaults to: - an allow-mode network policy, `{ mode: "allow" }`, with open egress. The reference Docker and Kubernetes providers do not enforce a `denylist`, so the cloud metadata endpoint `169.254.169.254` stays reachable. See [Network policy](https://b4.run/docs/sandbox#network-policy), - no host environment variables, and - releasing idle compute after `600_000` ms. `thread` decides each thread's workspace, image and policy once, when the thread is first admitted, and is exclusive with `workspace`. `b4 check`, `b4 build` and startup refuse any other key in the `sandbox` block. Read [Per-thread sandboxes](https://b4.run/docs/sandbox#per-thread-sandboxes) for what a thread may set. `workspaceRead: "http"` serves a read of each thread's managed workspace at `POST /threads/:thread_id/workspace/inspect`. It needs `workspace` or `thread` and a thread-access policy; `b4 check`, `b4 build` and startup refuse it without one. `workspaceReadTimeoutMs` (1,000 to 1,800,000; default 120,000) bounds one read, open to close. Read [Reading a thread's workspace over HTTP](https://b4.run/docs/sandbox#reading-a-threads-workspace-over-http). `stagedWorkspaces` accepts a thread's workspace at creation: a creator uploads it at `PUT /workspace/sources/:digest` and names it in `POST /threads`, and the resolver receives it as `thread.staged`. It needs a resolver (`thread`, or a function `workspace`) and a thread-access policy; `b4 check`, `b4 build` and startup refuse it otherwise. `maxUploadBytes` (default and ceiling 96 MiB), `maxStagedBytes` (default 1 GiB, at most 16 GiB), `retentionMs` (60,000 ms to 30 days; default 24 hours) and `uploadTimeoutMs` (1,000 to 1,800,000 ms; default 120,000) bound it. Read [Handing a thread its workspace at creation](https://b4.run/docs/sandbox#handing-a-thread-its-workspace-at-creation). Each provider enforces resources and security in its own way. Docker's security fields default to hardened. `diskGb` applies to PVC-backed providers, and Docker ignores it. At runtime, an injected `sandboxManager` takes precedence over `config.sandbox`, so this field only applies when the host doesn't pass a manager. Read [Execution Sandbox](https://b4.run/docs/sandbox) before you relax 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 retry?: { maxAttempts?: 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 } ``` A route opts into typed memory with its own `memory.ts` file. `enabled` is an inert compatibility field. The defaults are: - **Store:** SQLite at `.b4/memory.sqlite`, with `writes: "candidate"` and `indexMaxEntries: 20`. At runtime, an injected `memoryStore` takes precedence over `config.memory.store`. - **Keyword recall:** relevance/recency/confidence weights `0.6`/`0.3`/`0.1`, a 14-day half-life, and a `256`-record candidate pool. - **Vector recall:** off. Turn it on with an `embedder`. It then defaults to keyword/vector weights `1`/`1`, `rrfK: 60`, `vectorK: 64`, `recencyWeight: 0.3`, and `confidenceWeight: 0.1`. - **Episodes:** disabled, with a 30-day TTL, a cap of `500`, failed runs included, and no embeddings (`embed: true` isn't supported). - **Distillation:** runs only when you invoke it, with `gpt-5-mini`, the inferred provider (falling back to `openai`), five batches, and 3 attempts per model call. Consolidation takes records older than seven days in batches of 5–50, sets no summary TTL, and gives source records a seven-day TTL. Reflection waits for 10 new records, reads at most 100, and writes candidates. `distill.retry.maxAttempts` is the attempts per distillation model call, counting the first. It becomes the model's `maxRetries` (`maxAttempts - 1`), as [`agent({ retry })`](https://b4.run/docs/retry) does for a route. There's no `baseDelay` here: distillation has no rate-limit retry of its own for it to pace. On the `ollama` provider `maxAttempts` has no effect, because Ollama's chat requests bypass LangChain's retry (see [Retry limits](https://b4.run/docs/retry#limits)). `b4 check`, `b4 memory consolidate` and `b4 memory reflect` reject, with `B4_E1009`: - a non-object `memory`, `distill` or `retry`; - a key on `memory` that is `distill` up to case and one character, such as `Distill`, `distil` or `distills`; - `retry` or `maxAttempts` in any casing directly on `memory`, and `maxAttempts` directly on `distill`; - any other unknown key in `distill` or `distill.retry`, a typo such as `Retry` or `maxattempts` included; - a `baseDelay`, and a `maxAttempts` that isn't a whole number of at least 1. They never fall back to the default. Only the top-level keys of `distill` and `distill.retry` are checked, not the keys inside `consolidate` or `reflect`. A custom store ignores these tuning blocks. `resolveScope` receives only `routePath` and `appRoot`, so derive tenant or user dimensions from your own application context. See [Long-term Memory](https://b4.run/docs/memory/long-term) for write governance, [Recall and Retrieval](https://b4.run/docs/memory/retrieval) for ranking, [Episodes](https://b4.run/docs/memory/episodes) for run history, and [Distillation](https://b4.run/docs/memory/distillation) for consolidation and reflection. ##### `server` ```ts server?: { cors?: { origins: readonly string[] | "*" credentials?: boolean methods?: readonly string[] headers?: readonly string[] exposeHeaders?: readonly string[] maxAgeSeconds?: number } agui?: { clientTools?: readonly string[] clientForwardedProps?: readonly string[] clientToolTtlMs?: number clientToolRetentionMs?: number clientToolStore?: ClientToolCallStore } } ``` ###### `server.cors` By default there's no `server.cors`, which means **no `Access-Control-*` header on any response**. A browser on another origin can't 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 B4.run leaves it to you. Set it when a browser talks to B4.run directly, without a same-origin proxy: ```ts server: { cors: { origins: ["https://app.example.com"] } } ``` B4.run normalizes case and a trailing slash, then compares origins as strings, so `"HTTP://LocalHost:3010/"` matches an `Origin: http://localhost:3010`. Note, `localhost` and `127.0.0.1` are *different* origins. List both if you open your dev client at either one. B4.run validates the policy at boot, so a malformed origin list fails on startup. It rejects `origins: "*"` with `credentials: true`, because browsers refuse a wildcard allow-origin on a credentialed request. The other fields default to: - `credentials`: `false` - `methods`: `GET, POST, DELETE, OPTIONS` - `headers`: echoes the browser's own `Access-Control-Request-Headers`, so you can add an auth header without changing server config - `exposeHeaders`: empty - `maxAgeSeconds`: `600` A request from an origin that isn't on the list is still served. The response just has no CORS header, so the browser won't hand it to the page. (A 403 would break every non-browser client that sends an `Origin`.) Error responses carry the headers too, including the shutdown 503, so a cross-origin caller sees the real 404 rather than an opaque CORS failure. CORS controls which origins a browser lets read a response. It's not authentication, and it doesn't decide who may call the server. Pair it with [`defineThreadAccess`](https://b4.run/docs/thread-access). ###### `server.agui` Which client-supplied AG-UI envelope fields `POST /agui/:routeId` honors, per route. Both lists are empty unless set, and empty means the runtime **rejects** a non-empty `tools` or `forwardedProps` with a `422` rather than ignoring it — see [envelope validation](https://b4.run/docs/ag-ui#envelope-validation). ```ts server: { agui: { clientTools: ["/chat"] } } ``` Entries are route ids as they appear in the route tree (`"/chat"`, `"/support/billing"`), matched exactly. `clientTools` and `clientForwardedProps` are independent: opening one does not open the other, and a route that is not named stays closed even when a sibling is open. An empty `tools: []` or `forwardedProps: {}` — what an AG-UI client sends when it has nothing to add — is accepted with or without the opt-in, so the default costs an ordinary client nothing. On a route named in `clientTools`, the model can call the client's tools — see [Client-provided tools](https://b4.run/docs/ag-ui#client-provided-tools). Each call the model makes is recorded before the turn ends, so the client's `role: "tool"` result on its next run can be matched to a call this server actually issued. `clientToolTtlMs` is how long that call waits for its result before it is abandoned — `600000` (10 minutes) by default; it must be a positive integer of at most one year (`31536000000`), and anything else fails the boot. With a store resolved, every tool call on an AG-UI run is recorded, server tools included (identity only, no result text), and a store that cannot be written fails the tool call; see [The tool-call record](https://b4.run/docs/ag-ui#the-tool-call-record). `clientToolStore` is where tool calls on AG-UI runs are recorded. On node it defaults to a SQLite file at `.b4/client-tool-calls.sqlite`, opened when some route is listed in `clientTools` (or when the file already exists, so calls parked before the opt-in was removed can still be closed). Listing a route in `clientTools`, or setting `clientToolStore`, is also what turns on recording of server tool calls; a leftover file alone does not, and boot warns about it. Edge and serverless deployments have no default and must set one, and a deployment with more than one instance needs a shared one — `createPostgresClientToolCallStore` from `@b4run/postgres-storage`, for example. With no store at all, a run that sends client tools is refused with `503` `client_tool_store_unavailable` rather than parked on a call nobody could answer. A configured store missing any of the store methods fails the boot. Settled records are not kept forever. When an AG-UI turn settles, the runtime deletes answered or voided records older than `clientToolRetentionMs`, outstanding records whose expiry is older than it, and server tool call records settled before it, at most once an hour per store. The default is `604800000` (7 days); it must be a positive integer of at most one year, anything else fails the boot, and the window is never shorter than `clientToolTtlMs`. A record that is outstanding and unexpired, or a server tool call that has not settled, is never deleted. [`b4 client-tools prune`](https://b4.run/docs/cli#b4-client-tools) runs the same pass by hand, and a store you supply must implement `prune` as well. #### Postgres backend Shared Postgres persistence takes three explicit entries: the checkpointer, the thread store and the permission store. Each holds different data with its own lifecycle. Install the package and the driver: ```bash npm install @b4run/postgres-storage pg ``` Then wire all three to one pool: `b4.config.ts`: ```ts import { config } from "@b4run/cli" 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" }), }, }) ``` Always attach an `error` listener to the pool. Your application owns the pool, so end it yourself once B4.run has stopped accepting and draining requests. The stores leave it open. Postgres shares durable rows between replicas, but it does not distribute active-run or cancel coordination between processes. Use sticky or thread-aware routing, or add your own distributed coordination. See [Persistence and Tenancy](https://b4.run/docs/persistence) for migrations, lifecycle, retention and deletion, then [Production Topology](https://b4.run/docs/production-topology) for replica routing and shutdown order. --- ### CLI Reference Source: https://b4.run/docs/cli B4.run ships a single `b4` binary with seventeen commands: `add`, `approvals`, `build`, `check`, `client-tools`, `dev`, `docs`, `eval`, `inspect`, `memory`, `routes`, `run`, `start`, `test`, `threads`, `typegen`, and `verify`. Most commands read `b4.config.ts` from the current working directory or from `--cwd `. `b4 dev` always uses the current working directory. #### Running the CLI Always run B4.run through the `b4` **bin**: `npx b4 `, `pnpm exec b4 ` or a `package.json` script. `@b4run/cli` installs the bin into `node_modules/.bin`, and it resolves wherever the package lives. > **Warning: Monorepos: use the bin, not a direct path** > > In an npm/pnpm-hoisted monorepo, `@b4run/cli` usually hoists to the workspace root, so a hardcoded path like `node node_modules/@b4run/cli/dist/index.js` from a sub-package fails to resolve. Run `pnpm exec b4 …` instead. It finds the bin through the workspace's `node_modules/.bin`. #### `b4 check` `b4 check` validates the app structure and configuration. ``` b4 check ``` It loads `b4.config.ts`, resolves the app root, runs `discoverRoutes`, and parses each route's tool definitions with `discoverToolDefinitions`. It reports: - A `b4.config.ts` that fails to load. - An app root `package.json` without `"type": "module"` (`B4_E1006`). B4.run loads `b4.config.ts` and every route `index.ts` as ES modules. Without the field, Node compiles them as CommonJS, and B4.run won't recognise a route exported with `export default agent(...)`. - A route directory whose `index.ts` has no recognisable export (`B4_E1007`), with the file path and the exports it did find. If a nested `package.json` without `"type": "module"` made Node load the module as CommonJS, the message names that file. To keep a directory that isn't a route out of discovery, prefix it with `_`. - A route whose `index.ts` exports more than one of `agent`, `workflow`, `graph`, `chain` (`B4_E1008`), with the file path and the kinds it exported. For both codes, `b4 check` lists every offending route at once. When an app has both, it lists the unrecognised ones first, with a count of the multi-kind ones. - A tool file that fails to parse. - An agent route tool whose name starts with `client_` (`B4_E1001`). That prefix is reserved for [client-provided tools](https://b4.run/docs/ag-ui#client-provided-tools), and route preparation refuses it at run time too. - A stale build manifest (see below). - A `memory.distill.retry` with a key other than `maxAttempts`, or a `maxAttempts` that isn't a whole number of at least 1; an unknown key in `memory.distill`; a non-object `memory` or `memory.distill`; and a near miss on `memory` itself, such as `Distill`, `distil`, or a `retry` or `maxAttempts` placed there (`B4_E1009`). `b4 memory consolidate` and `reflect` refuse the same config before building a model. - An `agentsMd` that isn't an object, an unknown key in it such as `writeable`, or a `writable` that isn't a boolean (`B4_E1010`). Route preparation refuses the same config. - An unknown `build.targets` entry and, when `"hono"` is one of them, every feature that target [cannot serve](https://b4.run/docs/deployment/edge#what-the-edge-cannot-serve) (`B4_E1005`). `b4 build` applies the same gate, so you learn about all of them before you build. When `.b4/build/modules.mjs` exists (the `node` target writes it), `b4 check` compares its routes with the routes on disk. A mismatch, such as a route renamed since the last build, fails with the missing and extra route ids and asks you to re-run `b4 build`. When `"hono"` is a configured target, `b4 check` compares `.b4/build/modules.edge.mjs` the same way. It's a separate artifact with its own copy of every route's static imports, and an app that builds for `hono` **alone** has no `modules.mjs`. A missing manifest is fine, and so is a leftover edge manifest when `hono` isn't a target. The output is a `B4.run app is valid: N routes discovered.` line followed by a per-route `- ()` summary. On any violation, it exits non-zero with a detailed message. ##### Troubleshooting imports When a route, tool, or config module fails to load with the ESM error `does not provide an export named X`, B4.run prints the offending package with a likely cause and fix, in place of the raw `SyntaxError`. There are two usual causes: - **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.2.12`). - **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` `b4 verify` runs five checks in one call: `app`, `routes`, `typegen`, `deps` and `runtime`. Run it 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`: print a structured report (`{ status, appRoot, checks, counts }`) in place of the text output. A failed check carries `error.message`, plus `error.code` when the failure has an [error-code](https://b4.run/docs/errors) entry (for example `B4_E1006` for an app root that isn't an ES module). Text mode shows the same code in a `[B4_Exxxx] See ` footer. - `--env-file `: path to a `.env` file (overrides `b4.config.ts` `env` and the default `./.env`). The `deps` check looks for missing packages and missing env vars. It's **provider-aware**: it works out which API-key env vars to look for from the providers your routes use. An Anthropic-only app needs `ANTHROPIC_API_KEY`, an OpenAI app needs `OPENAI_API_KEY`, a multi-provider app needs both, and a local Ollama app needs none. A missing key is only a warning, because the runtime environment may supply it. The `runtime` check gates **environment readiness**: - **Node**: asserts the running Node version is at least `24.0.0`, the active LTS line (it bundles npm 11 or later and ships `node:sqlite` unflagged). An older version **fails** `verify` with a non-zero exit. - **Docker**: runs only when `b4.config.ts` configures a sandbox provider. It runs the provider's daemon preflight and **fails** if the daemon is unreachable. See [Deployment](https://b4.run/docs/deployment) for the recommended workflow. #### `b4 routes` `b4 routes` lists every route B4.run discovered, with its pathname. ``` b4 routes b4 routes --json ``` Output: ``` Discovered 2 B4.run routes in /path/to/app /navlog -> src/app/navlog/index.ts /admin/users -> src/app/(internal)/admin/users/index.ts ``` Use it to confirm that route groups and dynamic segments parse the way you expect. #### `b4 typegen` `b4 typegen` regenerates `.b4/b4.generated.d.ts` plus the 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. `b4 build` reads the `tools.json` files to emit LangGraph entries. Tool input types compile with the app's own `tsconfig.json` (the one nearest the app root, honoring `extends`, `paths`, and `baseUrl`). If a declared input type resolves to `any`, say through a path alias the tsconfig doesn't declare, the command fails and names the tool file and the unresolved type. It won't write an empty schema. > **Info: Run automatically by b4 dev, run, test, and build** > > `b4 dev` runs `typegen` before every child (re)start, so edits to tool signatures or state schemas are picked up on the automatic restart. `b4 run` and `b4 test` run it before executing routes in-process, and `b4 build` runs it before emitting artifacts, so a new or changed tool is bound with its current input schema. Run it yourself after a fresh clone, before CI, or to refresh editor types for `ctx.tools` while the dev server isn't running. #### `b4 build` `b4 build` writes deployment artifacts for each target in `build.targets` (default: `["node", "langsmith"]`). ``` b4 build b4 build --clean b4 build --out-dir dist/vercel ``` Flags: - `--cwd `: operate on a different app root. - `--clean`: wipe `.b4/build/` before writing. - `--out-dir `: where the `vercel` target publishes its Build Output tree, relative to the app root (default `.vercel/output`). It overrides `build.vercel.outDir` for this run, and it's an error when `"vercel"` isn't a configured target. Here's what each target writes: - **`node`**: `.b4/build/server.mjs`, which boots `serveRuntime` (the real B4.run runtime), and a hardened `Dockerfile` in the app root (or in `.b4/build/` if the app root already has one). Run the build output with `node .b4/build/server.mjs` (the generated `Dockerfile`'s entry point) or `docker build`/`docker run`. `b4 start` doesn't use the build output: it serves the app's source tree, so it reads route modules, middleware and the [thread access policy](https://b4.run/docs/thread-access) from source. - **`langsmith`**: `.b4/build/langgraph.json` plus per-route entry files under `.b4/build/.ts`, which you hand to LangSmith. The JSON includes `graphs` (keyed by `#`), `dependencies: ["."]`, `env` (`.env.example` if present, else `.env`), and `node_version: "22"`. For an `agent` route, the entry imports the default `agent()` descriptor, turns it into a LangGraph graph, and wires in every tool B4.run discovered for the route. - **`hono`** (opt-in): `.b4/build/app.mjs`, a Hono app around B4.run's web-standard fetch handler, `export default`ed for Cloudflare Workers, Vercel, or Bun. It also writes `.b4/build/modules.edge.mjs` (the module manifest, free of Node built-ins), `.b4/build/stores.mjs` (a per-request Postgres store factory), and a `wrangler.toml` scaffold if the app root has none. Deploy with `wrangler deploy`. - **`vercel`** (opt-in): a [Vercel Build Output API](https://vercel.com/docs/build-output-api) tree. See [below](#the-vercel-target). Pick the targets with `build.targets` in `b4.config.ts`, for example `{ build: { targets: ["node"] } }`. The list replaces the defaults, so an app that also deploys to the edge names both: `{ build: { targets: ["node", "hono"] } }`. Target-specific settings sit next to the list, for example `{ build: { targets: ["vercel"], vercel: { outDir: "dist/vercel" } } }`. ##### The `vercel` target The tree holds `config.json` and the runtime Node 24 function at `functions/b4.func/` (the self-contained `index.mjs` plus its `.vc-config.json`). The function serves the same edge subset as `hono`. The tree lands in `.vercel/output`, where Vercel deploys from, unless `build.vercel.outDir` (or `--out-dir`) points elsewhere. With `build.vercel` configured, the target also: - copies a static directory into `static/`, - bundles each declared function into `functions/.func/`, and - orders `routes` as your routes, the filesystem, the runtime, then the SPA fallback. The runtime function is named `b4.func` (or whatever `functionName` says), so it never shadows `static/index.html`. The target stages and validates the whole tree, then swaps it in with one rename, so the output directory can't contain the app root. The validator checks that `config.json` is version 3 and still routes to the runtime function, so a composed config with extra routes and functions passes. The target also checks the app-root `vercel.json` for a `buildCommand` that runs `b4 build` and `fluid: true`: - With no `vercel.json`, it writes a recommended one. - If your `vercel.json` is missing either setting, it prints a warning and writes the recommended file to `.b4/build/vercel.json` for reference. - It fails on `fluid: false`. A prebuilt flow (`vercel deploy --prebuilt` from CI, no Vercel Git integration) never runs `buildCommand`, so set `build.vercel.reconcileVercelJson: false` there. The target then leaves any committed `vercel.json` alone, including one with `fluid: false`. Keep Fluid compute on in the Vercel project either way, because the function is built for that concurrency model. See [the `vercel` target](https://b4.run/docs/deployment/edge#the-vercel-target) and [Vercel](https://b4.run/docs/deployment/vercel). ##### Edge feature gate The `hono` target serves a **subset** of B4.run. The build fails with `B4_E1005`, naming every offending config key and file at once, when the app uses any of these: - the sandbox, - `backends.filesystem` or `backends.exec`, - a config-supplied store, - `toolOutput`, - a `workspace/` directory, or - route-level long-term memory. The `vercel` target applies the same gate, and so does [`b4 check`](#b4-check) whenever `hono` or `vercel` is a configured target. See [Edge and Hono](https://b4.run/docs/deployment/edge). If you upgrade an app that sets both `toolOutput` and `"hono"`, a build that passed before now fails. Remove the key, or drop `"hono"` from `build.targets`. See [Upgrading](https://b4.run/docs/upgrading#tooloutput-is-now-gated-off-the-hono-target). See [Deployment](https://b4.run/docs/deployment) for how the targets fit together. #### `b4 start` `b4 start` serves the app in production with the real B4.run runtime (Agent Protocol, AG-UI, and `/healthz`). It binds `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 runs the [execution sandbox](https://b4.run/docs/sandbox) in production. See [Node and Docker](https://b4.run/docs/deployment/node). #### `b4 run` `b4 run` invokes one route, reading JSON from stdin and writing JSON to stdout. ``` echo '{"messages":[{"role":"user","content":"Hello"}]}' | b4 run '/navlog' ``` Flags: - `--cwd `: operate on a different app root. - `--url `: run against a live dev server rather than the in-process runtime. The route argument can be the route id (`/navlog`) or its entry file (`src/app/navlog/index.ts`). Dynamic segment values come from the JSON input. With `--url`, `b4 run` POSTs to `/threads//runs/wait` with `{ route: "#", input }`. Without `--url`, `b4 run` regenerates the `.b4/` typegen output first, so a tool you just added or changed is bound with its current input schema. You don't need to run `b4 typegen` yourself. If regeneration fails, for example because a tool's input type can't be resolved, `b4 run` prints a warning on stderr and runs the route with the schemas already on disk. > **Tip: Quote the path** > > Route paths containing `(`, `)`, `[`, or `]` should be quoted in shell so they aren't expanded. #### `b4 test` `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 limits the run to scenarios under a subdirectory. To target a live dev server, add `.server(url)` to the scenario's builder chain inside `run.test.ts`. (`b4 test` has no `--url` flag.) Before running in-process scenarios, `b4 test` regenerates the `.b4/` typegen output the same way `b4 run` does. It exits non-zero on any failure and prints a diff for each scenario that didn't match. See [Testing](https://b4.run/docs/testing) for how to write scenarios. #### `b4 eval` `b4 eval` runs every colocated `*.eval.ts` over its dataset, 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 it in CI. - `--record`: record real-model responses into sibling fixture files (requires `OPENAI_API_KEY`). Never use it in CI. Mutually exclusive with `--live`. - `--json [file]`: write a JSON report. Defaults to `.b4/eval-report.json`. The optional positional `[path]` limits discovery to a subdirectory. By default each case replays its aimock fixtures, which is deterministic and safe for CI. `--live` calls the real provider while you tune prompts locally. `--record` saves real-model responses as fixture files you commit, and plain `b4 eval` replays them. A failing gated eval exits non-zero, so CI fails when quality drops below the bar. Informational evals (no `gate`/`threshold`) never affect the exit code. See [Evals](https://b4.run/docs/evals) for how to write them. #### `b4 dev` `b4 dev` starts the local runtime with hot reload and the [Agent Protocol (AP) HTTP endpoints](https://b4.run/docs/dev-server/agent-protocol). It always binds `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`). The default port changes from run to run, so pass `--port` (or read the port `b4 dev` prints) before you copy a `curl` example. If the loaded environment has `LANGSMITH_API_KEY` and `LANGCHAIN_TRACING_V2` isn't set, `b4 dev` turns on LangSmith tracing by setting `LANGCHAIN_TRACING_V2=true`. Set `LANGCHAIN_PROJECT` to choose the project that receives the traces. See [Observability](https://b4.run/docs/observability) for tracing and [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol) for the protocol reference. #### `b4 memory` `b4 memory` inspects and manages the app's [long-term memory](https://b4.run/docs/memory/browse) store, the typed records the agent writes with 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](https://b4.run/docs/memory/episodes#retention) by hand. It deletes expired records (TTL) and enforces the per-namespace episode cap. `--cap ` overrides the cap for this pass, and `--namespace ` limits it to namespaces that match the prefix. - `consolidate`: run [distillation](https://b4.run/docs/memory/distillation)'s compaction pass. It groups old episodic records by namespace and ISO week, summarizes each group with one model call, then supersedes the sources and gives them a TTL so `prune` removes them later. - `reflect`: run distillation's insight pass. It derives 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. Below the configured thresholds, both print one line and exit `0` without creating a model or needing an API key. So it's safe to run the cron recipe (`b4 memory consolidate && b4 memory reflect`) nightly on any app. Their flags: - `--dry-run`: report the plan without making any model call. - `--namespace `: scope the pass. - `--model ` / `--provider `: override `memory.distill`. - `--max-batches `: cap the work (and spend) for one invocation. These are the only `b4 memory` subcommands that spend model tokens. See [Distillation](https://b4.run/docs/memory/distillation) for the full configuration block. Flags: - `--cwd `: operate on a different app root. #### `b4 inspect` `b4 inspect` opens the [B4.run Inspector](https://b4.run/docs/inspector), a localhost-only browser UI for browsing, searching, and approving the app's [long-term memory](https://b4.run/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` `b4 add` fetches a blueprint (an integration guide written for coding agents) 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. To use a self-hosted catalog, set `B4_BLUEPRINTS_URL`. See [Blueprints](https://b4.run/docs/blueprints) for authoring and catalog details. #### `b4 approvals` `b4 approvals` manages the records behind [approval grants](https://b4.run/docs/approval-grants). ``` b4 approvals prune b4 approvals prune --retention ``` `prune` deletes grant records voided because the thread moved past the prompt longer ago than the retention window. A consumed grant is voided when its resumed turn completes; an outstanding grant, or a consumed grant whose resume never completed, is never deleted, however old. The window is [`approvals.grantRetentionMs`](https://b4.run/docs/configuration#approvals) (7 days by default); `--retention ` overrides it for one pass. The runtime runs the same pass on its own, at most once an hour, so this is for cron or a one-off. It prints `pruned: `. With grants on and no store file yet, it creates the default store file (`.b4/interrupt-grants.sqlite`) and prunes nothing; with grants off and no store file, it says so and exits cleanly. Flags: - `--retention `: retention window for this pass, a positive integer of at most one year. - `--cwd `: operate on a different app root. Give it before the subcommand (`b4 approvals --cwd app prune`), as with `b4 memory`. #### `b4 docs` `b4 docs` prints the B4.run docs **bundled with the installed CLI**, so you (or a coding agent) can read the docs for the version you're running without going to the network. 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]` picks one doc by slug (with or without the `.md` suffix). An unknown topic exits non-zero and lists the available topics. In a source checkout, build the CLI first (`pnpm --filter @b4run/cli build`) so the bundled docs exist. #### `b4 client-tools` `b4 client-tools` manages the records behind [client-provided tools](https://b4.run/docs/ag-ui#client-provided-tools) on the AG-UI endpoint. ``` b4 client-tools prune b4 client-tools prune --retention ``` `prune` deletes records that can no longer affect a turn: answered or voided client calls settled longer ago than the retention window, outstanding client calls whose expiry is older than it, and server tool calls settled longer ago than it. An outstanding, unexpired client call and an unsettled server call are never deleted. The window is [`server.agui.clientToolRetentionMs`](https://b4.run/docs/configuration#serveragui) (7 days by default) and never shorter than `clientToolTtlMs`; `--retention ` overrides it for one pass. The runtime runs the same pass on its own when a turn settles, at most once an hour, so this is for cron or a one-off. It prints `pruned: `, or says so when the app has no client tool store. Flags: - `--retention `: retention window for this pass, a positive integer of at most one year. - `--cwd `: operate on a different app root. Give it before the subcommand (`b4 client-tools --cwd app prune`), as with `b4 memory`. #### `b4 threads` `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`](https://b4.run/docs/dev-server/agent-protocol) (the reattach endpoint). Use it to recover 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 right away. This path reads from the checkpoint, so it works across restarts and replicas. The header line reports `status`, whether the attach is `live`, the turn's `run_started_at`, and the checkpoint `anchor`. If `run_started_at` changes between reattachments, a *different* turn now holds the thread. `anchor` ties a resumed turn back to the run it continues. Attaching never cancels anything or takes the thread's run slot, so it's safe to tail a thread someone else is driving. To stop a run, use `POST /threads/:thread_id/cancel`. 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. `tail` exits with: - `1` when the stream ends without a terminal `done` frame, or the server detaches the viewer (at the per-thread viewer cap, or when the consumer falls behind). Reconnect for a fresh snapshot. - `2` when the server is unreachable, the thread doesn't exist, or the thread has never run (so there's no route 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 - [Getting Started](https://b4.run/docs/getting-started): create-b4-app, b4 verify, and b4 run in a first-run walkthrough - [Agent Protocol](https://b4.run/docs/dev-server/agent-protocol): full HTTP contract for b4 dev - [Scenario Testing](https://b4.run/docs/testing): scenario authoring for b4 test - [Deployment Options](https://b4.run/docs/deployment): b4 build and b4 verify in the deploy workflow --- ### API Reference Source: https://b4.run/docs/api This page lists every B4.run package, subpath and executable, and links to its reference. Application code starts with `@b4run/sdk`. Each focused reference covers a package's exports, defaults, errors and lifecycle rules. #### Package and surface index Application shortcuts: most apps only need these surfaces. - [`@b4run/sdk`](https://b4.run/docs/api/sdk) to author routes. - The [`b4` command and runtime entries](https://b4.run/docs/api/cli), including `/fetch` for supported edge integrations. - [`@b4run/testing`](https://b4.run/docs/api/testing) to test programmatically. - [`@b4run/evals`](https://b4.run/docs/api/evals) to score behavior. - [`b4:routes`](https://b4.run/docs/api/generated-routes) for your app's generated tool and state types. In the runtime column, `node-only` means the surface fails B4.run's edge-safety guard. A `node-only` surface can still run in a browser. Most `node-only` entries are server-side, but `@b4run/ag-ui/react` targets the browser and fails the guard only because React's JSX runtime reads `process.env.NODE_ENV`. `browser-only` means the surface needs a bundler: `@b4run/ag-ui/react/copilotkit` imports CopilotKit, whose bundle imports its own CSS, so plain Node cannot load it, and the `@b4run/ag-ui/angular` entries ship in Angular's partial compilation format, which an Angular build's linker finishes. Browser bundles only: import them from a bundled React or Angular app, not from Node or an edge runtime. | 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/client`
`@b4run/ag-ui/view`
`@b4run/ag-ui/react`
`@b4run/ag-ui/react/copilotkit`
`@b4run/ag-ui/angular`
`@b4run/ag-ui/angular/events`
`@b4run/ag-ui/angular/copilotkit`
`@b4run/ag-ui/copilotkit-runtime`
`@b4run/ag-ui/styles.css` | focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `edge-safe` runtime · `not-claimed` purity
focused reference · `node-only` runtime · `not-claimed` purity
focused reference · `browser-only` runtime · `not-claimed` purity
focused reference · `browser-only` runtime · `not-claimed` purity
focused reference · `browser-only` runtime · `not-claimed` purity
focused reference · `browser-only` runtime · `not-claimed` purity
focused reference · `edge-safe` 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](https://b4.run/docs/api/ag-ui) | [Guide](https://b4.run/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`
`@b4run/cli/workspace`
`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` 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](https://b4.run/docs/api/cli) | [Guide](https://b4.run/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](https://b4.run/docs/api#b4runconfig-biome) | [Guide](https://b4.run/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](https://b4.run/docs/api#b4runconfig-typescript) | [Guide](https://b4.run/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](https://b4.run/docs/api/core) | [Guide](https://b4.run/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](https://b4.run/docs/api#b4rundevkit) | [Guide](https://b4.run/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](https://b4.run/docs/api/evals) | [Guide](https://b4.run/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](https://b4.run/docs/api#b4runinspector) | [Guide](https://b4.run/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](https://b4.run/docs/api/langchain) | [Guide](https://b4.run/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](https://b4.run/docs/api/langgraph) | [Guide](https://b4.run/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](https://b4.run/docs/api/memory) | [Guide](https://b4.run/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](https://b4.run/docs/api/memory-pgvector) | [Guide](https://b4.run/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](https://b4.run/docs/api/permissions) | [Guide](https://b4.run/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](https://b4.run/docs/api/postgres-storage) | [Guide](https://b4.run/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](https://b4.run/docs/api/sandbox) | [Guide](https://b4.run/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](https://b4.run/docs/api/sdk) | [Guide](https://b4.run/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](https://b4.run/docs/api/sqlite-storage) | [Guide](https://b4.run/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](https://b4.run/docs/api/testing) | [Guide](https://b4.run/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](https://b4.run/docs/api#b4runvite-plugin) | [Guide](https://b4.run/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](https://b4.run/docs/api/workspace) | [Guide](https://b4.run/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](https://b4.run/docs/api#create-b4-app) | [Guide](https://b4.run/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** separates supported APIs from low-level or internal ones. - **Artifact boundaries** says whether each surface has a focused reference, a catalog summary, or is internal only. It also says whether the surface is runtime code, static configuration, metadata, generated types, a command, or a separately operated application. Runtime imports state their purity claim too. The headings below keep links from the old single-page reference working. Each one points to the page and section that now documents it. Use that link in new docs. #### @b4run/sdk The application authoring surface lives at [`@b4run/sdk`](https://b4.run/docs/api/sdk#use-this-when). ##### Agent See the canonical [`agent()` and `AgentConfig` contract](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### `agent(config)` See the canonical [`agent()` signature](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### `AgentConfig` See the canonical [`AgentConfig` fields](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### `ReasoningConfig` See the canonical [agent configuration contract](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### `RetryConfig` See the canonical [agent configuration contract](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### `B4Agent` See the canonical [agent descriptor contract](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### Subagent delegation types See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `isB4Agent(value)` See the canonical [agent descriptor contract](https://b4.run/docs/api/sdk#agent-and-agentconfig). ##### Middleware See the canonical [middleware result contract](https://b4.run/docs/api/sdk#allow-and-reject). ##### `defineMiddleware(fn)` See the canonical [middleware export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `allow(context?)` See the canonical [`allow()` and `reject()` contract](https://b4.run/docs/api/sdk#allow-and-reject). ##### `reject(status, body?)` See the canonical [`allow()` and `reject()` contract](https://b4.run/docs/api/sdk#allow-and-reject). ##### `B4Middleware` See the canonical [middleware export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `MiddlewareRequest` See the canonical [middleware export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `MiddlewareResult` See the canonical [middleware result contract](https://b4.run/docs/api/sdk#allow-and-reject). ##### `ContinueResult` See the canonical [middleware result contract](https://b4.run/docs/api/sdk#allow-and-reject). ##### `RejectResult` See the canonical [middleware result contract](https://b4.run/docs/api/sdk#allow-and-reject). ##### Memory See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1) and the [long-term memory guide](https://b4.run/docs/memory/long-term). ##### `defineMemory(def)` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `DefinedMemory` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `MemoryScopeDimension` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### Route configuration See the canonical [`RouteConfig` contract](https://b4.run/docs/api/sdk#routeconfig). ##### `RouteConfig` See the canonical [`RouteConfig` contract](https://b4.run/docs/api/sdk#routeconfig). ##### `RouteKind` See the canonical [`RouteConfig` contract](https://b4.run/docs/api/sdk#routeconfig). ##### Route types See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `RouteStateMap` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `RouteToolMap` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### Runtime See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `RuntimeContext` See the canonical [`RuntimeContext` export](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `RuntimeTool` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `ToolRegistry` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `B4ToolContext` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `WorkspaceFs` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### Models See the canonical [model validation contract](https://b4.run/docs/api/sdk#validatemodelid). ##### `KnownModelId` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `ModelProviderId` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `OpenAiModelId` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `GoogleModelId` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `AnthropicModelId` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `XaiModelId` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `inferProvider(model)` See the canonical [model validation contract](https://b4.run/docs/api/sdk#validatemodelid). ##### `SUPPORTED_AGENT_PROVIDERS` See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `validateModelId(opts)` See the canonical [`validateModelId()` contract](https://b4.run/docs/api/sdk#validatemodelid). ##### `ModelIdValidation` See the canonical [`validateModelId()` contract](https://b4.run/docs/api/sdk#validatemodelid). ##### Model id constants See the canonical [SDK model export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### Backend adapter See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `BackendAdapter` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### Utilities See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). ##### `Prettify` See the canonical [SDK public export inventory](https://b4.run/docs/api/sdk#b4runsdk-1). #### @b4run/cli The command and embedding surfaces now live at [`@b4run/cli`](https://b4.run/docs/api/cli#use-this-when). ##### `serveRuntime(options)` See the canonical [`serveRuntime()` contract](https://b4.run/docs/api/cli#serveruntime). ##### `loadStaticModules(manifestUrl)` See the canonical [CLI public export inventory](https://b4.run/docs/api/cli#b4runcli-1). ##### `B4StaticModules` and `StaticRouteModule` See the canonical [CLI root export inventory](https://b4.run/docs/api/cli#b4runcli-1). ##### @b4run/cli/fetch See the canonical [edge-safe fetch subpath](https://b4.run/docs/api/cli#b4runclifetch). ##### @b4run/cli/runtime See the canonical [low-level runtime subpath](https://b4.run/docs/api/cli#b4runcliruntime). #### @b4run/core The integration surface lives at [`@b4run/core`](https://b4.run/docs/api/core#use-this-when). ##### Capability exports See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). ##### `createCapabilityRegistry(markers)` and `applyCapabilities()` See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). ##### `gateToolOp()` and `wrapToolWithApproval()` See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). ##### `createWorkspaceFs(options)` See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). ##### `loadB4Config(options)` and `config(value)` See the canonical [`loadB4Config()` contract](https://b4.run/docs/api/core#loadb4config). ##### `discoverRoutes(options)`, `findB4App(options)`, and route segments See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). ##### State and typegen helpers See the canonical [`resolveStateFields()` contract](https://b4.run/docs/api/core#resolvestatefields). ##### Tool scope See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). ##### Storage type re-export See the canonical [Core public export inventory](https://b4.run/docs/api/core#b4runcore-1). #### @b4run/ag-ui The AG-UI adapter surface lives at [`@b4run/ag-ui`](https://b4.run/docs/api/ag-ui#use-this-when). ##### ID factories See the canonical [AG-UI public export inventory](https://b4.run/docs/api/ag-ui#b4runag-ui-1). ##### `toAguiEvents(chunks, context)` See the canonical [inbound and outbound contracts](https://b4.run/docs/api/ag-ui#inbound-and-outbound-calls). ##### `fromRunAgentInput(input)` See the canonical [inbound and outbound contracts](https://b4.run/docs/api/ag-ui#inbound-and-outbound-calls). ##### SSE subpath: `encodeAgUiSse(event, accept?)` `encodeAgUiSse` was replaced by `encodeAgUiEvent(event, accept?)` and `agUiContentType(accept?)`. See the canonical [SSE subpath inventory](https://b4.run/docs/api/ag-ui#b4runag-uisse). #### @b4run/memory The long-term memory package surface lives at [`@b4run/memory`](https://b4.run/docs/api/memory#use-this-when). ##### `MemoryStore` See the canonical [store and query shapes](https://b4.run/docs/api/memory#store-and-query-shapes). ##### `MemoryRecord` See the canonical [store and query shapes](https://b4.run/docs/api/memory#store-and-query-shapes). ##### `MemoryQuery` See the canonical [store and query shapes](https://b4.run/docs/api/memory#store-and-query-shapes). ##### `BrowseQuery`, `BrowsePage`, and `MemoryStats` See the canonical [browse subpath inventory](https://b4.run/docs/api/memory#b4runmemorybrowse). ##### @b4run/memory/browse See the canonical [browse subpath inventory](https://b4.run/docs/api/memory#b4runmemorybrowse). #### @b4run/memory-pgvector The shared memory backend lives at [`@b4run/memory-pgvector`](https://b4.run/docs/api/memory-pgvector#use-this-when). ##### `pgvectorMemoryStore(options)` See the canonical [`PgvectorMemoryStore` contract](https://b4.run/docs/api/memory-pgvector#pgvectormemorystore). ##### `PgvectorMemoryStore` See the canonical [`PgvectorMemoryStore` contract](https://b4.run/docs/api/memory-pgvector#pgvectormemorystore). ##### `vectorColumnDef(dimensions)` See the canonical [dimension branches contract](https://b4.run/docs/api/memory-pgvector#behavior-contract-memory-pgvectordimension-branches). ##### `initSchema(client, options)` See the canonical [initialization contract](https://b4.run/docs/api/memory-pgvector#initialization-and-retrieval). ##### `assertIdentifier(name, value)` See the canonical [schema identifier contract](https://b4.run/docs/api/memory-pgvector#behavior-contract-memory-pgvectorschemaidentifier-validation). #### @b4run/postgres-storage The durable runtime storage surface lives at [`@b4run/postgres-storage`](https://b4.run/docs/api/postgres-storage#use-this-when). ##### `PostgresStoreOptions` See the canonical [`PostgresStoreOptions` fields](https://b4.run/docs/api/postgres-storage#postgresstoreoptions). ##### `@b4run/postgres-storage/node` See the canonical [Node subpath inventory](https://b4.run/docs/api/postgres-storage#b4runpostgres-storagenode). ##### `postgresCheckpointer(options)` See the canonical [Postgres root export inventory](https://b4.run/docs/api/postgres-storage#b4runpostgres-storage-1). ##### `createPostgresThreadsStore(options)` See the canonical [Postgres root export inventory](https://b4.run/docs/api/postgres-storage#b4runpostgres-storage-1). ##### `createPostgresPermissionsStore(options)` See the canonical [Postgres root export inventory](https://b4.run/docs/api/postgres-storage#b4runpostgres-storage-1). ##### `assertIdentifier(name, value)` See the canonical [Postgres root export inventory](https://b4.run/docs/api/postgres-storage#b4runpostgres-storage-1). ##### `DEFAULT_SCHEMA` / `DEFAULT_TABLE_PREFIX` See the canonical [Postgres root export inventory](https://b4.run/docs/api/postgres-storage#b4runpostgres-storage-1). #### @b4run/testing The programmatic test surface lives at [`@b4run/testing`](https://b4.run/docs/api/testing#use-this-when). For fixture workflows, see [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures). ##### Harnesses See the canonical [harness options contract](https://b4.run/docs/api/testing#agentharnessoptions). ##### Aimock fixtures and recording See the canonical [harness and fixture lifecycle](https://b4.run/docs/api/testing#harness-and-fixture-lifecycle). ##### Matchers See the canonical [testing public export inventory](https://b4.run/docs/api/testing#b4runtesting-1). ##### Run-result utilities See the canonical [testing public export inventory](https://b4.run/docs/api/testing#b4runtesting-1). ##### Memory, protocol, and subprocess helpers See the canonical [harness and fixture lifecycle](https://b4.run/docs/api/testing#harness-and-fixture-lifecycle). ##### Example See the canonical [testing examples](https://b4.run/docs/api/testing#examples-and-related-guides). #### @b4run/evals The evaluation surface lives at [`@b4run/evals`](https://b4.run/docs/api/evals#use-this-when). ##### Eval definition and execution See the canonical [`EvalDefinition` contract](https://b4.run/docs/api/evals#evaldefinition). ##### Scores and gates See the canonical [evaluation semantics](https://b4.run/docs/api/evals#evaluation-semantics). ##### Built-in scorers See the canonical [eval public export inventory](https://b4.run/docs/api/evals#b4runevals-1). ##### Memory scorers See the canonical [eval public export inventory](https://b4.run/docs/api/evals#b4runevals-1). ##### Example See the canonical [eval examples](https://b4.run/docs/api/evals#examples-and-related-guides). #### b4:routes (generated) The generated application types now live at [`b4:routes`](https://b4.run/docs/api/generated-routes#use-this-when). ##### `RouteTools

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

` See the canonical [generated state contract](https://b4.run/docs/api/generated-routes#state). #### Where to read more Start with [Agents](https://b4.run/docs/agents), [Routes](https://b4.run/docs/routes), and [Tools](https://b4.run/docs/tools). The package references above cover each import, field, default, error, and lifecycle rule. #### Related - [Getting Started](https://b4.run/docs/getting-started): build your first B4.run application - [Configuration Reference](https://b4.run/docs/configuration): configure application runtime behavior - [CLI Reference](https://b4.run/docs/cli): develop, check, test, and build --- ### @b4run/sdk Source: https://b4.run/docs/api/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. Use `/pure` only when an integration needs dependency-free path or hash utilities, and `/testing` when you write route scenarios for `b4 test`. #### Install and import ```bash npm install @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's 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 per provider. | | `OpenAIReasoningConfig` | Configure OpenAI reasoning effort and streamed summary. | | `AnthropicReasoningConfig` | Configure Anthropic extended thinking. | | `RetryConfig` | Configure model-call retry attempts and delay. | | `SubagentMap` | Map local subagent names to agents. | | `ToolScope` | Select and gate route tools. | | `ApproveEntry` | Name one `tools.approve` tool, or `{ tool, allowAlways: false }` to approve every call. | | `NormalizedApproveEntry` | Describe a `tools.approve` entry as `{ tool, allowAlways }`. | | `normalizeApproveEntries` | Normalize `tools.approve` entries to `{ tool, allowAlways }`. | | `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 export contract: a handler or a lifecycle definition. | | `MiddlewareDefinition` | Define middleware with `setup`, `dispose`, `handle`, and `after`. | | `MiddlewareAfterHook` | Validate or replace the final assistant message of an AG-UI run. | | `MiddlewareAfterRun` | Describe what `after` receives: the final message and the allowed context. | | `MiddlewareAfterResult` | Express an `after` decision: keep, replace, or reject. | | `MiddlewareAfterMessage` | Describe one message of the conversation an `after` run was started with. | | `ReplaceFinalMessageResult` | Replace the final assistant message from `after`. | | `MiddlewareHandler` | Define the per-request middleware function. | | `MiddlewareRequest` | Describe a middleware request. | | `MiddlewareSetupContext` | Describe what `setup` receives. | | `MiddlewareResult` | Express a middleware decision. | | `RejectResult` | Stop middleware execution with a response. | | `allow` | Continue middleware execution. | | `defineMiddleware` | Preserve types for a middleware function or lifecycle definition. | | `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. | | `ThreadAccessRequestedWorkspace` | Describe the workspace an upload stages or a create chooses: the source digest, and on a create its links, baseline and `uploadedBy`, the stamps its uploads were allowed with. | | `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. | | `ApprovalGrantMode` | Name the approval-grant strictness: `off`, `optional`, or `required`. | | `ApprovalGrantMinter` | Mint one single-use approval grant for one parked interrupt. | | `InterruptGrantRecord` | Describe a stored grant row, which holds the hash and never the grant. | | `InterruptGrantStore` | Define the durable record of which parked approvals have been answered. | | `InterruptGrantConsumption` | Express the outcome of a conditional consume. | | `APPROVAL_GRANT_MODES` | List every approval-grant mode. | | `APPROVAL_GRANT_MINTER_KEY` | Name the run-config key the per-run minter travels under. | | `APPROVAL_GRANT_PREFIX` | Expose the `b4ag_` prefix that makes a grant recognizable. | | `APPROVAL_GRANT_BYTES` | Expose the randomness, in bytes, behind one grant. | | `isApprovalGrantMode` | Validate an approval-grant mode. | | `isApprovalGrantShape` | Check a grant arriving on the wire before it is hashed. | | `createApprovalGrant` | Mint one grant and return its plaintext and hash. | | `hashApprovalGrant` | Hash a grant for storage and comparison. | | `timingSafeHexEqual` | Compare two hex digests in constant time. | | `createMemoryInterruptGrantStore` | Create the in-process grant store used by tests and storeless runtimes. | | `ClientToolCallRecord` | Describe one row of the tool-call record: a client-provided call the server issued and is waiting on, or a server tool call recorded for identity — with the route that issued it and, inside a subagent, the launching `task` call. | | `ToolCallOrigin` | Say where a server tool call was issued from when inside a subagent: the child route's key and the launching `task` call id. | | `ClientToolCallStore` | Define the durable record of tool calls on AG-UI runs: which client calls are outstanding, answered, or voided, and which server calls were issued and settled. | | `ToolCallRecordKind` | Tell a client tool call row from a server tool call row. | | `ClientToolCallSettle` | Express the outcome of settling a server tool call row. | | `ClientToolCallAnswer` | Express the outcome of answering a client tool call. | | `ClientToolRecorder` | Record a tool call before it runs or its turn parks, and settle a server call afterward. | | `CLIENT_TOOL_RECORDER_KEY` | Name the run-config key the per-run client tool recorder travels under. | | `createMemoryClientToolCallStore` | Create the in-process client tool call store used by tests and embedders. | | `encodeClientToolResult` | Encode a client tool result for a store's text column: text as itself, a part list as a self-describing JSON envelope. | | `decodeClientToolResult` | Decode a stored client tool result: an envelope back to its parts, anything else as the text it is. | | `B4ContentPart` | Describe one AG-UI 1.0 content part: a `B4TextPart` or a `B4MediaPart`. | | `B4TextPart` | Describe a `text` content part. | | `B4MediaPart` | Describe an `image`, `audio`, `video` or `document` part and its source. | | `B4MediaPartType` | Name the four media part types. | | `B4PartSource` | Describe where a media part's bytes come from: inline data, a URL, or a provider file handle. | | `B4DataSource` | Describe inline base64 bytes with their `mimeType`. | | `B4UrlSource` | Describe a URL the provider fetches; B4.run never fetches it. | | `B4FileSource` | Describe a file handle issued by a model provider, optionally naming that provider. | | `B4MessageContent` | Describe what a user or tool message carries: a string or an ordered list of content parts. | | `isContentPart` | Check that an unvalidated value is a structurally valid content part. | | `isContentPartArray` | Check that an unvalidated value is an array of valid content parts. | | `contentPartsText` | Read a message's text: a string as is, or a part list's text parts in order. | | `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. | | `WorkspaceContext` | Supply trusted managed workspace identity, provenance, and permission-bound initial reads. | | `ToolDisplay` | Describe how a tool call reads to a person (icon, running/done labels, sources). | | `ToolDisplayIcon` | Name one of the fixed step icons. | | `ToolDisplaySource` | Describe a file, URL or record a call drew on. | | `TOOL_DISPLAY_ICONS` | List the fixed step icon names. | | `TOOL_DISPLAY_LABEL_MAX` | Cap a step label's length. | | `describeToolDisplayProblem` | Name what is wrong with a `display` export, or nothing. | | `isToolDisplayIcon` | Narrow a string to a step icon name. | | `ToolDenial` | Describe a denied tool call's result: the reason the model receives, branded. | | `TOOL_DENIAL` | Name the symbol key that brands a denied tool call's result. | | `toolDenial` | Brand a denial reason as a tool result; the model still reads the reason as plain text. | | `isToolDenial` | Tell a denied tool call's result from a successful one. | | `B4_STEP_KEY` | The `additional_kwargs` key under which every ToolMessage persists its step (`b4_step`). | | `B4_SUBAGENT_KEY` | The `additional_kwargs` key under which a `task` ToolMessage persists the subagent it launched (`b4_subagent`). | | `B4_TURN_METADATA_KEY` | The checkpoint-metadata key under which the runtime stamps how a turn ended (`b4:turn`). | | `GateDecision` | How a permission gate was answered: `once`, `always` or `deny`. | | `PersistedStep` | The step a ToolMessage persists: status (`completed`, `failed` or `denied`), icon, label, sources, start and settle times, and the gate decision. | | `PersistedSubagent` | The subagent a `task` call launched: name, route, description, depth, child checkpoint namespace, outcome and error. | | `PersistedTurnEnd` | How and when a turn ended, stamped on the head checkpoint's metadata. | | `readPersistedStep` | Validate a `b4_step` value; invalid optional fields are dropped, a missing identity returns undefined. | | `readPersistedSubagent` | Validate a `b4_subagent` value. | | `readPersistedTurnEnd` | Validate a `b4:turn` value. | ##### `@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 export declare function agent( config: AgentConfig, ): B4Agent ``` ```ts 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 per provider. | | `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` agent() returns a branded descriptor and includes optional tool scope only when supplied. ###### Agent configuration signatures ```ts export interface ReasoningConfig { readonly openai?: OpenAIReasoningConfig readonly anthropic?: AnthropicReasoningConfig } ``` **Fields: `@b4run/sdk#.:ReasoningConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly openai` | `OpenAIReasoningConfig` | no | Apply when the route resolves to OpenAI. | | `readonly anthropic` | `AnthropicReasoningConfig` | no | Apply when the route resolves to Anthropic. | ```ts export interface OpenAIReasoningConfig { readonly effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" readonly summary?: "auto" | "concise" | "detailed" } ``` **Fields: `@b4run/sdk#.:OpenAIReasoningConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly effort` | `"none" \| "minimal" \| "low" \| "medium" \| "high" \| "xhigh"` | no | Set the request's reasoning effort. | | `readonly summary` | `"auto" \| "concise" \| "detailed"` | no | Stream a reasoning summary on the Responses API. | ```ts export interface AnthropicReasoningConfig { readonly budgetTokens: number } ``` **Fields: `@b4run/sdk#.:AnthropicReasoningConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly budgetTokens` | `number` | yes | Enable extended thinking with this token budget (at least 1024). | ```ts export interface RetryConfig { readonly maxAttempts?: number readonly baseDelay?: number } ``` **Fields: `@b4run/sdk#.:RetryConfig`** | Field | Type | Required | Description | |---|---|---|---| | `readonly maxAttempts` | `number` | no | Set attempts per model call; defaults to `3`. | | `readonly baseDelay` | `number` | no | Set the first capacity rate-limit backoff; defaults to `1000`. | ```ts 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. `defineMiddleware` accepts either the handler function or a lifecycle definition with optional `setup(ctx)` and `dispose()` hooks around `handle`. `setup` runs once, lazily, before the first gated request and is retried on the next request if it rejects. `dispose` runs from the Node runtimes' shutdown path. See [Lifecycle hooks](https://b4.run/docs/middleware#lifecycle-hooks). The definition may also carry `after(run)`, which receives an AG-UI run's final assistant message with the allowed context and may keep, replace, or `reject(...)` it before the client sees it. See [Validating the final assistant message](https://b4.run/docs/middleware#validating-the-final-assistant-message). ```ts export declare function defineMiddleware(middleware: T): T ``` ```ts export declare function allow(context?: Record): ContinueResult ``` ```ts export declare function reject(status: number, body?: unknown): RejectResult ``` ###### Behavior contract `sdk.middleware.result-shapes` allow() and reject() return discriminated result objects; omitted context and body properties are absent. ##### `validateModelId()` Flags likely model-ID typos. It does not restrict custom model IDs. ```ts export declare function validateModelId(opts: { readonly model: string readonly provider?: string }): ModelIdValidation ``` ###### Behavior contract `sdk.validate-model-id.advisory` 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 don't change execution. Select deployment runtime with `build.targets`, select streaming through the endpoint or transport, and do not rely on tags for behavior. ```ts 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 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, and `@b4run/testing` when a test needs a programmatic harness. ##### Trusted initial source An authored tool's optional `ctx.workspace` is present only for an admitted managed workspace. It exposes `id`, `sourceDigest`, the pinned `environment`, an optional `baselineCommit`, and `readInitialFile(path): Promise`. Initial reads use the same read permission policy as ordinary file reads and return independently owned bytes. The context has no provider handle or lifecycle methods and cannot select another thread's source. Use `ctx.fs` for current files. `ctx.fs.stat(path)` inspects entry kind, size, executable mode, and a symlink's target without following the leaf link; custom backends must supply `lstat` to support it. #### Examples and related guides ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "Answer concisely.", }) ``` Continue with [Agents](https://b4.run/docs/agents), [Middleware](https://b4.run/docs/middleware), [Long-term Memory](https://b4.run/docs/memory/long-term), [Scenario Testing](https://b4.run/docs/testing), and [Agent Test Harness](https://b4.run/docs/testing-agents). --- ### @b4run/cli Source: https://b4.run/docs/api/cli #### Use this when Most apps only need the `b4` command to develop, test, build, and serve. Import the package root to embed the Node server. Use `/fetch` when an edge adapter supplies the generated modules and the durable stores its routes need. The adapter may also pass a constructed configuration. Without one, runtime defaults apply. `/runtime` is low-level tooling. `/workspace` is the managed-workspace lifecycle without the command program, for a host process that owns its own executions. `/testing` is a deprecated compatibility alias. #### Install and import For command-only use, install the CLI as a dev dependency: ```bash npm install -D @b4run/cli npx b4 dev ``` Install it as a runtime dependency when production code or a generated server imports it: ```bash npm install @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 | | `@b4run/cli/workspace` | node-only | not-claimed | tooling | low-level | | `bin:b4` | node-only | n/a | tooling | supported | #### Public exports ##### `@b4run/cli` | Export | Responsibility | |---|---| | `B4Config` | Re-export the [`@b4run/core` configuration type](https://b4.run/docs/api/core#b4runcore), so an app that depends only on `@b4run/cli` can name it. | | `B4StaticModules` | Map generated route modules for runtime boot. | | `ServeFallback` | Handle requests the runtime does not own. | | `ServeGuard` | Answer or pass every request before routing. | | `ServeHandle` | Control a composed server. | | `ServeOptions` | Configure a composed server. | | `ServeRuntimeHandle` | Control an embedded server. | | `ServeRuntimeOptions` | Configure an embedded Node server. | | `StaticRouteModule` | Describe one generated route module. | | `config` | Re-export [`@b4run/core` configuration typing](https://b4.run/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. | | `serve` | Serve the runtime's routes beside an application handler. | | `serveRuntime` | Start the production Node runtime server. | | `cleanupWorkspaces` | Remove managed workspaces from an inactive, dedicated app installation with exclusive ownership and retryable deletion. | | `withWorkspace` | Run host-authored work in a fresh managed workspace with durable cleanup. | | `WithWorkspaceOptions` | Select source, provider, policy, and a private durable state directory. | ##### `@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](https://b4.run/docs/api/core#b4runcore). | | `RuntimeFetchHandler` | Handle a web-standard runtime request. | | `SerializedError` | Describe one serialized error and its cause chain. | | `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. | | `describeConnectionTarget` | Reduce a connection string to its scheme, host, port and database for logging. | | `errorStackOf` | Find the first stack trace in a thrown value's cause chain. | | `formatErrorChain` | Render a thrown value and its cause chain as operator-readable log lines. | | `normalizeMiddlewareModule` | Normalize generated middleware exports. | | `normalizeThreadAccessModule` | Normalize generated thread-access policy exports. | | `normalizeWsProxy` | Normalize a `B4_PG_WS_PROXY` value (`host:port`, `ws://`, or `wss://`) into the address and TLS flag the neon driver needs. | | `PostgresDriver` | Name the Postgres driver a generated store factory opens (`neon` or `pg`). | | `PostgresDriverSelection` | Describe the driver decision and optional proxy target for one request. | | `readRuntimeEnv` | Re-export [`@b4run/core` environment lookup](https://b4.run/docs/api/core#b4runcore). | | `seedB4Config` | Re-export [`@b4run/core` static configuration seeding](https://b4.run/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](https://b4.run/docs/api/core#b4runcore). | | `selectPostgresDriver` | Decide per request which driver talks to `DATABASE_URL` on the `hono` or `vercel` target; see [Edge and Hono](https://b4.run/docs/deployment/edge#database-drivers-and-b4_pg_ws_proxy). | | `SelectPostgresDriverInput` | Supply `DATABASE_URL`, `B4_PG_DRIVER`, `B4_PG_WS_PROXY`, and the target to `selectPostgresDriver`. | | `serializeError` | Read the message, code and nested cause off any thrown value, including an `ErrorEvent`. | | `WsProxyTarget` | Carry a normalized proxy address and whether the WebSocket stays secure. | ##### `@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. | | `ReadResponseFormatResult` | Represent a parsed or rejected client response schema. | | `ResumeResolution` | Represent resolved resume input. | | `RuntimeFetchHandler` | Handle runtime fetch requests. | | `RuntimeRegistry` | Resolve materialized routes. | | `RuntimeRequestListener` | Handle Node HTTP requests. | | `SandboxManager` | Manage runtime sandboxes. | | `ServeFallback` | Handle requests the runtime does not own. | | `ServeGuard` | Answer or pass every request before routing. | | `ServeHandle` | Control a composed dev server. | | `ServeOptions` | Configure a composed dev server. | | `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. | | `readResponseFormat` | Parse a run body's `hashbrown.responseSchema` into a bound response format. | | `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. | | `serve` | Serve the runtime's routes beside an application handler. | | `serveRuntime` | Start the production runtime server. | | `startRuntimeServer` | Assemble and start the Node server. | | `streamResolvedRoute` | Stream a resolved route. | These exports are for framework tooling and test harnesses. Application code should use 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`](https://b4.run/docs/api/sdk#b4runsdktesting). ##### `@b4run/cli/workspace` | Export | Responsibility | |---|---| | `WithWorkspaceOptions` | Select source, provider, policy, and a private durable state directory. | | `cleanupWorkspaces` | Remove managed workspaces from an inactive, dedicated app installation with exclusive ownership and retryable deletion. | | `withWorkspace` | Run host-authored work in a fresh managed workspace with durable cleanup. | | `ManagedWorkspaceReadOptions` | Name the worker app root, its provider, the thread to read, cancellation, and the reader identity. | | `openManagedWorkspaceReader` | Resolve a thread to its published managed workspace through the worker's installation store and open it read-only. | | `withManagedWorkspaceReader` | Open a managed workspace reader for a thread, hand it to an operation, and always close it. | | `readThreadWorkspace` | Read a thread's workspace over a worker's Agent Protocol port (`sandbox.workspaceRead: "http"`) and verify the answer. | | `ReadThreadWorkspaceOptions` | Name the root, omitted directories, required links, ignored prefixes, and limits of an HTTP workspace read. | | `ReadThreadWorkspaceInit` | Carry the request headers, cancellation, the expected source digest, the response-size cap, and an optional `fetch`. | | `ThreadWorkspaceRead` | Return the thread id, its recorded source and intent digests, the root, and the inspection. | | `ThreadWorkspaceReadError` | Report a refused or unverifiable read with the worker's status, code, and details. | The package root exports the first three. This subpath lets a host process own the workspace lifecycle without importing the command program at the root. ##### `bin:b4` `bin:b4` is the executable, 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](https://b4.run/docs/cli) for command syntax. #### Key contracts ##### `serveRuntime()` Embeds B4.run's production Node server. It starts once, returns a close handle, and does not watch the filesystem. ```ts export declare function serveRuntime(opts: ServeRuntimeOptions): Promise ``` ```ts 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 readonly threadAccessExpected?: boolean } ``` **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. | | `readonly threadAccessExpected` | `boolean` | no | Record that the build saw a [thread access policy](https://b4.run/docs/thread-access), so a `modules` manifest without one fails the boot instead of serving thread endpoints open. The node target's generated `server.mjs` sets it. | ###### Behavior contract `cli.serve.production-boot` serveRuntime starts without running type generation or writing .b4 artifacts. ###### Behavior contract `cli.serve-runtime.port-precedence` 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 The `/fetch` handler has no filesystem fallback for its module map or its required stores, so an edge host must inject them. The host may also pass a constructed configuration. Without one, runtime defaults apply. ###### Behavior contract `cli.fetch.request-store-lifecycle` 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. ##### Disposable managed execution `withWorkspace(options, callback)` captures `options.workspace` relative to `appRoot`, uses `provider.workspaces`, and invokes the callback with ordinary sandbox filesystem/command backends. Its private `stateRoot` retains unfinished cleanup across process restarts. One invocation may own each state directory; use separate directories for concurrent jobs. Prior unfinished work is destroyed before a fresh callback starts. Cleanup is part of completion: failures propagate and retained deletion records support retry. An independent verifier can use this instead of duplicating provider lifecycle code. ##### Reading a managed workspace `withManagedWorkspaceReader({ appRoot, provider, threadId, signal }, operation)` reads the workspace of a thread whose app declares `sandbox.workspace`. Those threads never reach `SandboxProvider.acquire`, so `withWorkspaceReader` can't address them by thread id. The helper opens the worker's installation store under `appRoot` read-only, without taking the worker's owner lock. It resolves the thread's published `ReadyWorkspace` and passes it to `provider.workspaces.openWorkspaceReader`. It refuses, with the reason, when: - the provider has no managed or read capability, - the installation or the thread's association is missing, or - the workspace isn't published yet (`retryable`), is deleted (`lost`), or is past its retention deadline (`expired`). It never falls back to provider storage. `openManagedWorkspaceReader` does the same resolution without the scoped close. Both only address a workspace, so gate access in the calling process. `readThreadWorkspace(url, threadId, options, { headers, signal, expectedSourceDigest })` reads the same inventory over a worker's Agent Protocol port when the worker sets `sandbox.workspaceRead: "http"`, for a caller that cannot reach the worker's app root. It verifies the answer's shape, thread and (when given) source digest, and throws `ThreadWorkspaceReadError` with the worker's status and code otherwise. #### 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](https://b4.run/docs/cli), [Embed the Runtime](https://b4.run/docs/embedding), [Edge and Hono](https://b4.run/docs/deployment/edge), and [Deployment Options](https://b4.run/docs/deployment). --- ### @b4run/core Source: https://b4.run/docs/api/core #### Use this when Route authors normally use [`@b4run/sdk`](https://b4.run/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 npm install @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 stays free of that filesystem loader, 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. | | `BuildTargetName` | Name the known `b4 build` targets as a union (`"node" | "langsmith" | "hono" | "vercel"`); types `build.targets`. | | `BUILD_TARGET_NAMES` | List the known `b4 build` target names; the tuple `BuildTargetName` is derived from. | | `isBuildTargetName` | Narrow an untyped string to a known `b4 build` target name. | | `BUILT_IN_TOOL_NAMES` | List built-in capability tool names. | | `ClientToolCallEnvelope` | Describe the interrupt envelope a parked client tool call leaves in the checkpoint. | | `ClientToolDefinition` | Describe a client-provided tool definition after envelope validation. | | `ClientToolResumeValue` | Describe the value the internal resume delivers to a parked client tool call. | | `ClientToolStubOptions` | Options for `createClientToolStub`, such as a replay-only stub. | | `ABANDONED_CLIENT_TOOL_RESULT` | The tool result recorded for a client tool call the client never answered. | | `CLIENT_TOOL_CALL_TYPE` | The interrupt envelope `type` of a parked client tool call. | | `CLIENT_TOOL_PREFIX` | The model-visible prefix of every client tool name (`client_`). | | `CLIENT_TOOL_UNAVAILABLE_RESULT` | The tool result for a new call to a replay-only stub whose tool was not offered on this run. | | `clientToolInterruptId` | Derive the stable interrupt id of a client tool call from its provider tool-call id. | | `createClientToolStub` | Create the stub tool that gates, records and parks a client tool call. | | `gateClientToolOp` | Gate a client tool call under the `clientTool` permission key. | | `isClientToolCallEnvelope` | Narrow an interrupt value to a client tool call envelope. | | `MissingClientToolRecorderError` | Thrown when a client tool call would park with no recorder to answer it. | | `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. | | `B4AppError` | Describe an app-root or route-entry failure that carries a `B4_Exxxx` code. | | `NamespaceListingCheckpointer` | A checkpointer that enumerates a thread's checkpoint namespaces; B4's savers implement it, `GET /threads/:id/turns` requires it. | | `canListNamespaces` | Whether a checkpointer implements `listNamespaces`. | | `UnresolvedToolInputTypeError` | Thrown by tool-schema extraction when a tool's declared input type resolves to `any`/`unknown` or depends on an import that does not resolve; the message names the tool file, the type, and the tsconfig used. | | `UnresolvedToolInputTypeDetails` | The structured `details` on `UnresolvedToolInputTypeError`: tool name, file, type text, what it resolved to, unresolved imports, and the tsconfig path. | | `UnresolvedImport` | One unresolved module specifier or member from the compiler, with its diagnostic text. | | `__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. | | `impliedToolDenials` | List tools a scope withholds through an implied deny. | | `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. | | `configureApprovalGrants` | Raise the process-wide approval-grant mode. | | `approvalGrantMode` | Read the approval-grant mode in force in this process. | | `mintGrantForPark` | Mint the grant for a park, or fail closed when one is required. | | `MissingApprovalGrantMinterError` | Report a required grant that no minter could issue. | | `__resetApprovalGrantsForTests` | Drop the approval-grant latch so a suite can exercise every mode. | | `VercelBuildConfig` | Describe the `vercel` target's static assets, extra functions, and routes (`build.vercel`). | | `VercelBuildRoute` | One Build Output route with a required `src`. | | `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](https://b4.run/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. Optional `appRoot` (where the nearest `tsconfig.json` search starts; defaults to `routeDir`) and `tsconfig` (an explicit config to compile with). | | `extractToolSchemasForRoute` | Extract tool schemas for a route. Throws `UnresolvedToolInputTypeError` when a declared input type resolves to `any`/`unknown`. | | `ExtractToolTypesOptions` | Configure compiler-backed type extraction; accepts the same `appRoot` and `tsconfig` options. | | `extractToolTypesForRoute` | Extract tool types for a route. | ##### `@b4run/core/internal/compiler` This subpath is internal and carries no compatibility promise. The CLI uses it during type generation. #### Key contracts ##### `loadB4Config()` Use this low-level seam when an integration owns configuration loading. ```ts export declare function loadB4Config(options: LoadB4ConfigOptions): Promise ``` ###### Behavior contract `core.load-config.failed-load-eviction` 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()` Turns state defaults and optional reducer overrides into the normalized fields that type generation and runtime state assembly consume. ```ts export declare function resolveStateFields( options: ResolveStateFieldsOptions, ): readonly ResolvedStateField[] ``` ###### Behavior contract `core.state.reducer-resolution` resolveStateFields infers append for array defaults and replace for scalar defaults, honors explicit reducer overrides, and sorts fields by name. ##### Configuration ownership See [Configuration Reference](https://b4.run/docs/configuration) for the full `B4Config` schema. The root loader is a runtime seam: Node callers import `/node` to register disk loading, and edge callers seed a 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](https://b4.run/docs/configuration), [Routes](https://b4.run/docs/routes), [State](https://b4.run/docs/state), and [`b4:routes`](https://b4.run/docs/api/generated-routes). --- ### @b4run/ag-ui Source: https://b4.run/docs/api/ag-ui #### Use this when Use this package when a web client speaks AG-UI and your route emits B4.run stream chunks. Most applications should start with [AG-UI and Web Clients](https://b4.run/docs/ag-ui). This reference is for wiring a custom transport or client adapter. #### Install and import ```bash npm install @b4run/ag-ui ``` ```ts import { fromRunAgentInput, toAguiEvents } from "@b4run/ag-ui" import { encodeAgUiEvent } from "@b4run/ag-ui/sse" ``` A client that runs a B4.run route over HTTP, such as a CopilotKit runtime, imports the agent from the `./client` subpath: ```ts import { B4HttpAgent } from "@b4run/ag-ui/client" ``` A client in any framework folds the event stream into turns with the reducers from the `./view` subpath: ```ts import { reduceTurns } from "@b4run/ag-ui/view" ``` In a React client, and only there, import the activity kit from the `./react` subpath: ```tsx import { ApprovalCard, TurnActivity } from "@b4run/ag-ui/react" ``` A React CopilotKit chat connects the kit through the `./react/copilotkit` subpath, which is the only entry that imports `@copilotkit/react-core`: ```tsx import { B4Activity, useB4ChatSlots } from "@b4run/ag-ui/react/copilotkit" ``` In an Angular client, import the activity kit from the `./angular` subpath, and connect it to a thread's events with `./angular/events` or to CopilotKit's `` with `./angular/copilotkit`, the only Angular entry that imports `@copilotkit/angular`: ```ts import { TurnActivityComponent } from "@b4run/ag-ui/angular" import { MessageActivityComponent, provideB4Turns } from "@b4run/ag-ui/angular/events" import { provideB4Activity } from "@b4run/ag-ui/angular/copilotkit" ``` Both kits share one stylesheet, `@b4run/ag-ui/styles.css`. A CopilotKit runtime route restores a conversation from B4's storage with `createB4AgentRunner` from the `./copilotkit-runtime` subpath. The entry never imports `@copilotkit/runtime`: the route passes CopilotKit's `InMemoryAgentRunner` class in, so the runner extends whichever copy the route resolves, wherever the package manager placed it. It depends on `rxjs`, which installs with the package: ```ts import { InMemoryAgentRunner } from "@copilotkit/runtime/v2" import { createB4AgentRunner } from "@b4run/ag-ui/copilotkit-runtime" const runner = createB4AgentRunner(InMemoryAgentRunner, { url, fetch }) ``` #### 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/client` | edge-safe | not-claimed | integration | supported | | `@b4run/ag-ui/view` | edge-safe | not-claimed | integration | supported | | `@b4run/ag-ui/react` | node-only | not-claimed | application | supported | | `@b4run/ag-ui/react/copilotkit` | browser-only | not-claimed | application | supported | | `@b4run/ag-ui/angular` | browser-only | not-claimed | application | supported | | `@b4run/ag-ui/angular/events` | browser-only | not-claimed | application | supported | | `@b4run/ag-ui/angular/copilotkit` | browser-only | not-claimed | application | supported | | `@b4run/ag-ui/styles.css` | n/a | n/a | integration | supported | `@ag-ui/client` `>=1.0.1 <2.0.0` is an optional peer. Runtime validators (`RunAgentInputSchema`, `AgentCapabilitiesSchema`, `*EventSchema`) live at `@ag-ui/core/schemas` in 1.0; import types from `@ag-ui/core`. The adapter only translates protocol data, so it neither authenticates a caller nor makes client-supplied thread, run, state, tool, or context data authoritative. `@b4run/ag-ui/view` is the framework-free client entry: pure reducers over AG-UI events with no React dependency. `@b4run/ag-ui/react` is the React activity kit built on it: `TurnActivity`, `ApprovalCard` and the step rows take the view core's props and render one turn in plain language. `@b4run/ag-ui/react/copilotkit` connects the kit to a stock CopilotKit chat. React and `@copilotkit/react-core` are optional peer dependencies, so a server that imports only the root or `/sse` entry installs neither; only `./react/copilotkit` imports CopilotKit's React package. `@ag-ui/client` is the same kind of optional peer, needed only by `./client`. The `./react` entry is recorded as `node-only` because B4.run's edge guard requires an import graph free of unguarded Node globals, and React's JSX runtime reads `process.env.NODE_ENV`. It is meant for browser bundles, where your bundler substitutes that value. The `./react/copilotkit` entry is recorded as `browser-only`: CopilotKit's bundle imports its own CSS, so plain Node cannot load it either. Browser bundles only: import it from a bundled React app, not from Node or an edge runtime. `@b4run/ag-ui/angular` is the Angular activity kit, built on the same view core and rendering the same DOM contract as `./react`; `./angular/events` and `./angular/copilotkit` connect it to a chat. `@angular/core`, `@angular/common`, `@angular/platform-browser` (`^22.0.0`) and `@copilotkit/angular` (`>=0.5.3`) are optional peer dependencies, and only `./angular/copilotkit` imports CopilotKit's Angular package. The three Angular entries ship in Angular's partial compilation format, which your Angular build's linker finishes, so they are recorded as `browser-only`: import them from an Angular application built with the Angular CLI (`@angular/build`), not from Node or an edge runtime, where a plain import fails for want of Angular's JIT compiler. #### 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`. | | `B4PlanActivityContent` | Describe the complete public plan snapshot. | | `IdFactory` | Define event-ID generation. | | `B4Message` | Describe a normalized inbound message; `content` is text or AG-UI content parts. | | `B4RunInput` | Describe normalized B4.run run input. | | `fromRunAgentInput` | Translate AG-UI run input to B4.run input. | | `B4InterruptEnvelope` | Describe a B4.run interrupt payload. | | `B4AguiInterrupt` | AG-UI's interrupt as B4.run emits it: the gated `toolCallId`, `subagentRunId` for a child's gate, a permission prompt's `responseSchema`, and the approval grant at `metadata.grant`. | | `B4ResumeRequest` | Describe one resume decision. | | `AguiOutboundEvent` | Name events emitted by the outbound mapper. | | `ToAguiOptions` | Configure outbound event IDs, the cancelled outcome, and pending client tool calls. | | `toAguiEvents` | Translate a B4.run stream into AG-UI events. | | `B4_CONTENT_PARTS_DROPPED_EVENT` | The `CUSTOM` event name (`b4.content_parts_dropped`) that announces parts the model could not use. | | `B4AgentStreamChunk` | Describe accepted B4.run stream chunks. | | `B4UsageData` | Describe one model call's token usage as the `usage` chunk carries it. | | `RunContext` | Supply thread and run IDs for outbound events. | ##### `@b4run/ag-ui/sse` | Export | Responsibility | |---|---| | `encodeAgUiEvent` | Encode one AG-UI event as the bytes of the binding `accept` selects: an SSE frame, or a length-prefixed protobuf frame. | | `agUiContentType` | The `content-type` of the binding `accept` selects. | ##### `@b4run/ag-ui/client` | Export | Responsibility | |---|---| | `B4HttpAgent` | An `@ag-ui/client` `HttpAgent` whose `getCapabilities()` reads the route's [capabilities](https://b4.run/docs/ag-ui#capabilities) from `GET` on the same URL. | ##### `@b4run/ag-ui/view` The framework-free half of the client: pure reducers and selectors over AG-UI events, with no React and no CopilotKit. `./react` builds on it; so can any other framework. The runtime uses the same entry to serve `GET /threads/:thread_id/turns`, so a restored thread folds into the same `TurnsView` a live stream builds. It also holds the activity kit's framework-free rules (wording, durations, the open/closed rule, the clocks, the glyphs), so every kit words, times and draws a turn the same way. | Export | Responsibility | |---|---| | `reduceTurns` | Fold one AG-UI event into the turns of a thread: steps in order, nested subagent turns, the plan, reasoning, prose, and approvals attached to the calls they gate. Pure given `now`; an event that changes nothing returns the same state. | | `EMPTY_TURNS` | The initial turns state. | | `turnsFromState` | Rebuild a thread's turns from its checkpoint history and parked interrupts: the AG-UI events the stream would have carried are synthesised and folded through `reduceTurns`, so a restored view matches a live one. Never throws; ignored or missing stamps are named in `warnings`. | | `eventsFromState` | The thread as the AG-UI events its live runs would have carried, synthesised from the same input as `turnsFromState`: each turn's `RUN_STARTED` carries the user message (as content parts when it had media), a tool result that kept parts replays them as live sends them, a parked turn ends with `RUN_FINISHED { outcome: interrupt }`. What `GET /threads/:id/events` serves. Never throws. | | `blocksToParts` | A checkpointed message's LangChain content blocks mapped back to AG-UI content parts (text, image, audio, video, document; the legacy `image_url` block too), the reverse of what the runtime hands the model. `eventsFromState` uses it so a restored user attachment replays as parts. Never throws. | | `EventsFromStateResult` | What `eventsFromState` returns: `events` (each with `timestamp`) and `warnings`. | | `ThreadStateForTurns` | What `GET /threads/:id/turns` assembles: thread id and status, the root namespace's history, child namespaces, and parked interrupts. | | `CheckpointForTurns` | One decoded checkpoint: id, timestamp, metadata, and `values.messages`/`values.todos` as serialized LangChain envelopes. | | `PendingInterruptForTurns` | A parked interrupt as `/pending_interrupts` returns it, with its grant. | | `TurnsFromStateResult` | The rebuilt `TurnsView` plus warnings. | | `TurnsView` | The thread id and its turns. | | `TurnView` | One turn: its run id, status, timing, steps, prose, unattached approvals, error and failure count (nested subagents included). | | `StepView` | A tool, plan, reasoning or subagent step. | | `ToolStep` | One tool call with its streamed args, result text, non-text result `parts` (an image a tool rendered, live or restored), `b4.step` icon, label and sources, timing and approval. | | `PlanStep` | The plan as its latest snapshot of todos. | | `ReasoningStep` | One reasoning span and its text, streaming or done. | | `SubagentStep` | A subagent invocation holding its nested turn. | | `ApprovalView` | A parked interrupt as a chat shows it: kind, detail, message, the grant to echo on resume, and whether "Always allow" is offered. | | `StepSource` | A source a step cites; an alias of the SDK's `ToolDisplaySource`, as `b4.step` carries it. | | `StepStatus` | `pending`, `running`, `done`, `failed` or `awaiting`. | | `TurnStatus` | `working`, `awaiting`, `done`, `failed` or `stopped`. | | `ReduceTurnsOptions` | Inject the clock, name tools whose frames the view drops, and say whether a same-thread run resumes the awaiting turn (`resuming`). | | `stepLabel` | The sentence for a tool step: the app's override for that tool, else the server's `b4.step` label, else "Using X…"/"Used X"; never throws, never echoes raw arguments, at most 120 characters. | | `groupSteps` | Fold two or more consecutive done calls of one tool into a group with a summary label; pending, running, failed and awaiting steps never join, nor do `task` and `writeTodos`. | | `StepLabelOverride` | Client-side wording for one tool: reword, localize, or label a group. | | `StepLabelOverrides` | Overrides keyed by tool name. | | `StepGroup` | A run of merged tool steps and its label. | | `GroupedStep` | A step or a group. | | `BUILT_IN_GROUP_LABELS` | Group wording for B4.run's built-in tools; apps extend it through overrides. | | `B4_STEP_EVENT_NAME` | The AG-UI `CUSTOM` event name a tool call's human-readable step travels as (`b4.step`). | | `B4_STEP_STATUSES` | The statuses a step can carry; the one source for every validator. | | `B4StepEventValue` | A `b4.step` event's value: `toolCallId`, `status`, `icon`, `label`, `sources`. | | `B4StepStatus` | `running`, `completed`, `failed` or `denied`. | | `readStepEvent` | The `b4.step` value an event carries, or undefined for any other event or a malformed one; invalid optional fields are dropped. | | `isSubagentMessage` | Narrow a transcript message to one a subagent produced. | | `turnForToolCalls` | The turn that owns any of these tool call ids, nested subagent calls included. | | `turnForMessage` | The turn an assistant message belongs to, by the tool calls of its run of messages, else by position from the end of the thread; `first` marks the run's first assistant message, where a chat with one slot per message renders the turn's activity. | | `TranscriptMessage` | What `turnForMessage` reads of a message: id, role and the assistant's tool calls. AG-UI's `Message` fits. | | `MessageTurn` | A message's turn and whether the message is the turn's first assistant message. | | `approvalFromInterrupt` | A parked AG-UI interrupt as an `ApprovalView`: kind, detail, message, grant, and whether "Always allow" is offered. | | `approvalPrompt` | The card's title, `{agent} wants to {label}`, for a parked interrupt: the gated step's running label and the subagent it sits in, else "The agent" and "continue". | | `approvalLabel` | A gated step's running label, lower-cased, as it follows "wants to"; "use X" when the step has none. | | `ApprovalPrompt` | An approval card's agent and label. | | `pendingApprovals` | The approval cards an awaiting thread shows, read from its turns alone: every awaiting step's approval in the last turn and its paused subagents, then the interrupts that named no step. | | `PendingApproval` | One card to show: the `ApprovalView` and its prompt. | | `summaryLine` | The summary sentence and meta for a turn at a given time. | | `nestedSummaryLine` | The summary of a subagent's own turn: like any turn while it runs, then "researcher · paused", "researcher finished · 5 steps" or "researcher failed · boom". | | `NestedTurn` | A subagent's name and status, as its nested turn's summary needs them. | | `SummaryLine` | A summary line: its text, its muted meta, and whether it shows the running treatment. | | `formatDuration` | `<1s`, `Ns`, `Nm Ms`; undefined for an unknown duration. | | `countSteps` | Steps in a turn and its nested subagent turns; a subagent counts as one step too. | | `countSources` | Sources cited by a turn's tool steps and its nested subagent turns. | | `stepMeta` | A tool step's muted tail: awaiting approval, denied, or failed with no error text. | | `groupMeta` | A merged group's muted tail: how many sources its calls cite. | | `ownEntry` | An own-key lookup in a per-tool table, so a wire name like `toString` never finds `Object.prototype`. | | `planProgress` | How many plan items are done, of how many. | | `todoStatusLabel` | A plan item's status as the visually hidden text after it. | | `reasoningLabel` | "Thinking…", "Thought for 4s", or "Show reasoning" when the duration is unknown. | | `subagentRowState` | A subagent row's `data-state`; a paused subagent reads as awaiting. | | `isSubagentLive` | Whether a subagent is running or paused on an approval. | | `subagentSettledText` | A settled subagent row's sentence: "researcher finished" or "researcher failed". | | `subagentMeta` | A subagent row's muted tail: the error once failed, the step count once done. | | `stepDetailText` | A step's input and result pretty-printed and capped, the text "Show raw" opens, and whether both are empty. | | `stepDetailView` | A step's input and result as the detail panel shows them: rows for an object (one nested level as dotted keys), a group of rows per object in a short list, plain text for a string or scalar, pretty JSON for anything deeper, plus the raw original of each side shown as rows. | | `StepDetailView` | What `stepDetailView` returns: the input and result blocks, the raw originals, and whether both are empty. | | `DetailBlock` | One side of a step's detail: `fields`, `records`, `text` or `code`. | | `DetailField` | One key/value row of a `fields` block. | | `MAX_FIELD_CHARS` | The longest field value a row shows (80 characters) before it is cut to its first line with its full length. | | `prettyValue` | JSON pretty-printed with two spaces; any other text as-is. Never throws. | | `capDetail` | A detail text cut at `MAX_DETAIL_CHARS` with a "… (truncated)" line. | | `MAX_DETAIL_CHARS` | The longest inputs or output a detail panel shows, 20,000 characters. | | `isSafeHref` | Whether a source's link may render as a link: web, mail and same-origin paths only. | | `approvalPayload` | The payload an approval card shows: args preview, command, a subagent gate's name, input and reason, or detail JSON. | | `scopeLine` | What "Always allow" covers, from the interrupt's kind and pattern. | | `approvalErrorLine` | The line an approval card shows when its decision could not be sent. | | `dispatchDecision` | Run an approval decision on the click itself and report a throw or a rejection as a message. | | `ApprovalDecision` | `once`, `always` or `deny`. | | `DisclosureMemory` | What the open/closed rule remembers: the user's choice, whether the item was live, and the key it was last live under. | | `initialDisclosure` | The memory of a disclosure when it first renders. | | `observeDisclosure` | The open/closed rule: automation decides until the user toggles; the user wins until the item is live again under a new key. | | `toggleDisclosure` | The memory after the user toggles. | | `isDisclosureOpen` | Whether a disclosure is open: the user's choice, else automation's. | | `NO_FLASH_MS` | A step that settles within this many milliseconds (300) never shows its running treatment. | | `noFlashRemaining` | Milliseconds until a running step may show its running treatment. | | `ELAPSED_TICK_MS` | How often a live turn's elapsed time is re-sampled (1000 ms). | | `sampleElapsed` | Sample a clock now and once a second until stopped. | | `STEP_GLYPHS` | Each step icon as SVG shapes: one per `ToolDisplayIcon` name plus `alert` and `check`. | | `stepGlyph` | A step's glyph by icon name; unknown names draw the generic `tool` glyph. | | `CHEVRON_GLYPH` | The disclosure chevron's shapes. | | `checklistBox` | A plan item's checkbox shape, filled once done. | | `CHECKLIST_TICK` | The tick drawn over a done plan item's box. | | `Glyph` | A glyph: a list of shapes. | | `GlyphShape` | One SVG shape: its element name and DOM attributes. | ##### `@b4run/ag-ui/react` The React activity kit. Components are pure functions of the `TurnView`/`StepView` props `@b4run/ag-ui/view` builds; interaction state (open/closed, elapsed time, the 300 ms no-flash rule) lives in the hooks next to them. | Export | Responsibility | |---|---| | `TurnActivity` | The summary line plus the step list for one turn, nested for subagents: working, awaiting, done, failed or stopped. | | `TurnActivityProps` | Props for `TurnActivity`: the turn, label overrides, step renderers, the clock, and whether it is a subagent's turn. | | `Step` | One tool call as a sentence with its icon and meta; opens to `StepDetail`. | | `StepProps` | Props for `Step`: the tool step, label overrides, step renderers and the clock. | | `StepRenderer` | A per-tool replacement for a step's detail panel (the `StepDetail` it opens to), given the `ToolStep`; the sentence line stays. | | `StepRenderers` | Step renderers keyed by tool name. | | `StepGroup` | Consecutive done calls of one tool, merged; opens to the individual steps. | | `StepGroupProps` | Props for `StepGroup`: the group, label overrides, step renderers and the clock. | | `StepDetail` | A step's input and result, readable first: rows for an object, a group of rows per object in a short list, plain text for a string, pretty JSON for anything deeper, and a "Show raw" toggle for a side shown as rows. | | `StepDetailProps` | Props for `StepDetail`: the args text and the optional result text. | | `PlanStep` | "Made a plan · 2 of 4 done" with the checklist; updates in place. | | `PlanStepProps` | Props for `PlanStep`: the plan step and whether the owning turn is live. | | `ReasoningStep` | "Thinking…", "Thought for 4s" or "Show reasoning"; opens to the text. | | `SubagentStep` | "Asked researcher", its description as a muted line beneath, with the child's own activity nested; folds to "researcher finished · N steps". | | `SubagentStepProps` | Props for `SubagentStep`: the subagent step, label overrides, step renderers and the clock. | | `ApprovalCard` | A parked interrupt: who wants what, why, the payload, Allow once / Always allow / Deny on one row, and the scope line. | | `ApprovalCardProps` | Props for `ApprovalCard`: the approval, the agent's name, the gated step's label and the decision callback. | | `ApprovalDecision` | `once`, `always` or `deny`. | | `SourceChips` | File or URL chips from a step's sources, with "+N" overflow. | | `SourceChipsProps` | Props for `SourceChips`: the sources and the chip limit before overflow. | | `Disclosure` | A `button[aria-expanded]` and its panel, following the open/closed rule. | | `DisclosureProps` | Props for `Disclosure`: open state, toggle, the button class, the summary and the panel. | | `useDisclosure` | The open/closed rule: automation decides until the user toggles; the user wins until the item is live again. | | `StepIcon` | The glyph for a `ToolDisplayIcon` name (16px, `currentColor`). | | `Chevron` | The disclosure chevron. | | `StatusText` | The muted "· meta" fragment after a sentence. | | `Checklist` | The plan's SVG checklist. | | `summaryLine` | The summary sentence and meta for a turn at a given time. | | `SummaryLine` | A summary line: its text, its muted meta, and whether it shows the running treatment. | | `formatDuration` | `<1s`, `Ns`, `Nm Ms`; undefined for an unknown duration. | | `approvalPayload` | The payload an approval card shows: args preview, command, a subagent gate's name, input and reason, or detail JSON. | | `scopeLine` | What "Always allow" covers, from the interrupt's kind and pattern. | | `useLive` | Whether a running step shows its running treatment (after 300 ms). | | `useElapsed` | A clock re-sampled once a second while active. | `TurnActivity` is the drop-in: give it a `TurnView` from `reduceTurns` and it renders the turn. The kit components are plain React and don't need CopilotKit; the CopilotKit connector is `@b4run/ag-ui/react/copilotkit`. ##### `@b4run/ag-ui/react/copilotkit` The CopilotKit (React) connector: it drives a stock `` with the activity kit. | Export | Responsibility | |---|---| | `B4Activity` | Provider around your chat: hides CopilotKit's generic tool rows, renders one `ApprovalCard` per parked interrupt (in the chat by default; `renderInChat={false}` renders them after its children for a host with its own transcript), and keeps the thread's turns current from the agent's events. Supports `approvals.grants: "off"` today. | | `B4ActivityProps` | Props for `B4Activity`: the agent id, per-tool `labels`, `hiddenTools`, per-tool `renderStep`, `renderInChat`, the clock and children. | | `useB4ActivityContext` | The turns, labels and `renderStep` `B4Activity` provides, for host UI outside the chat (a map, a sheet); throws outside `B4Activity`. | | `B4ActivityContextValue` | What `useB4ActivityContext` returns: the `TurnsView`, the `labels` and `renderStep` passed to `B4Activity`, and its clock. | | `useB4ChatSlots` | Props to spread onto ``: one tool row per turn rendered as `TurnActivity`, no toolbar under tool-only rows. | | `B4ChatSlots` | The `messageView` slots `useB4ChatSlots` returns: `transformMessages` and `assistantMessage`. | | `useB4Turns` | The agent's thread as turns (`reduceTurns`), plus `markResuming()` to call before sending a resume and `clearResuming()` to forget it when the resume request failed. | | `UseB4TurnsOptions` | Options for `useB4Turns`: the clock and the tools whose frames the view drops. | | `UseB4TurnsResult` | What `useB4Turns` returns: the `TurnsView`, `markResuming` and `clearResuming`. | | `mergeTurnMessages` | The `transformMessages` function `useB4ChatSlots` installs: one tool-only assistant row per turn, the kept row carrying the union of the turn's tool-call ids, subagent messages dropped. | ##### `@b4run/ag-ui/angular` The Angular activity kit: standalone, `OnPush` components with signal inputs that render the same DOM contract (classes, `data-*` and `aria-*` attributes, text) as the React kit, from the `TurnView`/`StepView` values `@b4run/ag-ui/view` builds. Element hosts are `display: contents`; the step rows attach to the list item itself (`

  • `). | Export | Responsibility | |---|---| | `TurnActivityComponent` | ``: the summary line plus the step list for one turn, nested for subagents. Inputs: `turn`, `labels`, `renderStep`, `now`, `nested`. | | `ApprovalCardComponent` | ``: a parked interrupt with Allow once / Always allow / Deny and the scope line; a throw or rejection from `onDecide` shows inline and re-enables the buttons. | | `StepComponent` | `
  • `: one tool call as a sentence; opens to its inputs and output. | | `StepRenderers` | Per-tool components keyed by tool name that replace a step's detail panel; each receives the step as its `step` input. | | `StepGroupComponent` | `
  • `: consecutive done calls of one tool, merged. | | `PlanStepComponent` | `
  • `: "Made a plan · 2 of 4 done" with the checklist. | | `ReasoningStepComponent` | `
  • `: "Thinking…", "Thought for 4s" or "Show reasoning". | | `SubagentStepComponent` | `
  • `: "Asked researcher" with the child's activity nested. | | `StepDetailComponent` | ``: a step's input and result: rows, a group of rows per object, plain text or pretty JSON, with "Show raw" for a side shown as rows. | | `SourceChipsComponent` | ``: file or URL chips with "+N" overflow. | | `DisclosureComponent` | ``: a `button[aria-expanded]` and its panel, for custom steps. | | `StepIconComponent` | ``: the glyph for a `ToolDisplayIcon` name. | | `ChecklistComponent` | ``: the plan's SVG checklist. | | `SvgAttrsDirective` | `[b4SvgAttrs]`: sets an SVG shape's attributes from a shared glyph. | | `disclosure` | The open/closed rule over signals: automation decides until the user toggles; the user's choice holds until the item is live again. | | `DisclosureState` | What `disclosure` returns: the open state and its toggle. | | `elapsedSignal` | A clock re-sampled once a second while active; call in an injection context. | | `liveSignal` | Whether a running step shows its running treatment (after 300 ms); call in an injection context. | ##### `@b4run/ag-ui/angular/events` The Angular kit's connector for any chat, with no chat framework: give it the thread's AG-UI events. | Export | Responsibility | |---|---| | `B4TurnsStore` | The thread as a `turns` signal, folded by `reduceTurns`: `connect(events$)`, `apply(event)`, `reset(turns?)` for a restored view, `markResuming()`/`clearResuming()`, `destroy()`. An event with a numeric `timestamp` (a replay) is folded at that time. | | `createB4Turns` | A store that stops following its stream when the calling injection context is destroyed (or pass `injector`). | | `provideB4Turns` | Provides a store to a subtree, from options or a function run in the injection context. | | `B4TurnsOptions` | The store's options: `events$`, `hiddenTools`, `labels`, `renderStep` and the clock. | | `B4EventStream` | A stream of AG-UI events: an RxJS `Observable`, or anything with the same `subscribe`. | | `B4TurnEvent` | One AG-UI event, as `reduceTurns` folds it. | | `MessageActivityComponent` | ``: the turn's `` on its first assistant message, nothing on the others. | | `ApprovalsComponent` | ``: one approval card per parked interrupt; a click marks the resume, then emits and awaits the decision. | | `B4ApprovalDecision` | A decision on one parked interrupt: its id and `once`, `always` or `deny`. | ##### `@b4run/ag-ui/angular/copilotkit` The CopilotKit (Angular) connector, on `@copilotkit/angular`: it drives a stock `` with the activity kit. | Export | Responsibility | |---|---| | `provideB4Activity` | Provides a `B4ActivityStore` to the subtree, also as `B4TurnsStore`; inject it in the providing component so it sees every event. | | `B4ActivityOptions` | Options for `provideB4Activity`: the agent id, per-tool `labels`, `hiddenTools`, per-tool `renderStep` and the clock. | | `B4ActivityStore` | Follows the chat's agent into `turns` and CopilotKit's parked interrupts into `interrupts`; `decide(interruptId, decision)` marks the resume, then resolves or cancels through CopilotKit. Supports `approvals.grants: "off"` today. | | `B4Interrupt` | A parked interrupt, as CopilotKit's interrupt controller holds it. | | `B4ActivityAssistantMessageComponent` | ``'s assistant message: the message's text through CopilotKit's own assistant message, then the turn's `` on the turn's first assistant message. | | `B4ActivityApprovalsComponent` | One approval card per interrupt CopilotKit holds open, as ``'s `[messageViewChildrenComponent]` or anywhere under `provideB4Activity`. | ##### `@b4run/ag-ui/copilotkit-runtime` The server half of the CopilotKit connector, for the CopilotKit runtime route in your app. It uses only `fetch` and `rxjs`, so it runs in a Node or an edge runtime route. | Export | Responsibility | |---|---| | `createB4AgentRunner` | `createB4AgentRunner(Base, options)` builds a CopilotKit runtime runner (`new CopilotRuntime({ agents, runner })`) from `Base`, CopilotKit's `InMemoryAgentRunner` class: an instance of a subclass that overrides only `connect`, so runs, `stop`, `isRunning` and the local-thread endpoints are the base runner's; `connect` replays `GET /threads/:id/events` from the B4 server, and while a run is live in this process it continues with that run's events, so a reload or a new instance restores the chat, its activity and a parked approval. | | `B4AgentRunnerBase` | The shape `createB4AgentRunner` extends: a class whose instances have `connect` (returning an observable of events) and `isRunning`. CopilotKit's `InMemoryAgentRunner` fits it; the returned runner has the base class's type. | | `B4AgentRunnerOptions` | The B4 server's `url`, the `fetch` to read with, and `onWarnings` for replays that carried warnings. The read carries only the headers that `fetch` adds, never the browser request's, so it must carry the current caller's identity, derived per request (for example from an `AsyncLocalStorage`), never a fixed service credential, which would let any browser restore any thread whose id it knows. A read the thread gate refuses (404) restores as an empty chat, with no error. | ##### `@b4run/ag-ui/styles.css` This subpath is the activity kits' default stylesheet, one sheet for the React and Angular kits. It has no TypeScript exports, runtime 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/styles.css" ``` In an Angular application, list it in `angular.json`'s `styles` (`"@b4run/ag-ui/styles.css"`) or `@import` it from a global stylesheet. The sheet is optional, and the components render without it. Every rule sits in `@layer b4-activity`, so any unlayered app CSS wins, and every rule that styles an element is scoped to the `b4-` prefix, so it can't restyle the rest of your application. The sheet also declares `--b4-activity-*` design tokens on `:root`. To restyle the kit, override these properties in your own CSS: - `--b4-activity-surface`, `--b4-activity-surface-alt`, `--b4-activity-border`, `--b4-activity-text`, `--b4-activity-muted` - `--b4-activity-running`, `--b4-activity-running-bg`, `--b4-activity-complete`, `--b4-activity-failed`, `--b4-activity-failed-bg`, `--b4-activity-primary`, `--b4-activity-on-primary` - `--b4-activity-radius`, `--b4-activity-radius-card`, `--b4-activity-radius-pill`, and `--b4-activity-font-mono` Put the overrides in plain, unlayered CSS. A Tailwind `@theme` block won't work, because token values declared there lose to this sheet. Dark mode follows the host, not the OS: the dark palette applies under a `.dark` or `[data-theme="dark"]` ancestor, or when `data-b4-theme="dark"` is set on the root element. `data-b4-theme="light"` pins light, and `data-b4-theme="auto"` follows `prefers-color-scheme` instead. The token blocks are wrapped in `:where()`, so your own `:root` override wins in every theme. #### Key contracts ##### Activity identifiers and payloads ```ts export declare const B4_PLAN_ACTIVITY_TYPE: "b4.plan" ``` ```ts export interface B4PlanActivityContent { readonly todos: ReadonlyArray<{ readonly content: string readonly status: "pending" | "in_progress" | "completed" }> } ``` ###### Behavior contract `ag-ui.activities.plan-snapshot` 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.subagents.lifecycle` A subagent is announced with `SUBAGENT_STARTED` before any event is attributed to it, every event it produces carries its `subagentRunId`, and every announced invocation closes before `RUN_FINISHED`: with `SUBAGENT_FINISHED` on its result, `suspended` at an interrupt, or `SUBAGENT_ERROR` on failure, cancel, or a stream that ended first. ##### `ToAguiOptions` Supply an `IdFactory` when emitted IDs must be deterministic. Production integrations use the default factory. ```ts export interface ToAguiOptions { readonly idFactory?: IdFactory readonly cancelled?: () => boolean readonly pendingToolCallIds?: () => readonly string[] | Promise } ``` **Fields: `@b4run/ag-ui#.:ToAguiOptions`** | Field | Type | Required | Description | |---|---|---|---| | `readonly idFactory` | `IdFactory` | no | Override outbound event-ID generation. | | `readonly cancelled` | `() => boolean` | no | Asked once when the upstream stream throws; `true` ends the run with `RUN_FINISHED` and `outcome.type: "cancelled"` instead of `RUN_ERROR`. | | `readonly pendingToolCallIds` | `() => readonly string[] \| Promise` | no | Asked when the run succeeds, and may return a Promise; a non-empty list becomes `outcome.pendingToolCallIds`. A rejection ends the run as `RUN_ERROR`. | ##### Inbound and outbound calls ```ts 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 export interface B4Message { readonly role: "user" | "assistant" | "system" | "developer" | "tool" /** Plain text, or the ordered content parts the client sent (AG-UI 1.0). */ readonly content: string | readonly B4ContentPart[] readonly id?: string readonly toolCallId?: string } ``` `B4ContentPart` is exported by `@b4run/sdk` and is structurally AG-UI's `ContentPart`. ```ts export interface RunContext { readonly threadId: string readonly runId: string } ``` ```ts export interface B4UsageData { readonly provider?: string | undefined readonly model?: string | undefined readonly usage_metadata: Readonly> } ``` **Fields: `@b4run/ag-ui#.:B4UsageData`** | Field | Type | Required | Description | |---|---|---|---| | `readonly provider` | `string \| undefined` | no | Name the provider, lower-cased from LangChain's `ls_provider`. | | `readonly model` | `string \| undefined` | no | Name the model, from LangChain's `ls_model_name`. | | `readonly usage_metadata` | `Readonly>` | yes | Carry the provider's LangChain `usage_metadata` untouched; the mapper applies the protocol's accounting. | ```ts export declare function fromRunAgentInput(input: RunAgentInput): B4RunInput ``` ```ts export declare function toAguiEvents( chunks: AsyncIterable, ctx: RunContext, options?: ToAguiOptions, ): AsyncGenerator ``` ```ts export declare function encodeAgUiEvent(event: BaseEvent, accept?: string): Uint8Array ``` ```ts export declare function agUiContentType(accept?: string): string ``` ```ts export declare class B4HttpAgent extends HttpAgent { getCapabilities(): Promise } ``` `getCapabilities()` sends `GET` to the agent's `url` with its `headers` and `fetch`, and parses the answer with AG-UI's `AgentCapabilitiesSchema`. It throws on a non-2xx answer. ###### Behavior contract `ag-ui.outbound.errors-as-events` 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` 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 edge cases 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`. A `step` chunk becomes a `CUSTOM` event named `b4.step`; a malformed one is ignored. `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 { encodeAgUiEvent } from "@b4run/ag-ui/sse" async function* aguiFrames(chunks: AsyncIterable) { for await (const event of toAguiEvents(chunks, { threadId: "thread-1", runId: "run-1" })) { yield encodeAgUiEvent(event) } } ``` Continue with [AG-UI and Web Clients](https://b4.run/docs/ag-ui), [Embed the Runtime](https://b4.run/docs/embedding), and [Security Architecture](https://b4.run/docs/security-architecture). --- ### @b4run/memory Source: https://b4.run/docs/api/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](https://b4.run/docs/memory/long-term). #### Install and import ```bash npm install @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 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 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 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 export interface BrowsePage { readonly records: readonly MemoryRecord[] readonly total: number readonly continuation: string | null } ``` ```ts 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 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 export declare function serializeNamespace(tuple: MemoryScopeTuple): string ``` ```ts export declare function approveWithReconcile( store: MemoryStore, id: string, opts: { readonly identityKeys: readonly string[]; readonly now: string }, ): Promise ``` ###### Behavior contract `memory.namespace.stable-encoding` 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. 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` 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` `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](https://b4.run/docs/memory/long-term), [Recall and Retrieval](https://b4.run/docs/memory/retrieval), [Browse and Manage Memory](https://b4.run/docs/memory/browse), and [Persistence and Tenancy](https://b4.run/docs/persistence). --- ### @b4run/memory-pgvector Source: https://b4.run/docs/api/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 are worth running Postgres. #### Install and import ```bash npm install @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. | | `IDENTIFIER_PATTERN` | Expose the lowercase identifier pattern `assertIdentifier` enforces. | | `initSchema` | Initialize pgvector storage schema. | | `vectorColumnDef` | Select vector storage and operator class by dimensions. | #### Key contracts ##### `PgvectorMemoryStore` ```ts 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 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; must match `IDENTIFIER_PATTERN`. */ schema?: string /** Table name prefix (isolates multiple stores in one database); must match `IDENTIFIER_PATTERN`. */ 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` pgvector schema and table-prefix identifiers reject unsafe characters before B4.run interpolates them into DDL. ###### Behavior contract `memory-pgvector.dimension-branches` 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` `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. Both `queryEmbedding` and `embedderId` are required to activate hybrid retrieval. Keyword and vector candidates are fused in application code. `schema` and `tablePrefix` organize tables, but they are not tenant authorization boundaries. Both are interpolated unquoted, so both must be lowercase identifiers matching `IDENTIFIER_PATTERN`. A mixed-case value is rejected at construction rather than silently folded to lowercase by Postgres. 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](https://b4.run/docs/memory/long-term), [Recall and Retrieval](https://b4.run/docs/memory/retrieval), and [Persistence and Tenancy](https://b4.run/docs/persistence). --- ### @b4run/postgres-storage Source: https://b4.run/docs/api/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 npm install @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 edge-safe classification covers the root's import graph. It is tested on local workerd with a Neon WebSocket pool, not on 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. | | `IDENTIFIER_PATTERN` | Expose the lowercase identifier pattern `assertIdentifier` enforces. | | `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. | | `InterruptGrantRecord` | Describe a stored approval-grant row. | | `InterruptGrantStore` | Define the approval-grant store contract. | | `InterruptGrantConsumption` | Describe a conditional consume's outcome. | | `PostgresInterruptGrantStore` | Define a durable approval-grant store. | | `PostgresInterruptGrantStoreOptions` | Configure approval-grant storage. | | `createPostgresInterruptGrantStore` | Create an approval-grant store. | | `ClientToolCallRecord` | Describe a stored client tool call row. | | `ClientToolCallAnswer` | Describe an answer attempt's outcome. | | `ClientToolCallStore` | Define the client tool call store contract. | | `PostgresClientToolCallStore` | Define a durable client tool call store. | | `PostgresClientToolCallStoreOptions` | Configure client tool call storage. | | `createPostgresClientToolCallStore` | Create a client tool call store. | ##### `@b4run/postgres-storage/node` The `/node` entry also re-exports every root export. The rows below list them again. Its three factories use the connection-string-aware implementations. | Export | Responsibility | |---|---| | `NodePostgresStoreOptions` | Add connection-string convenience. | | `NodePostgresPermissionsStoreOptions` | Add connection-string convenience for permissions. | | `createPostgresPool` | Build the `pg` pool this entry builds for itself, with its idle-client `'error'` listener attached; the caller owns and ends it. | | `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. | | `IDENTIFIER_PATTERN` | Re-export the identifier pattern. | | `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. | | `InterruptGrantRecord` | Re-export the approval-grant row type. | | `InterruptGrantStore` | Re-export the approval-grant store contract. | | `InterruptGrantConsumption` | Re-export the consume outcome type. | | `PostgresInterruptGrantStore` | Re-export the approval-grant store type. | | `PostgresInterruptGrantStoreOptions` | Re-export approval-grant options. | | `createPostgresInterruptGrantStore` | Create a Node approval-grant store. | | `ClientToolCallRecord` | Re-export the client tool call row type. | | `ClientToolCallAnswer` | Re-export the answer outcome type. | | `ClientToolCallStore` | Re-export the client tool call store contract. | | `PostgresClientToolCallStore` | Re-export the client tool call store type. | | `PostgresClientToolCallStoreOptions` | Re-export client tool call options. | | `createPostgresClientToolCallStore` | Create a Node client tool call store. | #### Key contracts ##### `PostgresStoreOptions` ```ts 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; must match `IDENTIFIER_PATTERN`. | | `readonly tablePrefix` | `string` | no | Prefix this application's tables; must match `IDENTIFIER_PATTERN`. | 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 export interface PostgresPermissionsStoreOptions extends PostgresStoreOptions { readonly config?: PermissionsFile readonly mode?: PermissionMode } ``` ```ts export declare function postgresCheckpointer( options?: PostgresCheckpointerOptions, ): B4PostgresSaver ``` ```ts export declare function createPostgresThreadsStore( options?: PostgresThreadsStoreOptions, ): PostgresThreadsStore ``` ```ts export declare function createPostgresPermissionsStore( options?: PostgresPermissionsStoreOptions, ): PostgresPermissionsStore ``` ```ts export interface NodePostgresStoreOptions extends PostgresStoreOptions { readonly connectionString?: string } ``` ```ts export interface NodePostgresPermissionsStoreOptions extends PostgresPermissionsStoreOptions { readonly connectionString?: string } ``` ```ts export declare function postgresCheckpointer( options?: NodePostgresStoreOptions, ): B4PostgresSaver ``` ```ts export declare function createPostgresThreadsStore( options?: NodePostgresStoreOptions, ): PostgresThreadsStore ``` ```ts export declare function createPostgresPermissionsStore( options?: NodePostgresPermissionsStoreOptions, ): PostgresPermissionsStore ``` ###### Behavior contract `postgres-storage.migration.instance-scoped` 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` 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, but 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](https://b4.run/docs/persistence), [Production Topology](https://b4.run/docs/production-topology), and [Edge and Hono](https://b4.run/docs/deployment/edge). --- ### @b4run/testing Source: https://b4.run/docs/api/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`](https://b4.run/docs/api/sdk#b4runsdktesting) instead for route-scoped scenario files run by `b4 test`. #### Install and import ```bash npm install -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. | | `AgentHarnessMiddlewareContext` | Supply the middleware context as a value or per-turn factory. | | `AgentHarnessRunInfo` | Describe the turn a middleware context factory is evaluated for. | | `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 export interface AgentHarnessOptions { readonly appRoot: string readonly route: string readonly fixtures?: FixtureSet readonly middlewareContext?: AgentHarnessMiddlewareContext readonly live?: boolean readonly record?: boolean readonly recordUpstream?: string readonly responseSchema?: Readonly> } ``` **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 middlewareContext` | `AgentHarnessMiddlewareContext` | no | Supply the context `middleware.ts` would have produced. | | `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. | | `readonly responseSchema` | `Readonly>` | no | Constrain the root model's final message as a Hashbrown client's `hashbrown.responseSchema` does. | ```ts export interface AgentHarness { readonly baseUrl: string run(opts: { input: string | readonly B4ContentPart[] fixtures?: FixtureSet | ScriptBuilder }): Promise resume(opts: { resume: readonly B4ResumeEntry[] fixtures?: FixtureSet | ScriptBuilder }): Promise reset(): void close(options?: { readonly destroyWorkspaces?: boolean }): Promise [Symbol.asyncDispose](): Promise getRecordedFixtures(): FixtureSet } ``` ```ts export declare function createAgentHarness(options: AgentHarnessOptions): Promise ``` ```ts export interface ScriptBuilder { user(text: string): ScriptBuilder callsTool(name: string, args: Record, opts?: { id?: string }): ScriptBuilder replies(content: string): ScriptBuilder build(): FixtureSet } ``` ```ts export declare function writeFixtures(path: string, fixtures: FixtureSet | ScriptBuilder): void ``` ```ts export declare function loadFixtures(path: string): FixtureSet ``` ```ts 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 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 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 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` 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 export declare function fakeEmbedder(opts?: { readonly dims?: number }): Embedder ``` The default is 64 dimensions. ###### Behavior contract `testing.harness-isolation` AgentHarness reset starts a fresh scenario and clears prior fixtures; close is idempotent and async disposal delegates to it. ##### Harness and fixture lifecycle `run({ input })` sends one user message: a string, or a list of content parts (`B4ContentPart[]` from `@b4run/sdk`) for a turn that carries an image or other media. Fixtures accumulate until `reset()`, and the first match wins. `reset()` starts a new harness thread and replaces fixtures, but 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`, and start Docker or Postgres yourself. `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](https://b4.run/docs/testing-agents), [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures), and [Scenario Testing](https://b4.run/docs/testing). --- ### @b4run/evals Source: https://b4.run/docs/api/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 score them with `runEval()`. #### Install and import ```bash npm install -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 reads JSON and JSONL datasets from disk, so it is a Node testing surface even though some scorers are 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 export interface EvalDefinition { readonly name: string readonly route?: string readonly dataset: Dataset readonly scorers: readonly Scorer[] readonly middlewareContext?: AgentHarnessMiddlewareContext readonly responseSchema?: Readonly> 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 middlewareContext` | `AgentHarnessMiddlewareContext` | no | Supply the context `middleware.ts` would have produced. | | `readonly responseSchema` | `Readonly>` | no | Constrain the root model's final message as the client's response schema does. | | `readonly threshold` | `number` | no | Shorthand for a mean gate. | | `readonly gate` | `GatePolicy` | no | Define explicit report pass policy. | ```ts export declare function defineEval(def: EvalDefinition): EvalDefinition ``` ```ts export interface EvalCase { readonly name?: string readonly input: unknown readonly expected?: unknown readonly fixtures?: FixtureSet | ScriptBuilder readonly metadata?: Record } ``` ```ts export interface Scorer { readonly name: string readonly threshold?: number readonly score: (run: AgentRunResult, testCase: EvalCase) => Score | Promise } ``` ```ts export interface RunEvalOptions { readonly runCase: (testCase: EvalCase) => Promise readonly baseDir?: string } ``` ```ts export interface EvalReport extends ScoredReport { readonly gated: boolean readonly passed: boolean readonly reason?: string } ``` ```ts export declare function runEval( def: EvalDefinition, options: RunEvalOptions, ): Promise ``` ###### Behavior contract `evals.scorer-errors.zero-score` runEval records a thrown scorer as a zero with its error reason and continues evaluating the report. ###### Behavior contract `evals.run-and-gate` 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](https://b4.run/docs/evals), [Agent Test Harness](https://b4.run/docs/testing-agents), and [Fixtures and Recording](https://b4.run/docs/testing-agents/fixtures). --- ### b4:routes Source: https://b4.run/docs/api/generated-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 B4.run generates this module for your app. There is no 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 TypeScript erases every export. #### 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` 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. Dynamic route parameters stay out of state. ###### Behavior contract `generated-routes.state-conditional` 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](https://b4.run/docs/state), [Tools](https://b4.run/docs/tools), [Routes](https://b4.run/docs/routes), and [CLI Reference](https://b4.run/docs/cli). --- ### @b4run/permissions Source: https://b4.run/docs/api/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 npm install @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, so it 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. | | `createThreadPermissionsStore` | Build one thread's store over the app's: the app's mode and denials, the thread's allow-list, grants kept in the thread's record. | | `ThreadPermissions` | Describe one thread's allow and deny lists. | | `ThreadPermissionGrants` | List and add one thread's recorded "Always" grants. | | `MAX_THREAD_GRANT_LENGTH` | Bound a thread grant's tool and pattern; a longer "Always" is allowed once and not recorded. | | `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 export type PermissionMode = "interactive" | "non-interactive" | "bypass" ``` ```ts export type PermissionDecision = "once" | "always" | "deny" ``` ```ts 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 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 contracts ###### Behavior contract `permissions.match.prefix` Non-reserved command, path, and memory candidates use prefix matching, and deny wins over allow. ###### Behavior contract `permissions.tool.exact` Reserved tool names match exactly rather than by prefix. ###### Behavior contract `permissions.subagent.exact` Reserved subagent identities match exactly rather than by prefix. ###### Behavior contract `permissions.clientTool.exact` Reserved client-tool names match exactly rather than by prefix, and a tool entry of the same name does not answer them. ###### Pattern matching limits Reserved `tool`, `subagent` and `clientTool` 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` Non-interactive mode ignores the runtime permissions file. ##### Store lifecycle and trust 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 and 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. Pass the resolved `config` and `mode` when you construct 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](https://b4.run/docs/permissions), [Access Control](https://b4.run/docs/access-control), and [Workspace API](https://b4.run/docs/api/workspace). --- ### @b4run/workspace Source: https://b4.run/docs/api/workspace #### Use this when Use this package to implement or wrap filesystem, command, and execution-sandbox backends for a B4.run application. The root has the portable backend and sandbox contracts, middleware composition, and logging wrappers. Import `/node` for local filesystem/shell implementations and immutable source capture utilities. #### Install and import ```bash npm install @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` implementations use Node child-process, filesystem, path, crypto, and utility APIs. #### Public exports ##### `@b4run/workspace` | Export | Responsibility | |---|---| | `compose` | Compose backend middleware right-to-left. | | `inspectWorkspace` | Inspect a bounded text inventory through author filesystem APIs, a sandbox handle, or a read-only workspace reader. | | `InspectWorkspaceOptions` | Set byte and entry limits, cancellation, omitted directories, and required root links. | | `WorkspaceInspection` | Return relative-path text files, validated links, byte count, and entry count. | | `WorkspaceInspectionError` | A named inspection refusal: `invalid_options`, `root_missing`, `refused`, `changed`. | | `isWorkspaceInspectionError` | Recognize an inspection refusal by name and code, including one thrown by a second copy of the package. | | `WorkspaceInspectionErrorCode` | Name why an inspection refused. | | `WorkspaceInspectionErrorDetail` | Carry the refused `root` and whether it was absent or not a directory. | | `WorkspaceReadLimitError` | A bounded read found more bytes than its limit; thrown by B4.run's backends. | | `isWorkspaceReadLimitError` | Recognize a read-limit refusal by name, including one thrown by a second copy of the package. | | `isCanonicalWorkspaceRoot` | Check an inspection root: relative leaf names with no empty, `.`, `..`, backslash or control-character segment. | | `WorkspaceDefinition` | Declare initial source, immutable environment links, and an optional Git baseline. | | `CapturedWorkspaceDefinition` | Hold a verified source bundle and canonical creation options. | | `StagedWorkspaceReference` | Name an uploaded source by digest, with the links and baseline a thread is created with (`sandbox.stagedWorkspaces`). | | `WorkspaceResolver` | Decide one thread's initial workspace from its id and client metadata, once, at first admission. | | `WorkspaceResolverInput` | What a resolver is told: the thread id, its stored client metadata with the reserved key stripped, the admitting run's abort signal, and `staged`, the verified workspace the thread was created with under `sandbox.stagedWorkspaces`. | | `ThreadSandboxResolver` | Decide one thread's workspace, image, policy and permissions from its id and client metadata, once, at first admission. | | `ThreadSandbox` | Declare one thread's workspace, an optional provider-interpreted image, optional policy overrides, and optional permission lists. | | `ThreadSandboxPolicy` | Name the network, environment and resource fields a thread may set for itself. | | `ThreadSandboxPermissions` | Describe one thread's own allow and deny lists. | | `ThreadSandboxRecord` | Hold the image reference, policy and permission lists B4.run recorded for a thread at its first admission. | | `WorkspaceEnvironment` | Bind an immutable environment identity to provider, account, and scope. | | `WorkspaceCreateIntent` | Persist an operation identity and complete creation digest before provider mutation. | | `WorkspaceReference` | Identify provider resources without credentials or executable configuration. | | `WorkspaceProvenance` | Record trusted source, environment, baseline, and retention information. | | `ReadyWorkspace` | Pair a durable provider reference with verified provenance. | | `WorkspaceSessionReference` | Name one compute incarnation of a durable workspace. | | `WorkspaceSession` | Expose filesystem and command backends for one admitted incarnation. | | `CreationStatus` | Distinguish absent, pending, ready, failed, and unknown creation outcomes. | | `WorkspaceDeletionTarget` | Clean up a persisted creation operation, including one with no ready reference. | | `ManagedWorkspaceProvider` | Own physical workspace creation, inspection, reconnection, release, destruction, optional reads, and optional per-thread images. | | `WorkspaceLifecycleErrorCode` | Classify lost, expired, conflicting, unsupported, retryable, and uncertain operations. | | `WorkspaceLifecycleError` | Report a typed workspace lifecycle failure. | | `SourceBundle` | Describe immutable canonical source content and its digest. | | `SourceFileInput` | Supply exact file bytes and executable metadata. | | `WorkspaceSourceDefinition` | Declare the source directory, exact inventory, exclusions, and additional files. | | `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, including `workspaceRead: "http"`, which serves a thread's workspace over the Agent Protocol, and `stagedWorkspaces`, which accepts one at thread creation. | | `SandboxHandle` | Describe one acquired sandbox. | | `SandboxPolicy` | Describe per-thread network, environment, resource, and security policy. | | `SandboxProvider` | Define sandbox acquisition, release, destruction, and optional workspace reads. | | `SandboxSecurityPolicy` | Describe provider-agnostic hardening intent. | | `OpenWorkspaceReaderInput` | Name the thread to read, its cancellation signal, and the identity the reader runs as. | | `OpenManagedWorkspaceReaderInput` | Name the published managed workspace to read, its cancellation signal, and the identity the reader runs as. | | `SandboxWorkspaceReader` | Read one thread's workspace storage, with no write and no command surface. | | `ReadOnlyFilesystemBackend` | Define the read half of a filesystem, with inspection metadata required. | | `WorkspaceReadSource` | Describe the minimum a workspace inspector consumes. | | `withWorkspaceReader` | Open a thread's workspace reader, hand it to an operation, and always close it. | | `scopedWorkspaceReader` | Run an operation over any opened workspace reader and always close it. | | `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. | | `WalkedEntry` | Describe one entry of a batched tree walk. | | `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 owns the exports below. Its types are also exported from the dependency-free root. Its runtime functions are only in `/node`. | 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. | | `SourceBundle` | Describe immutable canonical source content and its digest. | | `SourceFileInput` | Supply exact file bytes and executable metadata. | | `WorkspaceSourceDefinition` | Declare source files to capture from an application tree. | | `createSourceBundle` | Construct a canonical bundle from explicit binary file inputs. | | `verifySourceBundle` | Validate bundle shape, encoding, paths, limits, and digest. | | `readSourceFile` | Read initial bytes into independently owned memory. | | `captureWorkspaceSource` | Capture the declared application source into a verified bundle. | | `captureWorkspaceDefinition` | Capture declared source and validate creation options. | | `verifyCapturedWorkspaceDefinition` | Validate and freeze a captured definition. | | `verifyStagedWorkspaceReference` | Validate and normalize a staged workspace reference: a bare digest, checked and sorted links, and only a `"git"` baseline. | | `stagedWorkspaceDefinition` | Build the captured definition a staged reference names over the held source, verifying it once and refusing a source that is not the one named. | | `stagedWorkspaceFits` | Check a staged reference's links and baseline against a source's recorded file paths, without its bytes. | | `createWorkspaceIntent` | Create a canonical immutable intent bound to operation and environment. | | `verifyWorkspaceIntent` | Validate the persisted intent and digest. | | `verifyReadyWorkspace` | Verify provider readiness against its persisted intent. | | `verifyCreationStatus` | Validate every provider inspection outcome before acting on it. | | `verifyImageReference` | Validate an image reference a thread names; flag-like references are refused. | | `verifyThreadSandbox` | Validate what a thread-sandbox resolver returned, refusing unknown keys by name. | | `verifyThreadSandboxPolicy` | Validate and normalize a thread's policy overrides. | | `verifyThreadSandboxPermissions` | Validate and normalize a thread's permission lists; empty patterns are refused. | | `verifyThreadSandboxRecord` | Validate and normalize a stored thread-sandbox record. | | `MAX_THREAD_SANDBOX_RECORD_BYTES` | Bound a thread-sandbox record's stored size; a larger record is refused at first admission. | | `threadSandboxRecordBytes` | Measure a verified thread-sandbox record as it is stored. | #### Key contracts ##### Bounded text inspection `inspectWorkspace(source, options?)` accepts a `WorkspaceFs` or `SandboxHandle`. It returns `{ files, symlinks, totalBytes, entries }`. File keys are relative paths. Metadata and binary reads are required. Unsupported entries, executable files, NUL bytes, invalid UTF-8, and exceeded limits fail inspection. UTF-8 BOMs remain part of the returned text. Defaults are 10,000 entries, 2 MiB per file, and 16 MiB total. Override these with `maxEntries`, `maxFileBytes`, and `maxTotalBytes`. `excludeRootDirectories` omits only directory subtrees. A file or link at an excluded name fails. Every entry in `expectedRootSymlinks` must exist with its exact raw link target. Other symlinks are rejected. `signal` cancels inspection. `root` (a sandbox handle or workspace reader only) starts the inspection at a relative directory: nothing outside it is read, keys are relative to it, and every segment must be a real directory, so a missing root or one that passes through a file or a symlink is a `root_missing` refusal naming it. Refusals are `WorkspaceInspectionError`s with a `code`; a backend's own failure is rethrown as it was thrown. ```ts import { inspectWorkspace } from "@b4run/workspace" const inspection = await inspectWorkspace(ctx.fs, { signal: ctx.signal, maxTotalBytes: 2 * 1024 * 1024, excludeRootDirectories: [".git"], }) ``` When a backend implements both `walkTree` and `readBinaryFiles`, inspection walks the tree in one call and reads the files in as few calls as the backend allows. The Docker and Kubernetes backends implement both, so inspecting a container workspace costs a few execs instead of one per entry. The checks and limits are the same either way. A batched inspection also refuses a file that grew between the walk and the read. Inspection uses separate metadata and read operations, so it is not an atomic snapshot. Quiesce writers or revalidate before acting on an inventory. Expected links establish link identity, not the contents of their targets. Custom backends must honor byte limits during reads to bound allocation. The built-in local, Docker, and Kubernetes readers cap content before collecting it. ```ts 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 export interface FilesystemBackend { lstat?(path: string, ctx: BackendContext): Promise<{ readonly kind: "file" | "directory" | "symlink" | "other"; readonly size: number; readonly executable: boolean; readonly target?: string }> 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 walkTree?( path: string, ctx: BackendContext, opts: { readonly maxEntries: number; readonly prune?: readonly string[] }, ): Promise readBinaryFiles?( requests: readonly { readonly path: string; readonly maxBytes: number }[], ctx: BackendContext, ): Promise } ``` `walkTree` returns every entry below `path` with the metadata `lstat` reports, without following symlinks. Names in `prune` are reported but not descended, and a walk over `maxEntries` throws instead of returning a partial tree. `readBinaryFiles` returns results in request order, with `readBinaryFile`'s `maxBytes` meaning for each request. Both are optional. ```ts export interface WalkedEntry { readonly path: string readonly kind: "file" | "directory" | "symlink" | "other" readonly size: number readonly executable: boolean readonly target?: string } ``` **Fields: `@b4run/workspace#.:WalkedEntry`** | Field | Type | Required | Description | |---|---|---|---| | `readonly path` | `string` | yes | Name the entry by its `/`-separated path relative to the walked directory. | | `readonly kind` | `"file" \| "directory" \| "symlink" \| "other"` | yes | Report the entry's type without following a symlink. | | `readonly size` | `number` | yes | Report the size in bytes, as `lstat` would. | | `readonly executable` | `boolean` | yes | Report whether any execute bit is set. | | `readonly target` | `string` | no | Carry a symlink's raw link text. | ```ts 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 export declare function compose( ...middlewares: ReadonlyArray<(next: T) => T> ): (base: T) => T ``` ###### Behavior contract `workspace.compose.order` Backend middleware composes right-to-left, with the first listed middleware outermost. ###### Sandbox contracts ```ts 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. The reference Docker and Kubernetes providers do not enforce an allow-mode `denylist`. | | `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 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 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 export interface SandboxProvider { readonly workspaces?: ManagedWorkspaceProvider 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[] }> openWorkspaceReader?(input: OpenWorkspaceReaderInput): Promise } ``` **Fields: `@b4run/workspace#.:SandboxProvider`** | Field | Type | Required | Description | |---|---|---|---| | `readonly workspaces` | `ManagedWorkspaceProvider` | no | Supply provider-owned durable workspace operations. | | `readonly name` | `string` | yes | Identify the provider. | `openWorkspaceReader` is an optional capability. Presence is the probe: a provider whose storage cannot be attached a second time omits it rather than implementing it unreliably. An implementation must leave the thread's sandbox undisturbed, make writes impossible rather than merely undocumented, and reject rather than return an empty view when the thread has no workspace storage. It is not an authorization boundary. The caller already holds the provider. Naming a thread id is not a claim of ownership. ```ts export interface SandboxWorkspaceReader extends WorkspaceReadSource { readonly threadId: string readonly filesystem: ReadOnlyFilesystemBackend close(): Promise } ``` **Fields: `@b4run/workspace#.:SandboxWorkspaceReader`** | Field | Type | Required | Description | |---|---|---|---| | `readonly threadId` | `string` | yes | Identify the thread whose workspace is being read. | | `readonly filesystem` | `ReadOnlyFilesystemBackend` | yes | Read files, directories, and leaf metadata. | `close()` releases reader-side resources and is idempotent. It never touches the thread's sandbox. `workspaceRoot` comes from `WorkspaceReadSource` and names the absolute root inside the sandbox. ```ts export interface OpenWorkspaceReaderInput { readonly threadId: string readonly signal: AbortSignal readonly runAsNonRoot?: SandboxSecurityPolicy["runAsNonRoot"] } ``` **Fields: `@b4run/workspace#.:OpenWorkspaceReaderInput`** | Field | Type | Required | Description | |---|---|---|---| | `readonly threadId` | `string` | yes | Name the thread whose workspace storage to open. | | `readonly signal` | `AbortSignal` | yes | Cancel the open. | | `readonly runAsNonRoot` | `SandboxSecurityPolicy["runAsNonRoot"]` | no | Match the identity that owns the produced files; defaults to the provider's secure default. | ```ts export declare function withWorkspaceReader( provider: SandboxProvider, input: OpenWorkspaceReaderInput, operation: (reader: SandboxWorkspaceReader) => Promise, ): Promise ``` `withWorkspaceReader` closes the reader on both paths. A close failure surfaces on its own, and alongside a body failure as an `AggregateError` rather than replacing it. A provider without the capability throws instead of returning nothing. ```ts export declare function scopedWorkspaceReader( open: () => Promise, operation: (reader: SandboxWorkspaceReader) => Promise, ): Promise ``` `scopedWorkspaceReader` is the lifetime rule behind `withWorkspaceReader` for any way of opening a reader: open lazily, run, always close, and never let a close failure hide a body failure. `withWorkspaceReader` and `@b4run/cli/workspace`'s `withManagedWorkspaceReader` both delegate to it. ###### Managed workspace reads A workspace created through `sandbox.workspace` is a managed workspace. Its storage is named by the creating installation and operation, not by the thread id, so `SandboxProvider.openWorkspaceReader` cannot address it. `ManagedWorkspaceProvider.openWorkspaceReader`, optional under the same contract, is addressed by the published `ReadyWorkspace` instead. ```ts export interface OpenManagedWorkspaceReaderInput { readonly workspace: ReadyWorkspace readonly signal: AbortSignal readonly runAsNonRoot?: SandboxSecurityPolicy["runAsNonRoot"] } ``` **Fields: `@b4run/workspace#.:OpenManagedWorkspaceReaderInput`** | Field | Type | Required | Description | |---|---|---|---| | `readonly workspace` | `ReadyWorkspace` | yes | Name the published workspace whose storage to open; the provider verifies it against its own record. | | `readonly signal` | `AbortSignal` | yes | Cancel the open. | | `readonly runAsNonRoot` | `SandboxSecurityPolicy["runAsNonRoot"]` | no | Match the identity that owns the produced files; defaults to the provider's secure default. | The Docker provider verifies the record, binding and volume exactly as `reconnect` does, then binds the managed volume's backing directory read-only in a separate networkless container. It never lists, starts, stops or replaces a session container. A lost record or volume is a `lost` error, never a recreated volume. A co-located host process obtains the `ReadyWorkspace` through `@b4run/sqlite-storage`'s `openWorkspaceInstallationReader`. `@b4run/cli/workspace`'s `withManagedWorkspaceReader` joins the two by thread id. ```ts export interface SandboxConfig { readonly workspace?: WorkspaceDefinition | WorkspaceResolver readonly thread?: ThreadSandboxResolver readonly workspaceRead?: "http" readonly workspaceReadTimeoutMs?: number readonly stagedWorkspaces?: | boolean | { readonly maxUploadBytes?: number readonly retentionMs?: number readonly maxStagedBytes?: number readonly uploadTimeoutMs?: number } 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 workspace` | `WorkspaceDefinition \| WorkspaceResolver` | no | Declare the initial managed workspace: one definition for every thread, or a resolver that decides per thread at first admission. | | `readonly thread` | `ThreadSandboxResolver` | no | Decide each thread's workspace, image, policy and permissions once, at first admission; exclusive with `workspace`. | | `readonly workspaceRead` | `"http"` | no | Serve `POST /threads/:thread_id/workspace/inspect`, a read-only inventory of a thread's managed workspace behind the thread-access policy; refused without a policy. | | `readonly workspaceReadTimeoutMs` | `number` | no | Bound one workspace read, open to close, before it is abandoned with `504 workspace_read_timeout`; 1,000 to 1,800,000 ms, default 120,000. | | `readonly stagedWorkspaces` | `\| boolean \| { readonly maxUploadBytes?: number; readonly retentionMs?: number; readonly maxStagedBytes?: number; readonly uploadTimeoutMs?: number }` | no | Accept a thread's workspace at creation (`PUT /workspace/sources/:digest`, then `workspace` on `POST /threads`), handed to the resolver as `thread.staged`; needs a resolver and a thread-access policy. | | `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 export interface LocalExecOptions { readonly timeout?: number readonly allowedCommands?: readonly RegExp[] } ``` ```ts export declare function localExec(opts?: LocalExecOptions): ExecBackend ``` ###### Behavior contract `workspace.exec.timeout` The local exec backend enforces its configured timeout. ```ts export interface LocalFilesystemOptions { readonly maxFileBytes?: number } ``` ```ts export declare function localFilesystem(opts?: LocalFilesystemOptions): FilesystemBackend ``` ###### Behavior contract `workspace.filesystem.symlink` `localFilesystem.realPath` resolves an escaping symlink to its outside real path; Core owns any path-jail enforcement. ##### Immutable source bundles ```ts export interface SourceFileInput { readonly path: string readonly bytes: Uint8Array readonly executable: boolean } ``` ```ts export interface SourceBundle { readonly version: 1 readonly digest: string readonly files: readonly { readonly path: string readonly base64: string readonly executable: boolean }[] } ``` ```ts export interface WorkspaceSourceDefinition { readonly directory: string readonly include: readonly string[] readonly excludeDirectories?: readonly string[] readonly files?: readonly ( | { readonly path: string; readonly file: string } | { readonly path: string; readonly text: string; readonly executable?: boolean } )[] } ``` ```ts export declare function createSourceBundle(files: readonly SourceFileInput[]): SourceBundle ``` ```ts export declare function verifySourceBundle(value: unknown): SourceBundle ``` ```ts export declare function readSourceFile(bundle: SourceBundle, path: string): Uint8Array ``` ```ts export declare function captureWorkspaceSource( appRoot: string, definition: WorkspaceSourceDefinition, options?: { readonly signal?: AbortSignal }, ): Promise ``` `createSourceBundle` copies explicit bytes into sorted, frozen file entries and computes a versioned SHA256 identity covering paths, content, and executable bits. `verifySourceBundle` rejects malformed or noncanonical entries and digest mismatch; it returns a new frozen bundle. `readSourceFile` verifies the bundle, rejects a missing path, and returns an independently owned byte array. `captureWorkspaceSource` resolves directory and referenced file paths relative to `appRoot`. `directory: "."` selects that root. `include` is an exact inventory of regular files relative to the source directory. Missing or extra files fail. `excludeDirectories` names existing directory subtrees to skip, without following symlinks. Additional `files` entries declare destination paths with either a file reference relative to `appRoot`, or inline text and an optional executable flag. Text preserves BOMs and rejects malformed Unicode. Referenced files preserve their executable flags. Duplicate destinations, conflicting ancestors, and inconsistent directory casing are rejected. Bundles allow at most 10,000 files, 16 MiB per file, and 64 MiB total content. Capture also limits distinct inspected filesystem paths to 10,000, including the app root, ancestor directories, exclusions, and extra references. Paths use an ASCII portability subset, at most 1,024 bytes per path and 255 bytes per segment. Traversal, absolute paths, backslashes, device names, leading/trailing spaces, consecutive spaces, trailing dots, and case-insensitive collisions fail. Leading dotfiles such as `.gitignore` are supported. Capture rejects symlinks and special files, checks size before allocating file buffers, and detects changes through file metadata and inventory rechecks. It reads a trusted application tree: these checks are not an atomic snapshot or a defense against a hostile host process swapping ancestor directories. Cancellation closes open handles and returns no completed bundle. These utilities do not create sandboxes or activate managed workspace recovery in runtime configuration. ##### Lifecycle, failures, and trust 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()` have distinct lifecycle roles. Each provider defines the exact persistence and cleanup behavior. 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. ##### Managed workspace lifecycle Declare `sandbox.workspace` with an exact source inventory. `b4 build` captures source bytes for the Node target. Startup verifies the artifact and retains it in the installation store. Existing threads retain their original source and provenance across deployment changes. Providers without `workspaces` support reject the configuration. The first qualified provider is Docker. Managed workspace builds require `build: { targets: ["node"] }` and the existing app-root `workspace/` capability marker. `sandbox.workspace` may instead be a `WorkspaceResolver`: host code called once per thread, at the thread's first admission, with the thread id and its stored client metadata. B4.run captures what it returns (or verifies it, if already captured) and records it by digest in that thread's creation intent. It's never re-resolved. Later turns, restarts and readers all use the record. `b4 build` can't capture a resolver, so the artifact only records that one is configured, and startup refuses an artifact whose form disagrees with the loaded config. Deployed host code decides the result, never the client or the model. A resolver treats the metadata it is given as input to validate, not as authority. The resolver sees the thread's stored metadata with B4.run's reserved key stripped. On the server run endpoints (`runs/stream`, `runs/wait`, AG-UI), `route` is present and server-authoritative, because the runtime stamps it before admission and the store's merge overwrites any client value. Under `b4 run` it is absent. Every other key is client-writable, so never use metadata as an authorization input. `POST /threads` always assigns the id, so to attach metadata before the first resolution, create the thread and use the id it returns. `b4 run` creates a fresh thread per invocation, so the resolver runs on every run with empty metadata. Provider creation receives an immutable `SourceBundle`, bounded to 64 MiB of original content, and a persisted `WorkspaceCreateIntent`. Readiness must match that intent before tools receive a session. An unknown outcome cannot authorize a new operation. Release targets a compute incarnation. Destroy targets the original operation and may run before readiness exists. Missing established storage is an explicit failure, never an instruction to reseed. Environment links point to directories in the pinned immutable environment. They cannot overlap source files or generated `.git` metadata. Docker requires a Node-capable preparation image, keeps those targets read-only, and supports UID/GID 1000 for managed non-root execution. Git baselines use fixed identity and time with ambient configuration and hooks disabled. The stored baseline commit is provenance. Agent-writable `.git` contents are not verification truth. `sandbox.thread` goes one step further: a `ThreadSandboxResolver` returns the thread's workspace, an optional image and optional policy overrides, once, at first admission. Every thread admitted this way has a record, and a thread-mode admission of a thread without one is refused as a conflict. The provider's `resolveImageEnvironment` turns the image into the identity recorded in the creation intent, and B4.run writes the image reference and the policy to a per-thread record in the same transaction as the association. Every reconnect runs the thread's recorded policy over the app's; nothing is re-resolved. A provider without `resolveImageEnvironment` refuses a thread that names an image. When the resolver returns `permissions`, they go in the same record, and the thread's permission gates use them in place of the app's allow-list; its "Always" grants are kept beside the record and deleted with it (see [Per-thread permissions](https://b4.run/docs/permissions#per-thread-permissions)). #### 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](https://b4.run/docs/workspace), [Execution Sandbox](https://b4.run/docs/sandbox), and [Permissions API](https://b4.run/docs/api/permissions). --- ### @b4run/sandbox Source: https://b4.run/docs/api/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](https://b4.run/docs/api/workspace). #### Install and import ```bash npm install @b4run/sandbox npm install -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, including the optional workspace read capability. | | `DockerSandboxOptions` | Configure the default Docker image, the images a per-thread sandbox may name, and the 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 export interface KubernetesSandboxOptions { readonly scope: string readonly image: string readonly namespace?: string readonly storageClass?: string readonly startupTimeoutMs?: number readonly client?: KubeClient } ``` ```ts 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 export declare function dockerSandbox(opts: DockerSandboxOptions): SandboxProvider ``` ```ts export declare function kubernetesSandbox(opts: KubernetesSandboxOptions): SandboxProvider ``` ```ts export declare function runProviderConformance(opts: { readonly name: string readonly makeProvider: () => SandboxProvider readonly describe: (name: string, fn: () => void) => void readonly workspaceReads?: boolean }): void ``` `workspaceReads` declares the optional `openWorkspaceReader` capability. Setting it registers the workspace-read contract. Omitting it asserts the provider does not implement it. Either way, the kit registers a test that checks the declaration against the provider. No test is skipped, because a skipped test would make an unchecked contract look like a passing one. ##### Behavior contracts ###### Behavior contract `sandbox.docker.release` Docker release removes the container but retains its volume; destroy removes both. ###### Behavior contract `sandbox.kubernetes.release` 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` 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` A failed sandbox container creation is tagged `B4_E2001`. ###### Workspace read capability `dockerSandbox` implements the optional `openWorkspaceReader` member of `SandboxProvider`. It attaches the thread's existing workspace into a separate, ephemeral, networkless container as a read-only bind of the volume's own backing directory, and reads through that container, so the thread's keeper is never inspected, started, stopped, or replaced. Writes fail at the kernel. It reads a thread whose compute was already released, and rejects with `B4_E2001` when the thread has no workspace storage. The reader runs as uid/gid `1000:1000` unless the input says otherwise. The bind is deliberate: a `-v :/workspace:ro` mount would **create** the volume if it were missing, so a reader racing a thread delete would resurrect that thread's workspace as an empty volume and the next `acquire()` would reattach it. A bind refuses a missing source instead. The cost is that reading needs a volume with a host-visible backing path, which is what the default local driver provides. A volume that reports none is rejected rather than read. `kubernetesSandbox` omits the member: a `ReadWriteOnce` PersistentVolumeClaim cannot be mounted by a second Pod unless it lands on the same node. `fakeSandbox` implements it in memory, so `runProviderConformance` exercises the capability block in ordinary CI. ###### Provider identity and reattachment caveat Both provider factories require a nonblank `scope: string`. Resource names use a stable hash of the scope and thread ID, and handles keep the logical thread ID. Scope is case-sensitive, isn't authorization, and adds no cross-process coordination. Changing scope selects different storage. That's a breaking change from unscoped providers, because legacy storage is not automatically reattached, migrated, or deleted. A running Kubernetes Pod is reattached without applying a changed image, environment, resources, or security, and a later allow acquire leaves a prior deny policy in place. Release before you change provider options or policy. #### Examples and related guides ```ts import { dockerSandbox } from "@b4run/sandbox" const provider = dockerSandbox({ scope: "my-app", 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](https://b4.run/docs/sandbox), [Kubernetes Sandbox](https://b4.run/docs/sandbox/kubernetes), and [Workspace API](https://b4.run/docs/api/workspace). --- ### @b4run/langgraph Source: https://b4.run/docs/api/langgraph #### Use this when Use this 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. Route authors normally use the [SDK route contracts](https://b4.run/docs/api/sdk) and let the CLI choose these adapters. #### Install and import ```bash npm install @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. That covers importing and running these adapters. It says nothing about a graph, workflow, or tool your application supplies. #### 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](https://b4.run/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](https://b4.run/docs/api/sdk). | | `RuntimeTool` | Re-export the SDK-owned callable tool contract. | `ToolRegistry` lives in a private implementation module and is not exported. ##### `@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. #### Key contracts ```ts export declare function defineEntry>( module: TModule, ): TModule ``` ```ts 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 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 export type RouteModule = GraphRouteModule | WorkflowRouteModule ``` ```ts 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 export declare function normalizeRouteModule( module: RouteModule | (GraphRouteModule & WorkflowRouteModule), ): NormalizedRouteModule ``` ```ts export declare function assertExactlyOneEntry( module: RouteModule | (GraphRouteModule & WorkflowRouteModule), ): asserts module is RouteModule ``` ##### Behavior contracts ###### Behavior contract `langgraph.entry.exclusive` 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` The route-module subpath exposes only its published normalization and route-module contracts. ###### Adapter lifecycle and failures `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, with no native multi-chunk graph streaming. The `runtime`, `streaming`, and `tags` route-config fields are reserved, and these adapters ignore them. #### 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](https://b4.run/docs/routes), [SDK API](https://b4.run/docs/api/sdk), and [CLI API](https://b4.run/docs/api/cli). --- ### @b4run/langchain Source: https://b4.run/docs/api/langchain #### Use this when Use this 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()`](https://b4.run/docs/api/sdk#agent-and-agentconfig) and lets the B4.run runtime call this layer. #### Install and import ```bash npm install @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. The evidence is 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. The root is not dependency-free and makes no general browser promise: provider integrations, application tools, stores, and dynamically selected imports keep 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, optionally bound to a JSON-schema response format. | | `JsonSchemaResponseFormat` | Describe a JSON schema the root model's final message must match. | | `JSON_SCHEMA_RESPONSE_FORMAT_PROVIDERS` | List the providers whose schema-constrained output coexists with tool calls. | | `supportsJsonSchemaResponseFormat` | Report whether a provider can be bound to a JSON-schema response format. | | `unsupportedResponseFormatMessage` | Produce the rejection message for a provider that cannot. | | `ModalitySupport` | Describe which content parts a route's model takes, by modality and source. | | `DEFAULT_MODALITY_SUPPORT` | Supply the support assumed for a model with no profile: images inline and by URL. | | `resolveModalitySupport` | Read a model's LangChain profile and apply the provider's converter limits. | | `readModelProfile` | Read a model's LangChain profile off its provider class without constructing it; `undefined` when the provider package is not installed. | | `ConvertedContent` | Describe converted message content and the parts dropped from it. | | `LangChainContentBlock` | Describe one LangChain standard content block. | | `DroppedPart` | Describe one dropped part: its index, type, source and reason. | | `DropReason` | Name why a part was dropped. | | `DroppedPartsReport` | Describe the parts dropped from one message, with the model and route they were judged against. | | `toLangChainContent` | Convert content parts to LangChain content blocks, dropping what the model cannot take. | | `formatDroppedPartsWarning` | Format the server-log warning that announces dropped parts. | | `pickDroppedPartsReport` | Pick the report fields off a `content_parts_dropped` or `subagent.content_parts_dropped` chunk's data. | | `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. | | `modelMaxRetries` | Map an agent's `retry` to a chat model's `maxRetries` (`maxAttempts - 1`, default 2). | | `resolveReasoningConfig` | Check an agent's `reasoning` against its resolved provider and map it to the chat model's constructor options, reporting whether reasoning text will stream. | | `ResolvedReasoningConfig` | Describe the resolved reasoning controls: constructor options and the `streams` flag `GET /agui/:routeId` advertises. | | `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. | | `ToolResultModality` | Describe the root model's modality support, used to shape a tool's media result. | | `B4_CONTENT_PARTS_KEY` | Name the `ToolMessage` `additional_kwargs` key that keeps every part a tool returned, for the UI. | | `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. It is for tests only: it mutates process-wide cache state and is not an application lifecycle API. Helpers that implementation modules export but the root barrel omits (such as `composePromptMessages`, `AgentOptions`, `ResolvedStateField`, `ExecuteWithToolLoopOptions`, `BuildStubArgs`, `jsonSchemaToZod`, warning/message helpers, and default importers) are not owned exports. ##### `@b4run/langchain/package.json` This subpath is package metadata as JSON for tooling. It has no TypeScript exports, runtime classification, or purity claim. Don't execute it as application code. #### Key contracts ```ts export interface AgentStreamChunk { readonly type: | "token" | "tool_call" | "tool_call_args" | "tool_result" | "interrupt" | "done" | (string & {}) readonly data: unknown readonly messageId?: string } ``` **Fields: `@b4run/langchain#.:AgentStreamChunk`** | Field | Type | Required | Description | |---|---|---|---| | `readonly type` | `\| "token" \| "tool_call" \| "tool_call_args" \| "tool_result" \| "interrupt" \| "done" \| (string & {})` | yes | Identify the chunk kind. | | `readonly data` | `unknown` | yes | Carry the chunk payload. | | `readonly messageId` | `string` | no | Identify the source model invocation for text tokens within this stream. | Text token data remains a string. Non-subagent model text carries `messageId` from its model invocation. A `message_end` chunk with `{ messageId }` follows that model's text before any same-model tool announcements. Models that emit no text produce no completion chunk. This preserves independent text framing for concurrent nested models. Invocation identity does not deduplicate replays. ```ts 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 export declare function resolveProvider(options: { readonly model: string readonly provider?: ModelProviderId }): BuiltInModelProviderId ``` ```ts export declare function withRetry( fn: () => Promise, options?: RetryOptions, ): Promise ``` ```ts 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 export interface UnwrappedToolResult { readonly content: string | readonly B4ContentPart[] readonly stateUpdates: Record | undefined } ``` **Fields: `@b4run/langchain#.:UnwrappedToolResult`** | Field | Type | Required | Description | |---|---|---|---| | `readonly content` | `string \| readonly B4ContentPart[]` | yes | Carry agent-visible content: text, or the content parts the tool returned. | | `readonly stateUpdates` | `Record \| undefined` | yes | Carry optional channel updates. | Several callable exports use non-barrel helper shapes in their declarations. These shapes are listed here for reading. You can't import them: ```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, signal, and optional `maxRetries` for the model it builds. 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 contracts ###### Behavior contract `langchain.provider.explicit` An explicit model provider bypasses provider inference. ###### Provider lifecycle and failures 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. `createChatModel({ responseFormat })` and `streamAgent({ responseFormat })` bind a `JsonSchemaResponseFormat` on the root model as the provider's native schema-constrained output (OpenAI `response_format` with `json_schema` and `strict: true`, or Anthropic `output_config.format`). The route's tools stay bound, so tool-calling turns are unaffected and only the final message is constrained. Only the providers in `JSON_SCHEMA_RESPONSE_FORMAT_PROVIDERS` qualify. Any other provider throws `unsupportedResponseFormatMessage(provider)` before its package is imported, so it never runs unconstrained. A format-bound agent always compiles a fresh graph and never touches the materialized-agent cache. The AG-UI endpoint sources it from the client's `hashbrown.responseSchema`. See [AG-UI](https://b4.run/docs/ag-ui#response-schema). ###### Behavior contract `langchain.retry.exhaustion` Retry throws after the configured maximum attempts are exhausted. ###### Retry failures 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` The tool loop limits iterations to prevent an infinite loop. ###### Tool-loop failures 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` A chain stream falls back to invoke when the entry has no stream method. ###### Chain, offload, and summarization limits 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. If the summarizer fails, that turn falls back to full history. It compresses context and isn't a security control. #### 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](https://b4.run/docs/agents), [Context Management](https://b4.run/docs/context-management), [Retry](https://b4.run/docs/retry), and [SDK API](https://b4.run/docs/api/sdk). --- ### @b4run/sqlite-storage Source: https://b4.run/docs/api/sqlite-storage #### Use this when Use this Node-only package for local, single-process checkpoint and Agent Protocol thread persistence. It is not encrypted and not shared across replicas. #### Install and import ```bash npm install @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. | | `InterruptGrantRecord` | Describe a stored approval-grant row. | | `InterruptGrantStore` | Define the approval-grant store contract. | | `InterruptGrantConsumption` | Describe a conditional consume's outcome. | | `InterruptGrantStoreOptions` | Select the approval-grant database path. | | `createInterruptGrantStore` | Open, migrate, and return an approval-grant store. | | `ClientToolCallRecord` | Describe a stored client tool call row. | | `ClientToolCallAnswer` | Describe an answer attempt's outcome. | | `ClientToolCallStore` | Define the client tool call store contract. | | `ClientToolCallStoreOptions` | Select the client tool call database path. | | `createClientToolCallStore` | Open, migrate, and return a client tool call store. | | `openWorkspaceInstallation` | Own a local installation's durable source and association storage. | | `WorkspaceInstallation` | Hold installation identity, guarded stores, per-thread sandbox records, staged workspace sources, and close. | | `openWorkspaceInstallationReader` | Open a read-only view of an installation another process owns, without its admission lock. | | `WorkspaceInstallationReader` | Hold installation identity, read-only association lookups, and close. | | `WorkspaceSourceStore` | Persist and retrieve verified source bundles. | | `WorkspaceStagedSourceStore` | Keep uploaded sources with their file paths and uploaders, and each thread's staged reference, and reclaim sources nothing references. | | `WorkspaceStagedSourceError` | Refuse a staging request by code: `not_held`, `already_staged`, `quota_exceeded`, or `uploader_invalid`. | | `WorkspaceAssociation` | Describe creating, ready, deleting, or deleted state with its revision. | | `WorkspaceAssociationStore` | Insert source-backed intents, with an optional thread sandbox record in the same transaction, and transition associations by revision. | | `WorkspaceThreadSandboxStore` | Read the sandbox record a thread's first admission wrote, and keep that thread's "Always" grants. | #### Key contracts ```ts export interface SqliteCheckpointerOptions { readonly path: string } ``` ```ts export declare function sqliteCheckpointer(options: SqliteCheckpointerOptions): B4SqliteSaver ``` ```ts export type ThreadStatus = "idle" | "busy" | "interrupted" ``` ```ts export interface Thread { readonly thread_id: string readonly created_at: string readonly updated_at: string readonly metadata: Record readonly status: ThreadStatus } ``` ```ts export interface CreateThreadInput { readonly thread_id?: string readonly metadata?: Record } ``` ```ts 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 export interface ThreadsStoreOptions { readonly path: string } ``` ```ts export declare function createThreadsStore( options: ThreadsStoreOptions, ): import("./store.js").ThreadsStore ``` ##### Behavior contracts ###### Behavior contract `sqlite.checkpointer.persistence` A file-backed SQLite checkpoint persists across saver instances. ###### Behavior contract `sqlite.threads.order` `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` 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` 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 has no cleanup hook, so treat these as process-lifetime stores. 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 to get its pending writes. Thread status and metadata updates for a missing ID are no-ops. Metadata updates shallow-merge. ##### Workspace installations `openWorkspaceInstallation(appRoot)` owns `.b4/workspaces/state.sqlite` and a separate admission database. Only one active owner on one local host may manage an installation. Separate state transactions permit concurrent threads. Close the owner after execution and provider cleanup settle. Interrupted initialization can resume. Missing established state or mismatched identity fails closed. Network filesystems, multiple hosts, and whole-state-directory replacement are unsupported. The runtime normally owns this connection for the application. Associations require retained source before insertion. Readiness is validated against the intent and uses an expected revision. Deleting state prevents new admission and stale readiness publication. Deleted rows remain tombstones. `openWorkspaceInstallationReader(appRoot)` opens the same two databases read-only, takes no admission transaction, and so coexists with the live owner. It validates what the owner validates and requires a ready installation. It never creates the directory or either database, so a missing installation is an error rather than an empty view. It exposes `associations.get` and `associations.list` only. A co-located host process uses it to resolve a thread's published `ReadyWorkspace` for `@b4run/cli/workspace`'s `withManagedWorkspaceReader`. `WorkspaceInstallation.staged` serves `sandbox.stagedWorkspaces`. An upload is kept in the same content store, keyed by its own digest, with its file paths, size and uploaders; a re-upload rewrites nothing and refreshes its upload time. Only an uploaded source is stageable: a source an admission stored is not, even though its bytes are in the same table. A thread's staged reference is recorded in the same database, refused for a source that was not uploaded. `reclaim` deletes every source no association, staged thread or caller reference names, uploads only once older than the retention window, in one transaction. The read-only reader neither needs nor creates these tables. Cleanup errors retain deletion state so a later attempt can finish it. #### 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](https://b4.run/docs/persistence) and [CLI API](https://b4.run/docs/api/cli). --- ### Error Codes Source: https://b4.run/docs/errors Every user-facing B4.run failure carries a stable `B4_Exxxx` code. You'll see the code wherever the failure shows up (CLI stderr, HTTP/SSE error bodies and permission-denial tool results), so you can search for it and link to it. Codes are grouped by range: `E1xxx` config / `b4 check`, `E2xxx` sandbox, `E3xxx` permissions, `E4xxx` model / provider, and `E5xxx` runtime / import. A code without a docs link is still a stable identifier you can search for. | Code | Meaning | Docs | | --- | --- | --- | | `B4_E1001` | Invalid tool scope | [/docs/tools#scoping-a-routes-tools](https://b4.run/docs/tools#scoping-a-routes-tools) | | `B4_E1002` | Invalid sandbox config | [/docs/configuration#sandbox](https://b4.run/docs/configuration#sandbox) | | `B4_E1003` | Unknown build target | [/docs/deployment](https://b4.run/docs/deployment) | | `B4_E1004` | Invalid delegation policy | [/docs/subagents#delegation-policy](https://b4.run/docs/subagents#delegation-policy) | | `B4_E1005` | Feature unsupported by the build target or runtime | [/docs/deployment](https://b4.run/docs/deployment) | | `B4_E1006` | App root package.json is not an ES module | [/docs/cli#b4-check](https://b4.run/docs/cli#b4-check) | | `B4_E1007` | Route entry has no recognisable export | [/docs/cli#b4-check](https://b4.run/docs/cli#b4-check) | | `B4_E1008` | Route entry exports more than one route kind | [/docs/cli#b4-check](https://b4.run/docs/cli#b4-check) | | `B4_E1009` | Invalid memory config | [/docs/configuration#memory](https://b4.run/docs/configuration#memory) | | `B4_E1010` | Invalid agentsMd config | [/docs/configuration#agentsmd](https://b4.run/docs/configuration#agentsmd) | | `B4_E2001` | Sandbox unavailable | [/docs/sandbox#what-it-is--and-isnt](https://b4.run/docs/sandbox#what-it-is--and-isnt) | | `B4_E2002` | Sandbox preflight failed | [/docs/sandbox#quickstart](https://b4.run/docs/sandbox#quickstart) | | `B4_E3001` | Permission denied | [/docs/permissions](https://b4.run/docs/permissions) | | `B4_E3002` | Subagent dispatch denied | [/docs/subagents#delegation-policy](https://b4.run/docs/subagents#delegation-policy) | | `B4_E3003` | Thread access policy failed to load | [/docs/thread-access#load-failures](https://b4.run/docs/thread-access#load-failures) | | `B4_E3004` | Middleware failed to load | [/docs/middleware#when-middleware-fails-to-load](https://b4.run/docs/middleware#when-middleware-fails-to-load) | | `B4_E4001` | Model provider package missing | [/docs/agents#model-providers](https://b4.run/docs/agents#model-providers) | | `B4_E4002` | Unknown model id | [/docs/agents#model-providers](https://b4.run/docs/agents#model-providers) | | `B4_E5001` | Import or export mismatch | None | | `B4_E5002` | Tool file has the wrong shape | [/docs/tools](https://b4.run/docs/tools) | | `B4_E5003` | Subagent unavailable or dispatch failed | [/docs/subagents#dispatch-failures](https://b4.run/docs/subagents#dispatch-failures) | | `B4_E5101` | Node version below the supported floor | None | | `B4_E5201` | Inspector server failed | [/docs/inspector](https://b4.run/docs/inspector) | | `B4_E5301` | Runtime store not provided | [/docs/deployment](https://b4.run/docs/deployment) | | `B4_E5401` | Invalid AG-UI run envelope | [/docs/ag-ui#envelope-validation](https://b4.run/docs/ag-ui#envelope-validation) | | `B4_E5402` | AG-UI response schema rejected | [/docs/ag-ui#response-schema](https://b4.run/docs/ag-ui#response-schema) | --- ### FAQ Source: https://b4.run/docs/faq Here are the questions developers ask before adopting B4.run. Each answer links to the full page when there is one. #### 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 adds the conventions around it: file-system routing, type inference, the dev server and the build step. If you don't want LangGraph, B4.run is the wrong tool. See [Mental Model](https://b4.run/docs/mental-model). ##### Can I bring my own model provider? Yes. An `agent()` route runs on a LangChain chat model. B4.run infers the provider for known model families and lazy-loads the matching LangChain integration package. For an alias, an ambiguous name, a local model or a provider-router model id, set `provider` to one of the built-in provider ids. Raw `graph` and `chain` routes can create any provider's model directly. See [Model providers](https://b4.run/docs/agents#model-providers). ##### 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 sits one layer up and organizes whole agent projects. 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: it produces a deployable agent runtime. The two work together through B4.run's AG-UI endpoint. Both `b4 dev` and `b4 start` serve `POST /agui/{routeId}` with the same route middleware, and the chat example wires a CopilotKit runtime to it through `B4HttpAgent`. - **Mastra** is a general-purpose agent framework with its own runtime and abstractions. B4.run is narrower. It keeps LangGraph and removes the boilerplate around it. If you're already on LangGraph, B4.run fits. If you're not, pick the framework whose runtime you want to live in. ##### Does B4.run support Python? No. B4.run is TypeScript-only because it relies on type inference: it reads tool parameter types from function signatures at build time and writes them to `.b4/b4.generated.d.ts`. In Python, use `langgraph` directly. LangSmith deploys both. ##### Is B4.run production-ready? B4.run is pre-1.0, and the API is still changing. By default, `b4 build` emits a runnable `node` target (`server.mjs` plus a hardened `Dockerfile`) and a `langsmith` target (`langgraph.json`). The edge targets are opt-in: `hono` for Cloudflare Workers and other edge runtimes, and `vercel` for Vercel. `node .b4/build/server.mjs` runs the `node` target's build output on `0.0.0.0`, and `b4 start` serves the app's source tree the same way. Pin a version and read the release notes before each upgrade. See [Deployment](https://b4.run/docs/deployment). #### Working in B4.run ##### Can I drop down to raw LangGraph when I need to? Yes. A route's `index.ts` can default-export an `agent()` descriptor or named-export a `workflow`, `graph`, or `chain`. The `graph` shape gives you a raw `StateGraph` with no B4.run abstractions in the way. You can mix shapes across routes in one project. See [Routes](https://b4.run/docs/routes). ##### What does `b4 build` actually do? It walks your routes, runs typegen, then writes deployment artifacts for each target in `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 `node .b4/build/server.mjs` or `docker build`/`docker run`. `b4 start` serves the source tree instead of the build output. - 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, turns it into a LangGraph graph, and wires in the route's tools. Each `assistant_id` is `#`, for example `/navlog#agent`. - The opt-in `hono` target emits `.b4/build/app.mjs` plus a `wrangler.toml` for edge runtimes, and the opt-in `vercel` target emits a Vercel Build Output tree. See [CLI Reference](https://b4.run/docs/cli#b4-build) and [Deployment](https://b4.run/docs/deployment). ##### Why a meta-framework instead of a library? A library is something you call. A meta-framework is something you write inside. B4.run's conventions (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 works because every page lives under `app/`. A library would leave you writing the boilerplate B4.run removes. See [Mental Model](https://b4.run/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 run `node .b4/build/server.mjs` directly. `b4 start` serves the same runtime from the source tree rather than the build output. This is the only deployment path that runs the execution sandbox. You can also feed the `langsmith` target's `.b4/build/` output (`langgraph.json`) to any container that runs the LangGraph runtime. That path skips both the B4.run runtime and the sandbox. See [Deployment](https://b4.run/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. You don't need the `nodejs_compat` flag. The sandbox, workspace file and shell tools, tool-output offloading, and long-term memory aren't available there. If your app uses them, the build fails and names them. The runtime repeats that check per request for anything that skipped the build. 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](https://b4.run/docs/deployment/edge) before you commit to this path. That "yes" rests on a gated CI lane serving real AG-UI turns under **local** workerd (the same binary Cloudflare runs). Nobody has deployed to Cloudflare itself yet. [What is proven, and what is not](https://b4.run/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](https://b4.run/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, because B4.run wraps your code without rewriting it. Once the route is live, you can move state and tools into B4.run's conventions, or leave the graph as it is. #### Related - [Mental Model](https://b4.run/docs/mental-model): the framework's shape - [Deployment Options](https://b4.run/docs/deployment): runtime targets - [Recipes Overview](https://b4.run/docs/recipes): task-oriented how-tos --- ## Task-Specific Prompts ### Build your first B4.run agent Source: https://b4.run/prompts/scaffold Help me build my first B4.run agent. B4.run is the TypeScript framework for LangGraph.js. An agent is a folder: the folder is the route, and each file in its `tools/` folder is a tool. B4.run writes each tool's schema from its TypeScript input type. 1. Scaffold the app: ``` npm create b4-app@latest my-agent cd my-agent npm install ``` This creates the default `basic` template: one package with a `/hello` agent in `src/app/hello/index.ts` and one typed `greet` tool in `src/app/hello/tools/greet.ts`. Pass `-- --template navlog` instead for the larger VFR flight planner with subagents, planning, memory, and a web UI. 2. Walk me through the two files. Explain: - `index.ts` default-exports `agent({ model, systemPrompt })`. - Each file in `tools/` default-exports one async function. The file name is the tool name, the doc comment is its description, and the input type is its schema. - `.b4/b4.generated.d.ts` is generated. Never edit it. 3. Help me add a second tool in `src/app/hello/tools/`. Keep it deterministic, with no network call. 4. Validate the app. None of these need a model-provider key, because the test and the eval replay recorded model responses: ``` npx b4 check npm test npm run eval ``` 5. Only then run the agent live. Require me to set a real `OPENAI_API_KEY`. Never invent or commit a key: ``` export OPENAI_API_KEY=... echo '{"messages":[{"role":"user","content":"Say hello to Ada."}]}' | npx b4 run /hello ``` 6. For a dev server, run `npm run dev`. It serves Agent Protocol and AG-UI on `http://127.0.0.1:3000` and reloads as I edit. 7. Summarize what I can build next: add a route, write an agent harness test, add an eval, or start from the navlog template. 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 Source: https://b4.run/prompts/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 navlog scaffold keeps `computeNavlog` 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 navlog 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 Source: https://b4.run/prompts/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 Source: https://b4.run/prompts/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 navlog scaffold's `/navlog#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: "/navlog#agent" }) afterAll(async () => { await h.close() }) it("reads the POH and cites it", async () => { h.reset() const run = await h.run({ input: "What does the POH give for cruise at 4000 ft and 2400 RPM?", fixtures: script() .user("What does the POH give for cruise at 4000 ft and 2400 RPM?") .callsTool("readDoc", { path: "poh/cruise-performance.md" }) .replies("64% BHP, 110 KTAS, 7.1 GPH. [poh/cruise-performance.md, Figure 5-7]"), }) expectToolCalled(run, "readDoc") expectFinalMessage(run).toContain("[poh/") }, 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("/navlog").scenario("uses a controlled METAR", (s) => s .input({ messages: [{ role: "user", content: "What is the weather at KSTP?" }] }) .mockTool("getMetar", async ({ ids }) => ids.map((id) => ({ id, observedAt: "2026-10-04T19:53:00.000Z", flightCategory: "VFR", windDirDeg: null, windKt: null, visibilityMi: null, ceilingFt: null, tempC: null, dewpointC: null, altimeterHpa: null, raw: `METAR ${id} 041953Z 00000KT 10SM CLR`, })), ) .expectPassed() .expectTool("getMetar", (call) => call.calledOnce().withArgs({ ids: ["KSTP"] }), ), ) ``` 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 Source: https://b4.run/prompts/deploy 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 `/navlog#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 Source: https://b4.run/AGENTS.md ````markdown # 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 such as the default basic scaffold, and the `server/` package in an app scaffolded from the navlog 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 for 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 basic scaffold: `src/app/hello/index.ts` → route id `/hello`; agent route key `/hello#agent`. - Navlog scaffold (`npm create b4-app@latest my-app -- --template navlog`): `src/app/navlog/index.ts` → route id `/navlog`; agent route key `/navlog#agent`. - Route group plus dynamic segment: `src/app/(public)/hello/[tenant]/index.ts` → route id `/hello/[tenant]`; callers pass `tenant` in JSON input. ## Defining an Agent Route ```ts // src/app/navlog/index.ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You are a VFR flight-planning assistant for a Cessna 172N. Brief the weather, compute the navlog in code, and cite every POH figure.", // 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 }`: attempts and backoff for each model 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/tools/computeNavlog.ts (shared; the navlog scaffold uses this location) import type { B4ToolContext } from "@b4run/sdk" import { computeNavlog, type Navlog, type NavlogInput } from "../lib/navlog.js" // NavlogInput: { aircraft, altitudeFt, departureTimeUtc, waypoints, winds } // KSTP → KRST at 4500 ft returns totals of 66 nm, 33 min and 5.5 gal. export default async (input: NavlogInput, ctx: B4ToolContext): Promise => { ctx.signal.throwIfAborted() return computeNavlog(input) } ``` - 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, body?, headers, method, params, routeId, url }`. - `body` is an untrusted, detached parsed JSON envelope on POST execution requests; it is absent on GET requests. Validate values before returning them through `allow(context)`. Editing the snapshot does not rewrite execution input. - 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/navlog/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 NavlogState = z.infer export async function workflow( state: NavlogState, ctx: RuntimeContext>, ) { // ctx.signal is the request-scoped AbortSignal. // ctx.tools.computeNavlog is fully typed from the shared src/tools/ directory. const waypoints = [ await ctx.tools.lookupAirport({ id: "KSTP" }), await ctx.tools.lookupAirport({ id: "KRST" }), ] const navlog = await ctx.tools.computeNavlog({ aircraft: { tailNumber: "N738ZU", cruiseRpm: 2400, usableFuelGal: 50 }, altitudeFt: 4500, departureTimeUtc: "1400Z", waypoints, winds: [{ dirDegTrue: 270, speedKt: 15 }], // one entry per leg }) const { distanceNm, eteMin, fuelGal } = navlog.totals // 66 nm, 33 min, 5.5 gal return { ...state, context: `${distanceNm} nm, ${eteMin} min, ${fuelGal} gal`, } } ``` The `RouteTools<"/navlog">` 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 `npx`) - `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. `/navlog#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":"Say hello to Ada"}]}' | b4 run /hello`: execute the default basic scaffold route. - `echo '{"messages":[{"role":"user","content":"Plan a VFR flight from KSTP to KRST at 4500 feet, departing 1400Z."}]}' | b4 run /navlog`: execute the navlog 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": "/navlog#agent", "input": { "messages": [{ "role": "user", "content": "Plan a VFR flight from KSTP to KRST at 4500 feet, departing 1400Z." }] } } ``` Resume resolves a parked human-in-the-loop interrupt and streams the continuation: ```json { "resume": [ { "interruptId": "", "status": "resolved", "payload": "once" } ], "route": "/navlog#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. ### B4.run 0.13: A sandbox per thread Source: https://b4.run/blog/b4-0-13-a-sandbox-per-thread Published: 2026-09-30 > Learn what 0.13 adds for threads that need their own environment: per-thread images and permissions, staged workspaces and HTTP reads. Until now, a B4.run app had one sandbox configuration. Every thread got the same image, the same limits and the same allow-list. That's fine for a chat assistant. It falls apart when each thread is a different job. I ran into this building a software factory on B4.run. A controller hands a work order to a worker agent, the worker fixes a bug in its own container, and the controller checks the result. Each work order needs its own repository snapshot, and some need a different toolchain. The controller runs somewhere else, so it can't just drop files onto the worker's disk. B4.run 0.13 moves those decisions from the app to the thread. Let's walk through what that looks like. #### Goals We want each thread to: 1. Run in its own image, with its own resource limits. 2. Have its own allow-list for the shell and filesystem. 3. Start from a workspace that another process chose. 4. Be readable from that other process when the work is done. If your app gives every thread the same environment, you don't need any of this. Your configuration keeps working as it is, with a few upgrade notes at the end. #### Decide the sandbox per thread Since 0.10 you could make `sandbox.workspace` a function of the thread, so each thread started from different files. In 0.13, `sandbox.thread` goes further and decides the whole sandbox: workspace, image, policy and permissions. Here's a builder app where each thread names a target in its metadata: ```ts // b4.config.ts const targets: Record = { small: { image: "builder-small:1a2b3c4d5e6f", memoryMb: 1024 }, large: { image: "builder-large:6f5e4d3c2b1a", memoryMb: 8192 }, } export default config({ sandbox: { provider: dockerSandbox({ scope: "builder", images: (reference) => Object.values(targets).some((t) => t.image === reference), }), network: { mode: "deny" }, thread: async ({ threadId, metadata }) => { const target = targets[String(metadata.target ?? "")] if (!target) throw new Error(`thread ${threadId} names no known target`) return { workspace: { source: { directory: "project", include: ["package.json", "src/index.ts"] } }, environment: { image: target.image }, policy: { resources: { memoryMb: target.memoryMb } }, permissions: { allow: { bash: ["npm test"] } }, } }, }, }) ``` A few things to note: 1. The resolver runs once, when the thread is first admitted. B4.run records what it returned, so later turns, restarts and readers all see the same sandbox. Changing the resolver never changes a thread that already exists. 2. `images` on `dockerSandbox()` bounds what a resolver can name. Any other image is refused before a single Docker command runs. 3. A thread can narrow the app's network, but it can't open a network the app denies. `security` stays the app's. 4. `sandbox.thread` and `sandbox.workspace` are exclusive, and `b4 check` refuses both together. One limit to be upfront about: the Kubernetes provider doesn't have managed workspaces, so it refuses `sandbox.thread`. Per-thread sandboxes are Docker-only for now. The [Sandbox guide](https://b4.run/docs/sandbox#per-thread-sandboxes) has the full rules. #### Give each thread its own permissions Did you notice the `permissions` in that resolver? When a resolver returns them, that thread gets its own allow-list in place of the app's. Let's quickly review how the two combine: - The thread's `allow` replaces the app's. A thread that sets only `deny` starts with nothing allowed. - Denials add up. The app's denials and the thread's both apply, and a denial always wins. - The mode stays the app's. - An **Always** decision is saved to that thread's record, not to `.b4/permissions.json`. It applies to later turns and subagents of that thread, and it goes away when the thread is deleted. Why does this matter? Before, a reviewer clicking **Always** on `npm install` in one thread granted it to every thread in the app. Now that grant stays where it was given. See [Per-thread permissions](https://b4.run/docs/permissions#per-thread-permissions) for the details. #### Hand a thread its workspace So far the worker's own config decides the files. In a factory, the controller should decide, and it may live on another host with no shared filesystem. `sandbox.stagedWorkspaces` solves that. The creator uploads a content-addressed bundle of files, then names it when it creates the thread: ```ts // b4.config.ts export default config({ sandbox: { provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }), stagedWorkspaces: true, thread: async (thread) => { if (!thread.staged) throw new Error("create threads with a staged workspace") return { workspace: thread.staged } }, }, }) ``` ```bash curl -X PUT "$WORKER/workspace/sources/$DIGEST" -H "authorization: Bearer $TOKEN" --data-binary @source.json curl -X POST "$WORKER/threads" -H "authorization: Bearer $TOKEN" \ -d '{"metadata":{},"workspace":{"sourceDigest":"'$DIGEST'","environmentLinks":[],"baseline":"git"}}' ``` Let's break that down: 1. The upload is verified byte for byte against the digest in its path. 2. The create names that digest. A thread can only name a source that was uploaded, never one stored for another thread. 3. At the thread's first run, the bytes are verified again and the resolver receives them as `thread.staged`. It can accept them, check them or refuse them. Handing the staged workspace straight back, as the example does, trusts the creator with more than files. `environmentLinks` can point at any absolute path in the image. If not every creator should choose those, check them in the resolver. This option also requires a thread-access policy, and `b4 check` refuses it without one. Uploads arrive as a new `workspace.source.put` operation, so turning this on means reviewing your policy's `create` handler. A handler written before 0.13 lets both through unless it checks `req.requestedWorkspace`. The [Thread access guide](https://b4.run/docs/thread-access#workspace-endpoints) shows how to keep one caller from choosing another caller's upload. #### Read the workspace back The last piece closes the loop. The worker is done, and the controller wants to see what it produced. Turn on `sandbox.workspaceRead: "http"` and the worker serves a bounded, read-only inventory of a thread's workspace. The client lives in `@b4run/cli/workspace`: ```ts import { readThreadWorkspace } from "@b4run/cli/workspace" const read = await readThreadWorkspace( "http://worker:4100", threadId, { root: "draft", excludeRootDirectories: [".git"] }, { headers: { authorization: `Bearer ${token}` }, expectedSourceDigest }, ) read.inspection.files // relative to `root` ``` The read happens in a separate, networkless container that mounts the workspace read-only. It holds the thread's run slot, so it never overlaps a turn. Pass `expectedSourceDigest` and the client refuses an answer about a different workspace. Like staged workspaces, this needs a thread-access policy, which sees the read as the new `thread.workspace` operation. #### Smaller things A few more changes I think you'll notice. ##### `editFile` and ranged reads Workspace agents get an `editFile` tool. It replaces an exact span of text in an existing file, and refuses when the text is missing or appears more than once instead of guessing. `readFile` also accepts `startLine` and `endLine`. Together they steer models away from rewriting a whole large file to change one line, which is how files got truncated. A route that denies `writeFile` loses `editFile` too, so read-only routes stay read-only. ##### Recordings that match production `b4 eval --record` now refuses to write a fixture that replay would reject, such as a turn with an empty assistant message. You get `Refused to record › ` at record time instead of a confusing replay failure later. `createAgentHarness` and `defineEval` also accept a `responseSchema`, so a harness run sends the model the same structured-output constraint a Hashbrown client sends in production. A recording made without it could capture replies production never produces. ##### Faster workspaces Inspecting a container workspace used to cost one `docker exec` per file. Batched walks and reads bring 1,214 entries from 143 seconds to about 1 second on Docker, and the Kubernetes backend gets the same treatment. Warm calls on a managed workspace no longer re-verify the entire source bundle each time, which took a 951-file workspace from about 340 ms per call to about 0.1 ms. #### Upgrade notes Pin every direct `@b4run/*` dependency to `0.13.0` together, then check these: 1. **Rebuild `node` target apps that have a thread-access policy.** This is a security fix. A built `node` app looked for `src/thread-access.ts` on disk at boot, and a missing file read as "no policy", so every thread endpoint served ungated. The policy is now part of the build, and a missing one stops the server from starting. The `hono` and `vercel` targets already worked this way. 2. **Unknown `sandbox` keys are refused.** A misspelled key used to be ignored silently. `b4 check`, `b4 build` and startup now reject it. 3. **The default sandbox network is plain `{ mode: "allow" }`.** The old default listed the cloud metadata endpoint in a `denylist` that neither provider enforced. Nothing changed at runtime, but the config no longer claims a block that never happened. On a cloud VM, set `network: { mode: "deny" }` or block that endpoint outside B4.run. 4. **`ThreadAccessRequest.requestedWorkspace` is a new required field**, and `ThreadOperation` gains `"workspace.source.put"` and `"thread.workspace"`. An exhaustive `switch` needs the new cases, and code that builds a request by hand needs `requestedWorkspace: undefined`. 5. **`POST /threads` refuses a `workspace` field it won't serve** with `400 workspace_not_accepted`, instead of ignoring it. Then run `npx b4 verify`, your tests and your evals. The [Upgrading guide](https://b4.run/docs/upgrading) covers the general process. #### Conclusion An app with one sandbox is a great place to start. Once each thread is a different job, the environment belongs to the thread: its image, its limits, its permissions and its files. 0.13 gives you each of those pieces separately, so you can adopt only what you need. Start with `sandbox.thread` if your threads need different toolchains. Add staged workspaces and HTTP reads when another process is choosing the work and checking the result. --- ### B4.run 0.9 to 0.12: The answer the user sees Source: https://b4.run/blog/b4-0-12-the-answer-the-user-sees Published: 2026-09-23 > Learn what 0.9 through 0.12 changed about the final answer: response schemas, an after hook, returnDirect tools and streamed tool arguments. The last release roundup covered everything through 0.8. Since then we've shipped four minor releases, 0.9 through 0.12, in four days. That was a busy week. Most of those changes are about one thing: the answer the user actually sees. Is it shaped the way the client expects? Can we check it before it's shown? Does it stream, or does the user stare at a spinner? Let's walk through them with an assistant that answers by rendering UI components instead of prose. **Version note:** These releases are superseded by [0.13](https://b4.run/blog/b4-0-13-a-sandbox-per-thread). Upgrade straight to the current release rather than stopping at 0.12. #### Goals We want our assistant to: 1. Reply in a shape the client can render, every time. 2. Check the reply before the client sees it. 3. End the run as soon as the answer is ready. 4. Show progress while the model is still working. Let's dive in. #### Constrain the final message [Hashbrown](https://hashbrown.dev) renders an assistant's reply as UI. Its client sends a JSON Schema as `hashbrown.responseSchema` on every run, and the final message must match it. Before 0.9, `POST /agui/:routeId` accepted that field and quietly ignored it. The client thought its schema was honored, right up until a reply failed to parse. Now the runtime either applies the schema or rejects the run. On an `agent` route, the schema is bound on the root model as the provider's native structured output: OpenAI's `response_format` or Anthropic's `output_config.format`. Tool-calling turns are untouched, and only the answering turn is constrained. Anywhere it can't be applied, like a `workflow` route or a provider without a schema mode that works alongside tool calls, the run is refused with `422` and `B4_E5402`. That's a much better failure than a reply that doesn't parse. The [AG-UI guide](https://b4.run/docs/ag-ui#response-schema) lists which providers support it. #### Check the answer before it's shown A schema tells us the reply has the right shape. It can't tell us whether the reply is allowed. Maybe a component needs a permission the current user doesn't have. Middleware used to run only before the route. In 0.9, the object form of `defineMiddleware()` gains an `after` hook that runs on the final assistant message: ```ts // src/middleware.ts import { allow, defineMiddleware, reject } from "@b4run/sdk" import { validateUi } from "../assistant-ui" export default defineMiddleware({ async handle(req) { const session = await loadSession(req.headers.authorization) if (!session) return reject(401) return allow({ session }) }, async after({ context, finalMessage }) { const verdict = validateUi(finalMessage, context?.session) if (!verdict.ok) return reject(422, { error: verdict.reason }) return { finalMessage: JSON.stringify(verdict.ui) } }, }) ``` A few things to note: 1. `after` receives whatever `handle` passed to `allow()`, so the session is right there. 2. Return nothing to keep the message, `{ finalMessage }` to replace it, or `reject()` to end the run with a `RUN_ERROR`. 3. `after` only runs on AG-UI runs. There's a tradeoff. AG-UI has no way to take back text a client already received, so with `after` defined, the final message is buffered on the server and delivered whole. Text before a tool call still streams. An app without the hook streams exactly as before. The [Middleware guide](https://b4.run/docs/middleware#validating-the-final-assistant-message) covers the details. #### End the run on a tool's result Sometimes a tool's result *is* the answer. In our assistant, a `render` tool validates the components and shows them. Handing that result back to the model just so it can say "Here you go!" costs a turn, tokens and seconds. In 0.11, a tool module can export `returnDirect`: ```ts // src/app/(public)/assistant/tools/render.ts export const returnDirect = true const COMPONENTS = new Set(["Table", "Chart", "Summary"]) /** Show the composed answer to the user. Call exactly once, last. */ export default async (input: { readonly ui: readonly string[] }) => { const unknown = input.ui.filter((name) => !COMPONENTS.has(name)) if (unknown.length > 0) throw new Error(`invalid_ui: unknown components ${unknown.join(", ")}`) return { rendered: true } } ``` The run ends on the tool's result. The AG-UI stream ends after `TOOL_CALL_RESULT`, and an `after` hook sees an empty final message. Did you notice that this tool throws on bad input? In 0.11, that would have ended the run on the error, because LangGraph's prebuilt agent routed a `returnDirect` tool to the end by name without checking whether it failed. That made `returnDirect` a poor fit for exactly the kind of tool that wants it. 0.12 fixes that. Only a successful call ends the run. A thrown error, or arguments that fail the tool's schema, go back to the model so it can correct the call and try again. #### Run on `createAgent` That fix came with a bigger change under the hood. In 0.12, agent routes run on LangChain's `createAgent` instead of LangGraph's deprecated `createReactAgent`. B4.run now installs its own `createAgent` middleware for the things it used to pass as options: prompt fragments rendered from live state, summarization's condensed history, and tool errors returned to the model. For most routes nothing should change, but it's the kind of thing worth covering with your evals when you upgrade. LangChain packages moved to their current releases in 0.11, too, and every workspace package now resolves a single copy of `@langchain/core` and `@langchain/langgraph`. #### Stream more of the run An answer that takes ten seconds feels a lot faster when the user can see it forming. ##### Tool-call arguments In 0.10, tool-call arguments stream as the model generates them. The AG-UI translator emits one `TOOL_CALL_START`, a series of `TOOL_CALL_ARGS` deltas and one `TOOL_CALL_END`. A client that renders from a tool call's arguments, like our `render` tool, can paint progressively instead of waiting for the whole call. The tool itself still receives the complete, parsed arguments. ##### Assistant text on Anthropic This one is a little embarrassing. LangChain's Anthropic integration only flattens a chunk's content to a string when the request binds no tools, and every B4.run agent binds tools. So an Anthropic-backed agent streamed no assistant text at all. The same shape came from OpenAI's Responses API. In 0.11, the adapter reads a chunk's `text` blocks, skipping thinking, citation and tool-input deltas, so those models stream their prose like any other. ##### Tool errors In 0.9, a tool that throws emits a `tool_result`, so AG-UI clients see a `TOOL_CALL_RESULT` instead of a call that never finishes. #### Tools and types A tool's input type becomes the schema the model sees. Two changes make that more reliable. First, in 0.9, schema extraction reads your app's `tsconfig.json`, including `extends`, `paths` and `baseUrl`. An input type imported through a path alias used to resolve to `any` and produce a permissive schema. Now it gets its real schema, and a type that still can't be resolved fails `b4 typegen` with an error naming the tool and the type. Second, in 0.11, `b4 run` and `b4 test` regenerate tool schemas before they execute a route. A tool you changed since the last `b4 typegen` is bound with its current schema, not a stale one. #### Workspaces per thread This is where [0.13](https://b4.run/blog/b4-0-13-a-sandbox-per-thread) picks up, and 0.9 and 0.10 laid the groundwork: - In 0.10, `sandbox.workspace` can be a function. It runs once per thread, at first admission, and returns that thread's starting files. - In 0.9, a trusted host process can open a read-only view of a thread's managed workspace without touching its live sandbox. A controller can check what an agent produced. - In 0.10, the Docker sandbox starts its container with `--init`, so orphaned processes are reaped instead of lingering as zombies. #### A smaller starter `npm create b4-app` now scaffolds a single `/hello` agent route with one typed `greet` tool. The route group, the `[tenant]` segment and `state.ts` are gone. A new app starts with the smallest agent that works, and you add the rest when you need it. A few rough edges around getting started are smoother, too: - `@b4run/cli` exports the `B4Config` type, so `export default config({})` type-checks under pnpm's isolated `node_modules`. - The missing model provider error, `B4_E4001`, suggests the install command for the package manager you're using instead of always printing `pnpm add`. #### Testing `createAgentHarness` and `defineEval` accept a `middlewareContext`, either a value or a function of the run. Tools that read `ctx.middleware` can now be tested in-process, even though the harness bypasses `middleware.ts`. #### Upgrading Use Node.js 24 or later, and pin every direct `@b4run/*` dependency to the same release. Since these releases are superseded, upgrade to the current one: 1. Read the [Upgrading guide](https://b4.run/docs/upgrading) and the release notes in between, including the [0.13 upgrade notes](https://b4.run/blog/b4-0-13-a-sandbox-per-thread#upgrade-notes). 2. Update all direct B4.run dependencies together. 3. Run `npx b4 verify`. 4. Run your route tests and evals, especially for agent routes, since they now run on `createAgent`. #### Conclusion The user doesn't see your routes, your tools or your middleware. They see the answer, and how long it took to show up. These releases give you more control over that answer. You can constrain its shape, check it before it's shown, end the run as soon as it's ready and stream the work in between. Pick the one your app needs first. --- ### What is a software factory? Source: https://b4.run/blog/what-is-a-software-factory Published: 2026-09-22 > Software factory is a buzzword again. Here is what the term meant in 1968, what it means in 2026, and what we learned building one with B4.run. "Software factory" is a buzzword again. If you've spent any time around coding agents this year, you've probably seen it: dark factories, light factories, agent factories, and factories with a capital F. I've been building one with B4.run for the past few weeks, and I kept getting stuck on a simple question: what is a software factory, actually? Let's take an honest look. We'll cover: - Where the term came from - Whether it ever worked - What it means in 2026 - Where the factory analogy breaks - A definition that holds up across all of it - What we learned building one #### Where did the term come from? The phrase is 58 years old. In October 1968, NATO held the first Software Engineering conference in Garmisch, Germany. Robert Bemer of General Electric presented a plan for what he called a "machine-controlled production environment, or software factory." My favorite line from the [conference report](http://homepages.cs.ncl.ac.uk/brian.randell/NATO/nato1968.PDF) is this one: > Ideally it should be impossible to produce programs exterior to this environment. That's a sandbox. Bemer wanted every program built, tested, measured and costed inside one controlled environment, with production records used to predict the next job. At the same conference, Doug McIlroy of Bell Labs presented [Mass Produced Software Components](https://mcilroy.cs.dartmouth.edu/components.txt). He said the hardware folks were "the industrialists and we are the crofters." He was also careful to point out that mass production, in the sense of copying, is trivial for software. Copying a program costs nothing. What he wanted was a catalogue of reusable parts. What surprised me most is that the objections we hear today were raised in that same room. One attendee warned that an automated system "may disguise some of what is happening." Another said that if you don't know what you're doing, automating it can be dangerous. And McIlroy said this: > It would be immoral for programmers to automate everybody but themselves. Fifty-eight years later, that's more or less the project. #### Did it work? Sort of. It depends on what kind of work was going through the factory. ##### Hitachi and Toshiba In 1969, Hitachi opened the first software facility in the world called a factory. Some of its engineers wanted to call it a "Software Center." The company president overruled them and told them to call it a factory. It worked. According to Michael Cusumano's research at MIT, late projects at Hitachi dropped from over 72% in 1970 to 13% in 1973. Toshiba opened its Fuchu Software Factory in 1977. By 1985, nearly half of the code its programmers shipped was reused, and faults found in final testing fell dramatically. Then the gains flattened. Productivity grew 70% in the first five years and only 8% over the next four. Toshiba hit a practical ceiling of about 50% reuse and couldn't get past it. ##### SDC Around the same time, System Development Corporation in Santa Monica built a software factory of about 200 programmers with separate design, production and test groups. It never made it past the experiment. One manager summed it up in [Cusumano's study](https://archive.org/details/ussoftwarefactor00cusu): > Every job was totally different. Another said he didn't think the programmers considered themselves factory workers. The word "factory" became, in Cusumano's words, an anathema at SDC. So what was the difference between Toshiba and SDC? Toshiba had a focused product line and a steady stream of similar work. SDC had contract work where every project was different. I think this is the most important lesson in the history of the term, and we'll come back to it. #### The middle years Between 2004 and 2023, the word was reused three more times, and each time it meant something a little different. **Microsoft** published *Software Factories* in 2004 (Jack Greenfield and Keith Short). Their factory was a Visual Studio environment configured to generate a specific type of application from models and domain-specific languages. Greenfield argued that software has economies of scope, not scale, and wrote that development would never be "a purely mechanical process tended by drones." It still didn't take off. The tooling was retired, and in 2010 Microsoft cancelled the core of Oslo, the modeling platform that came after it. **The US Department of Defense** got into the act with Kessel Run, founded in 2017. A DoD software factory is basically an in-house team with a DevSecOps pipeline. The GAO counted [29 of them in 2022](https://www.gao.gov/assets/gao-23-105611.pdf) and found that the reforms meant to support them were still a work in progress. **Outsourcing firms** used it too. In Brazil, a "fábrica de software" is a standard name for an outsourced development shop. This is the version that drew the criticism that the factory model treats developers as interchangeable parts. Notice the pattern. Microsoft industrialized the *artifact*. The DoD industrialized the *pipeline*. The outsourcers industrialized the *labor*. Same word, three different things. #### What does it mean in 2026? Today, "software factory" almost always means the layer that sits *above* a coding agent. The best one-liner I've found comes from a [Firecrawl post](https://www.firecrawl.dev/blog/ai-software-factory) describing what it's like to drive a single agent by hand: > Everything except the typing is still you. You pick the task. You set up the environment. You decide whether the result is any good. You merge it. A factory moves those jobs out of your head and into infrastructure. I find it helpful to think in three tiers: 1. **An agent harness** runs one loop on one task: the model, the tools, the instructions and the environment around it. This is Claude Code or Codex working in your repository. 2. **An orchestrator** runs many loops at once, but you're still the one choosing the work and reading the diffs. Steve Yegge's Gas Town, which runs a couple dozen agents in parallel, is a good example. 3. **A factory** makes intake, isolation, verification and the merge gate part of the system. Work comes from a queue, agents work in disposable sandboxes, the results are checked automatically, and a policy decides what ships. Within that third tier there is a real split. In January, Dan Shapiro published a five-level scale for AI coding, modeled on self-driving levels, and called level 5 the [Dark Factory](https://www.danshapiro.com/blog/2026/01/the-five-levels-from-spicy-autocomplete-to-the-software-factory/). The name comes from lights-out manufacturing, where the lights can stay off because no people are on the floor. Two weeks later, StrongDM published their [software factory](https://factory.strongdm.ai/) with two rules: code must not be written by humans, and code must not be reviewed by humans. Simon Willison [wrote it up](https://simonwillison.net/2026/Feb/7/software-factory/) the next day and asked what he called the most consequential question in software development right now: how do you prove the software works when agents write both the code and the tests? On the other side is what Addy Osmani calls the light, or lit, [factory](https://oreillyradar.substack.com/p/software-factories-light-and-dark). Humans don't disappear. Human judgment moves upstream, to the product, the design and the architecture, before an agent ever starts working. #### Is it a buzzword? Partly, yes. Gartner has a name for this: "agent washing." In 2025, it estimated that only about 130 of the thousands of vendors claiming agentic AI actually have it. "Software factory" is heading the same way. It's starting to mean any CI pipeline with a model attached. The evidence is also thin. The big numbers, like 80% or even 100% of code being written by AI, are self-reported by the companies selling the AI. METR's 2025 study found experienced developers took 19% *longer* with AI while believing they were 20% faster. Its 2026 follow-up suggests developers are probably faster now, but METR calls its own data only very weak evidence for how much. The 2025 DORA report found AI adoption correlates with higher throughput and also higher instability. I haven't found a factory vendor publishing the numbers that would tell us whether a factory works: - How long does it take an issue to become a merged change? - How often do agent-merged changes cause a failure? - What does each accepted change cost? If you're evaluating a factory, I'd start by asking for those three numbers. #### Where does the analogy break? The factory metaphor has been criticized for about as long as it has existed. Fred Brooks, in *No Silver Bullet* (1986), said the hardest part of building software is "deciding precisely what to build." Jack Reeves argued in 1992 that the source code *is* the design, and that the manufacturing step, compiling, is so cheap it is almost free. Martin Fowler made the same point: in software, all the effort is design. If they're right, and I think they are, then the traditional factory has always automated the easy half. Physical factories are great at producing the same, fully specified thing over and over. Software rarely needs that. Copying is free, and every change is a little bit of design. The analogy does hold in one place. Toyota's *jidoka*, or "stop the line," means the machine stops itself the moment something is wrong. A red build that blocks a merge is jidoka. The pipeline has always been the most factory-like thing we have. Here's what's actually new in 2026. Earlier factories industrialized everything *around* the programmer: the environment, the components, the pipeline and the labor. The dark factory claims to industrialize the programmer. And because agents now write the source code, the design work doesn't go away. It moves into the spec and the acceptance scenarios, which become the new source code. Real lights-out plants are a useful check. FANUC has built robots unsupervised for weeks at a time since 2001, but those plants make a fixed product, and even highly automated plants keep people on quality inspection. A software dark factory is trying to go dark on the design step, and I'm not aware of any physical factory that has done that. #### So, what is a software factory? Here is my attempt: > A software factory is a long-lived system, above any single project, where work enters one standard way, every change is built inside one controlled environment, results are checked by verification the builders can't game, and a named person is accountable for what ships. The definition doesn't mention AI. Bemer's 1968 factory fits it, and so does Toshiba's. In 2026, the thing doing the building happens to be an agent in a sandbox. I think four tests separate a real factory from the buzzword: 1. **A steady stream of similar work.** SDC failed without it. Bug queues, dependency upgrades and migrations are a good fit. New product design is not. 2. **One enforced environment.** Bemer's "impossible to produce programs exterior to this environment" is exactly what a sandbox is. 3. **Verification the builders can't game.** If the agent can influence its own grade, you have a very expensive way to make tests pass. 4. **A named owner.** When something breaks in production, whose name is on it? If you're missing any of those, what you have is an agent with a cron job. That can be useful. It just isn't a factory. #### What we learned building one We have been building a [software factory example](https://github.com/cacheplane/b4run/tree/main/examples/software-factory) with B4.run, and its target is the B4.run repository itself. I wanted to use the framework on the codebase I know best. We built it in small rungs, and each rung had to prove itself before we moved on. The example is two B4.run apps that share no source. The controller keeps the work orders, and its commands (create, dispatch, approve, deny and cancel) are workflow routes. The builder is one bounded agent route that edits files in a container. The model doesn't decide what happens next. The controller does. The most important decision was this one: > The builder has no channel for a verdict. In the first rung, the worker agent reported whether it had succeeded. That turned out to be backwards. From the next rung on, the builder can only edit a workspace. The controller reads that workspace, runs the verification, and freezes a review bundle for a person to approve. Even then, keeping the builder away from its own grade was harder than I expected. The verifier ran two test suites: one the agent could see, and an independent check it couldn't. Both ran in the same container. The tamper check skipped the build output directory because that seemed harmless. It wasn't. The independent check graded that same build output, so a candidate's code could start a detached process during the visible tests and swap the built file before the independent check ran. We proved it with a test payload, and nothing noticed the tampering. The candidate got to choose its own oracle's verdict. The fix was to exclude nothing from the tamper check and to give each suite its own container. The lesson stuck with me: every exclusion in an integrity check is a hole the moment the excluded path is also an input to the verdict. This is also why I don't buy the fully dark version yet. StrongDM's answer to reward hacking is holdout scenarios the agents never see, and I like that idea. But once a repair loop gets feedback from a test suite, that suite is no longer a true holdout. In my opinion, verification independence *is* the factory. #### Conclusion I think the word is worth keeping, with a caveat. It has always promised predictability and throughput, and for 58 years it has delivered that only in the narrow, repetitive part of the work. Hitachi took three years to bring late projects down from 72% to 13%. Nobody has published a before-and-after like that for an AI software factory yet. So I'll keep using the word, and try to use it honestly. A factory is the careful system around the model and the agent. It decides what work comes in, where it gets built, how it gets checked, and who is accountable for it. If you want to see how those pieces fit together in code, start with the [software factory example](https://github.com/cacheplane/b4run/tree/main/examples/software-factory) and the [Sandbox guide](https://b4.run/docs/sandbox). If you're building a factory of your own, or think the term should be retired, I'd like to hear from you. --- ### Build a code-fixing agent you can read Source: https://b4.run/blog/build-a-code-fixing-agent-you-can-read Published: 2026-09-15 > Follow a B4.run agent from a failing CLI test through a Docker workspace, independent checks, and approval of the exact change. Learn how to build a code-fixing agent with TypeScript and B4.run. **Updated September 2026:** The source links now point to commit `b4084553` from September 20, and the published blueprint uses B4.run 0.10.0. If you've written a Node.js application that reads files, runs a command and checks the result, you already know most of the pieces we'll use. The new part is letting a language model decide which operation to perform next. In this article we'll follow an agent that fixes a small bug in a command-line application. We'll give it the source code and a failing test, let it make a change, and review the result before exporting it. Let's start with a few concepts, then work through the code. #### What is an agent? For this application, an agent is a language model running in a loop with access to functions that can do work. A model can generate text, including TypeScript. To change a file or run a test, it needs the application to do it. We expose those operations as **tools**. A tool is a function the model can ask the application to call. For example, our `readFile` tool reads a file and returns its contents. A tool call includes the function's name and input, such as the path of the file to read. Here is roughly how it works: 1. We send the model our instructions and the user's request. 2. The model requests a tool call, such as reading `src/cli.ts`. 3. The runtime checks whether the call is allowed and executes the tool. 4. The result is added to the conversation and sent back to the model. 5. The model can request another tool or respond to the user. That last step lets the agent work through a problem. It can read a file, see a failed test, inspect another file and then choose an edit. We don't have to predict every file it will need. We do need to decide what it can do and how we'll check its work. A model that says "fixed" hasn't proven that our application works. #### Goals Our code-fixing agent will: 1. Reproduce a failing test before editing. 2. Inspect and change the permitted TypeScript source. 3. Run the test again. 4. Verify the proposed change using a fresh copy of the project and additional tests. 5. Wait for a person to approve exporting the change. We'll use **B4.run**, a TypeScript framework for building agent applications, for the tool runtime, workspace lifecycle and approval handling. We'll write the application-specific rules ourselves. #### Source code You can follow along with the [completed example](https://github.com/cacheplane/b4run/tree/b40845532/examples/code-fixer/server). The source links throughout this article point to that version, so you don't need to copy each code block. First we'll walk through how it works. At the end, we'll start the application and send it a request through its HTTP API. #### The bug we're going to fix Our sample is a small command-line application built with Commander, a library for parsing command names, arguments and options. It contains a real bug from [an earlier B4.run fix](https://github.com/cacheplane/b4run/pull/399). The following command should run memory consolidation without changing any files: ```sh node --import tsx src/cli.ts memory consolidate --dry-run ``` Here, `memory` is the command, `consolidate` is its subcommand and `--dry-run` is an option for that subcommand. The `--import tsx` argument lets Node run the TypeScript entry point. Instead, the command fails because the parser rejects `--dry-run` before our handler runs. Let's look at the registration in `src/cli.ts`: ```ts import { Command } from "commander" import { runMemoryCommand } from "./memory.js" const program = new Command().name("fixture") program .command("memory [subcommand] [args...]") .description("Manage memory") .option("--cwd ", "App directory") .action(async (subcommand: string, args: string[], options: { cwd?: string }) => { await runMemoryCommand(subcommand, args, options) }) await program.parseAsync(process.argv) ``` [Source: `sample/project/src/cli.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/project/src/cli.ts#L1-L13). A few things to note: - We create a `Command` instance and name the program `fixture`. - The `memory` command accepts a subcommand and additional arguments. - The command defines `--cwd`, which selects the application's directory. - The action passes the parsed values to `runMemoryCommand`. The problem occurs before that action receives the arguments, so testing `runMemoryCommand` directly would skip the parser and miss the defect. Our test starts the actual CLI process instead: ```ts import assert from "node:assert/strict" import { spawnSync } from "node:child_process" import test from "node:test" test("documented dry-run flag reaches the handler", () => { const result = spawnSync(process.execPath, ["--import", "tsx", "src/cli.ts", "memory", "consolidate", "--dry-run"], { encoding: "utf8" }) assert.equal(result.status, 0, result.stderr) assert.deepEqual(JSON.parse(result.stdout), { action: "consolidate", dryRun: true }) }) ``` [Source: `sample/project/test/cli.test.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/project/test/cli.test.ts#L1-L9). First, `spawnSync` runs the command and waits for it to finish. Then we assert that its exit status is zero, meaning success. Finally, we parse the output and check that the handler reported `dryRun: true`. We'll call this the **visible test** because the agent can read it in the project. The agent also needs to know which files it may change and which behavior it must preserve. We put those instructions in `TASK.md`: ```markdown # Repair CLI flag forwarding The documented command `node --import tsx src/cli.ts memory consolidate --dry-run` fails before the handler runs. Reproduce the failure with `npm test`, repair the CLI registration, and verify it. Preserve memory-level `--cwd`, value-taking `prune --cap`, and rejection of invalid arguments. Change only `src/cli.ts`. Do not change tests, dependency versions, configuration, or the handler. ``` [Source: `sample/task.md`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/task.md#L1-L7). That tells the agent it may edit one file: `src/cli.ts`. A repair must also preserve `--cwd`, numeric `--cap` values, rejection of invalid arguments and dry-run behavior. Before we build the agent, let's look at the known fix so we understand what we're asking it to find. The reference patch adds two Commander settings: ```diff --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,10 @@ import { Command } from "commander" import { runMemoryCommand } from "./memory.js" -const program = new Command().name("fixture") +const program = new Command().name("fixture").enablePositionalOptions() program .command("memory [subcommand] [args...]") + .passThroughOptions() .description("Manage memory") .option("--cwd ", "App directory") .action(async (subcommand: string, args: string[], options: { cwd?: string }) => { ``` [Source: `sample/reference.patch`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/reference.patch#L1-L14). The parent enables positional options, and the memory command passes through subcommand arguments for the handler to interpret. The handler can then receive `--dry-run` and validate the arguments it understands. We'll use this known repair later to test the application's plumbing. It stays outside the files the agent can see, so a real model has to inspect the broken source and choose an edit itself. #### Define the agent Next, let's open `src/app/fix/index.ts`. B4.run uses the application directory to discover routes. This file defines the `/fix` agent, and the `tools` directory beside it holds that agent's own functions. We define the agent by passing a configuration object to `agent()`: ```ts import { agent } from "@b4run/sdk" export default agent({ model: process.env.B4_CODE_FIXER_MODEL ?? "gpt-5-mini", recursionLimit: 60, description: "Repairs a failing test and verifies the change.", tools: { approve: ["exportForReview"] }, systemPrompt: `You fix a focused code defect in the workspace project. Read TASK.md and the verify-change skill. Reproduce the failure with npm test before editing. Inspect the relevant source and make the smallest correct fix. Use readFile, listDir, writeFile, and runBash. Use the commands documented in TASK.md for diagnostics; inspect files rather than inventing other shell commands. Never change tests, configuration, dependencies, or files outside the source paths specified in TASK.md. Run npm test after the change and verify the task's preservation requirements, not just its failing example. Explain what changed and what actually passed. Then call prepareReview({}) to independently verify and inspect the exact candidate. Call exportForReview({candidate}) with the complete candidate returned by prepareReview to request runtime approval. Calling this tool pauses BEFORE export and presents the human approval gate. Do not substitute a prose confirmation question for the tool call; the runtime owns approval. Do not claim success if a command failed or a test was not run.`, }) ``` [Source: `src/app/fix/index.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/index.ts#L1-L22). Let's break down the configuration: 1. `model` selects the language model. We default to `gpt-5-mini` and let an environment variable override it. 2. `recursionLimit` bounds the runtime's execution steps across repeated model and tool interactions. 3. `description` explains what this agent does. 4. `tools.approve` names a tool that requires a person's approval before execution. 5. `systemPrompt` supplies the instructions the model receives while working. The prompt names four general-purpose tools: `readFile`, `listDir`, `writeFile` and `runBash`. They let the agent inspect files, make an edit and run `npm test`. It also names two tools from our application, `prepareReview` and `exportForReview`, which handle review and export. The prompt tells the model to reproduce the failure before editing, but that is not a hard-coded sequence of calls. The model still chooses its next action. We'll check its submitted files in application code and measure its tool-call order in an evaluation later. Here is how the files are organized: ```text src/app/fix/ index.ts plan.md skills/verify-change/SKILL.md tools/prepareReview.ts tools/exportForReview.ts evals/repair.eval.ts evals/scoring.ts ``` The [plan](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/plan.md) lists six steps: read and reproduce, inspect the cause, edit source, test and explain, prepare the candidate, and request export approval. A **skill** holds instructions for a particular task. Here, `verify-change` explains how to check a repair. It is a Markdown file with a name, description and instructions: ```markdown --- name: verify-change description: Reproduce and verify a focused code repair. --- Read TASK.md. Run the documented failing command and inspect its output before editing. Follow the actual entry point through the source; don't stop at a handler test if the failure occurs at a parser or adapter boundary. Change only the permitted source files. Preserve tests and validation behavior. Run the documented tests again and check the task's preservation requirements. For validation changes, consider both accepted and rejected inputs with the documented diagnostics. Report the commands, actual results, and remaining failures. After verification, call prepareReview({}) and inspect its diff and checks. Pass the complete returned candidate to exportForReview({candidate}) for approval. The runtime pauses before exporting; a prose approval question does not raise that gate. ``` [Source: `src/app/fix/skills/verify-change/SKILL.md`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/skills/verify-change/SKILL.md#L1-L16). The instruction to follow the entry point matters, because calling a handler directly would miss a bug in the CLI parser that runs before it. #### Give the agent a workspace The agent needs a directory where it can read and write files. We'll call it the **workspace**. Each conversation starts with its own copy of the broken project. The model can make changes without touching the sample in our repository, and we can compare those changes with the original files. B4.run calls a conversation a **thread**. The workspace belongs to that thread, so later tool calls in the same conversation can see earlier edits. Let's declare the files that go into a new workspace: ```ts export function projectWorkspace(id: string): WorkspaceDefinition { const manifest = projectManifest(id) return { source: { directory: "sample/project", include: [...manifest.allowedSourcePaths, ...manifest.immutablePaths], files: [ { path: "TASK.md", file: "sample/task.md" }, { path: ".gitignore", text: "node_modules/\n" }, { path: "project.json", text: JSON.stringify({ id: manifest.id }) }, ], }, environmentLinks: [ { path: "node_modules", target: `/opt/fixtures/${manifest.id}/node_modules` }, ], baseline: "git", } } ``` [Source: `src/project/workspace.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/project/workspace.ts#L14-L31). Let's quickly review: - `source.directory` identifies the project to copy. - `source.include` selects the files from that project. The manifest is a JSON file listing the source files and other information about our sample. - `source.files` adds `TASK.md`, a Git ignore file and a small JSON file that identifies the project. - `environmentLinks` supplies a link to the prepared `node_modules` directory. - `baseline: "git"` initializes a Git baseline for inspecting changes. The [sample manifest](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/manifest.json) lists `src/cli.ts` as editable and the handler, test, package files and license as immutable. Those lists decide the captured source and what candidate inspection accepts. They do not make files in the editing workspace read-only. B4.run captures the starting bytes, initializes the workspace and handles reconnection and deletion. `baseline: "git"` lets the model inspect a diff while working. The review tool reads the original bytes through `ctx.workspace.readInitialFile`, so editing Git metadata can't change what the review compares against. ##### Configure the sandbox A **sandbox** is the controlled environment where file and shell operations run. The `dockerSandbox` provider runs them inside a Docker container. For example, `runBash` runs `npm test` there, not in our repository on the host. The app's `b4.config.ts` connects the provider to the workspace declaration. Here is its `sandbox` property: ```ts sandbox: { ...sandboxPolicy, provider: dockerSandbox({ scope: "code-fixer-local", image: sandboxImage }), workspace: projectWorkspace(task), }, ``` [Source: `b4.config.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/b4.config.ts#L11-L15). The `provider` selects Docker and the prepared image. Its `scope` identifies this application's Docker resources, so use a distinct scope for a separate installation. The `workspace` property supplies the declaration we just reviewed. We also provide a policy that controls network access and resources: ```ts export const sandboxPolicy = { network: { mode: "deny" as const }, env: { npm_config_cache: "/tmp/npm-cache", npm_config_update_notifier: "false" }, resources: { memoryMb: 1024, cpus: 1, timeoutMs: 120_000 }, } ``` [Source: `src/project/workspace.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/project/workspace.ts#L7-L12). Let's quickly review: - `network` denies network access during execution. - `env` configures npm to use a temporary cache and disables its update notifier. - `resources` limits execution to one CPU, 1 GiB of memory and a 120-second command timeout. The container can't download packages while the agent works, so we prepare the project's dependencies in the image first. We'll run that step when we start the app. The B4.run server on the host still calls the language model. Denying network access to the sandbox doesn't block that call, and the host's API key never reaches the container. The [full config](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/b4.config.ts) also allows reads of the prepared dependency directory and shell commands including `npm test`, Node diagnostics and Git inspection. Docker isolation, command permissions and rejection of out-of-scope source edits are separate controls, and the app needs all three. #### Create a tool to prepare the change At this point, the model may have edited `src/cli.ts` and run a passing test. We need to turn those edits into something a person can review. We'll call the proposed change a **candidate**. It contains the new file contents and information identifying the workspace and original source, as data our application can validate and pass around. The agent calls `prepareReview({})` to create that candidate. Here is the whole tool in `src/app/fix/tools/prepareReview.ts`: ```ts import type { B4ToolContext } from "@b4run/sdk" import { inspectCandidate } from "../../../review/inspect.js" import { renderReviewDiff } from "../../../review/patch.js" import { verifyChanges } from "../../../review/verifier.js" /** Verify the source repair independently and return the exact candidate for review. */ export default async function prepareReview(_input: Record, ctx: B4ToolContext) { // Compare the workspace with its captured source and reject edits outside the allowed files. const { manifest, candidate, baseline, initial } = await inspectCandidate(ctx) // Apply these exact changes in a fresh workspace and run both test suites. const verification = await verifyChanges(manifest.id, candidate.changes, ctx.signal, initial) if (!verification.passed) throw new Error("Independent verification failed") // Give the agent a readable diff and the exact candidate it must submit for approval. return { task: manifest.id, candidate, diff: renderReviewDiff(baseline, candidate.changes), verification, } } ``` [Source: `src/app/fix/tools/prepareReview.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/tools/prepareReview.ts#L1-L22). Let's walk through it: 1. `_input` is an empty input object. The function reads the workspace itself, so the model doesn't supply any file contents. 2. `ctx` is a `B4ToolContext` supplied by the runtime. It gives the tool access to the workspace, filesystem operations and cancellation signal. 3. `inspectCandidate(ctx)` compares the current files with the original files and returns the proposed changes. 4. `verifyChanges(...)` applies those changes to a separate workspace and runs the checks we'll discuss next. 5. If verification fails, the tool throws an error. Otherwise, it returns the candidate, a diff and the verification results. The model reads that object as the tool result, and a client can show it to the person reviewing the change. ##### Inspect the changed files The [candidate inspector](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/review/inspect.ts) first reads the original project identity and files from B4.run's captured source. It then reads the current workspace through this shared B4.run API: ```ts const { files: current } = await inspectWorkspace(ctx.fs, { signal: ctx.signal, maxEntries: 1000, maxFileBytes: 2 * 1024 * 1024, maxTotalBytes: 2 * 1024 * 1024, excludeRootDirectories: [".git"], expectedRootSymlinks: { node_modules: `/opt/fixtures/${manifest.id}/node_modules` }, }) ``` [Source: `src/review/inspect.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/review/inspect.ts#L35-L42). `inspectWorkspace` reads a bounded set of text files. The options limit the number of entries and the bytes it reads. We exclude the root Git directory and require `node_modules` to stay the prepared dependency link. The result's `files` property holds the current file contents. Inspection also rejects executable files, unexpected links, and binary or invalid UTF-8 content, which keeps the example focused on a small source change. The app's [`collectChanges` helper](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/review/patch.ts) compares that inventory with the original. It rejects added or deleted files, changes outside `src/cli.ts` and more than 1 MiB of changed-file content. An empty repair also fails. ##### Identify the change we are reviewing The candidate contains the complete changed-file contents, workspace ID and initial source digest. A **digest** is a value calculated from some data that tells us whether the data has changed. [`candidateDigest`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/review/candidate.ts) sorts the changed paths and hashes these fields with SHA-256, which pins down the bytes under review. The diff is generated from the same validated contents, with three lines of surrounding context. #### Verify the change with a fresh project copy We could stop after the agent runs a passing test, but that trusts the same directory it has been editing. Instead, we'll create another workspace from the original source and apply only the accepted candidate files. That gives us the original tests and configuration with the proposed source change. [`verifyChanges`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/review/verifier.ts) uses B4.run's `withWorkspace` to create a disposable Docker workspace from the captured source and image identity. It applies the candidate's changed files, then: 1. Inspects the workspace and runs the original visible test. 2. Inspects it again and rejects persistent file changes made during that test. 3. Copies the host-owned independent checks into the verifier. 4. Runs those checks and again rejects persistent file changes. The first suite is the visible test we saw earlier. The second checks behavior the repair must preserve, such as rejecting invalid arguments. These extra tests live outside the editing workspace and are copied into the verifier after the visible test runs. The app specifies the test files and assertion names in `sample/checks.json`: ```json { "visible": { "file": "test/cli.test.ts", "assertions": [ "documented dry-run flag reaches the handler" ] }, "independent": { "file": "checks/independent.test.ts", "assertions": [ "forwards cap and memory-level cwd", "rejects unknown and incomplete arguments", "dry-run preserves memory state and creates no files" ] } } ``` [Source: `sample/checks.json`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/checks.json#L1-L16). The [independent tests](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/sample/checks/independent.test.ts) make those names concrete: | Check | Input and required result | | --- | --- | | Forward arguments | `memory --cwd /workspace/app prune --cap 17` returns the expected action, directory, and numeric cap. | | Reject invalid arguments | Unknown flags, a missing cap, and a nonnumeric cap exit unsuccessfully. | | Preserve a dry run | Create `memory.json`, run consolidation with `--dry-run`, then require unchanged contents and no additional files. | The verifier reads structured Node test-runner events. Exit code zero isn't enough: every expected assertion must occur exactly once and pass, with no skip or todo. Output printed by submitted code is recorded as stdout and never counts as a passing assertion. Of course, these checks have limits. Both suites run in the same verifier container, and our file comparisons catch changes left behind after a suite finishes, not every transient change while code runs. The tests establish the behaviors listed above. They don't prove that any submitted program is correct. #### Require approval before exporting Now we have a proposed change and its check results, so we can ask a person to review it before the application exports it. Recall the `tools.approve` property in our agent configuration: ```ts tools: { approve: ["exportForReview"] }, ``` When the model requests `exportForReview({candidate})`, B4.run pauses before running the tool and returns a pending approval request. A client can display that request, let the person decide and send the decision back to resume the run. Why not just let the model ask? A model asking "May I export this?" in a text response doesn't stop a function from running. The runtime's approval handling is what pauses the tool call. After approval, the tool starts with these checks: ```ts const candidate = validateCandidate(input.candidate) const inspected = await inspectCandidate(ctx) if (candidate.receiptDigest !== inspected.candidate.receiptDigest) throw new Error("Workspace changed since review; prepare and approve a new candidate") const verification = await verifyChanges( inspected.manifest.id, candidate.changes, ctx.signal, inspected.initial, ) if (!verification.passed) throw new Error("Independent verification failed") ``` [Source: `src/app/fix/tools/exportForReview.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/tools/exportForReview.ts#L15-L25). Let's walk through what happens after approval: 1. `validateCandidate` checks the input structure and recomputes its digest. 2. `inspectCandidate` reads the workspace again. 3. We compare the new digest with the approved candidate's digest. If someone edited the workspace while approval was pending, the tool throws an error. 4. We verify the candidate again in a fresh workspace. Why check again? Imagine approving a diff, then exporting a different edit made a moment later. The approval would no longer describe the exported files. Comparing candidates makes that mismatch an error, and the agent must prepare the new change for approval. Only then does the [export tool](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/tools/exportForReview.ts) write `.b4/code-fixer/review-outbox/.json`. The receipt contains the task, candidate and diff. The write uses `flag: "wx"`, and an existing file is accepted only if its contents match exactly, so retrying the same approved export leaves the same receipt. Export creates no commit, push or pull request. #### Evaluate the agent Our tests check the repaired application. We also want to check how the agent went about the task. Did it reproduce the failure before editing? Did it request approval with the candidate it had prepared? An **evaluation**, or **eval**, runs an agent on an input and scores its behavior or result. Here, our scores come from tool calls, test results and the pending approval request. The eval definition lives beside the route: ```ts import { defineEval, gate } from "@b4run/evals" import { repairScorers } from "./scoring.js" export const taskInput = "Read TASK.md, reproduce the failure, repair the permitted source, verify the preservation requirements, call prepareReview, and then exportForReview with its exact candidate to request runtime approval." /** Run with b4 eval --live; test:sandbox also exercises the same gates offline. */ export default defineEval({ name: "code-fixer repair workflow", dataset: [{ name: "configured project", input: taskInput }], scorers: repairScorers, gate: gate.perScorer(), }) ``` [Source: `src/app/fix/evals/repair.eval.ts`](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/evals/repair.eval.ts#L1-L13). The [scoring implementation](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/src/app/fix/evals/scoring.ts) defines six criteria. In the definition above, `dataset` supplies the input to run, `scorers` supplies the functions that examine the result and `gate` decides whether the evaluation passes. Each scorer has a threshold of 1. With `gate.perScorer()`, every criterion must pass, so a successful test can't make up for a missing approval request: | Criterion | Evidence required | | --- | --- | | `reproduced` | A recognized `npm test` command failed before the first `writeFile`. | | `verified` | A recognized `npm test` command passed after the first `writeFile`. | | `visible` | The latest successful preparation reports passing visible tests. | | `independent` | That preparation reports passing independent checks. | | `scope` | Its validated candidate contains changed files. Preparation already enforced the allowed paths. | | `approval` | The run has one pending export-tool interrupt, no export result, and the export call carries the same validated candidate digest as preparation. | The first two scores establish command results and order. They don't establish a correct diagnosis, and the post-edit score doesn't require testing after the last edit. Candidate verification tests the submitted files separately. The approval score establishes a pause before export, and the Docker integration tests exercise acceptance and denial. Run these from the repository root after setup: ```sh pnpm --filter @b4-example/code-fixer-server test pnpm --filter @b4-example/code-fixer-server test:sandbox pnpm --filter @b4-example/code-fixer-server eval --live ``` Each command answers a different question: - `test` runs the application's unit tests. - `test:sandbox` runs Docker integration tests. They replay the known repair through the real tools and check verification, approval and denial without paid model calls. - `eval --live` asks the configured model to solve the task and scores the run. This uses your model API key. Replay is useful when changing the application: it checks that the tools and approval handling still work with a known repair. Live evaluation measures whether the model can find a repair on its own, which replay can't tell us. Offline `b4 eval` requires recorded fixtures. This example uses Docker tests for replay because candidate identities change between runs. #### Run it and inspect the result Use Node 24+, pnpm, Git and a running Docker daemon. In a checkout of the repository version linked above, run from the root: ```sh pnpm install --frozen-lockfile pnpm build pnpm --filter @b4-example/code-fixer-server sandbox:prepare pnpm --filter @b4-example/code-fixer-server check pnpm --filter @b4-example/code-fixer-server dev --port 3001 ``` Set `OPENAI_API_KEY` on the host or in the server's local `.env` before starting the development server. Rerun image preparation only when dependencies change. In another terminal, create a thread and start the agent: ```sh THREAD=$(curl -fsS http://127.0.0.1:3001/threads \ -H 'Content-Type: application/json' -d '{}' | \ node -e 'let s=""; for await (const c of process.stdin) s+=c; console.log(JSON.parse(s).thread_id)') curl -N --fail-with-body http://127.0.0.1:3001/threads/$THREAD/runs/stream \ -H 'Content-Type: application/json' \ -d '{"route":"/fix#agent","input":{"messages":[{"role":"user","content":"Read TASK.md, reproduce and repair the defect, verify the change, and request approval to export the exact candidate."}]}}' ``` The first request creates a thread and saves its ID in the `THREAD` shell variable. The second request starts a run of `/fix#agent` in that thread. Its `input.messages` array contains the user's request. The `-N` option lets curl display the streamed response as it arrives. You'll see tool calls, their results and the prepared diff. When the agent requests export, the runtime pauses. Read the pending approval request with: ```sh curl -fsS http://127.0.0.1:3001/threads/$THREAD/pending_interrupts ``` Inspect the operation. For the export request, copy its `interruptId` into the resume body below to approve once. Use `"deny"` instead of `"once"` to decline. If multiple requests are pending, the resume array must answer each exactly once. ```sh curl -N --fail-with-body http://127.0.0.1:3001/threads/$THREAD/resume \ -H 'Content-Type: application/json' \ -d '{"route":"/fix#agent","resume":[{"interruptId":"PASTE_INTERRUPT_ID","status":"resolved","payload":"once"}]}' ``` An approved export writes its receipt under the server app's `.b4/code-fixer/review-outbox/`. When finished, delete the thread and managed workspace: ```sh curl --fail-with-body -X DELETE http://127.0.0.1:3001/threads/$THREAD ``` These commands use the ordinary B4.run HTTP runtime, not an evaluation runner. The [example README](https://github.com/cacheplane/b4run/blob/b4084553287939c2cd9ded48b006803a2671a955/examples/code-fixer/server/README.md) also covers built-server execution and adapting the sample. ##### Installing from a blueprint You may also see `b4 add code-fixer` in the documentation. That command prints an installation guide for a coding agent to apply. You (or your coding agent) still install and start the application. When this post came out, the [published guide](https://b4.run/blueprints/code-fixer.md) used an earlier example with B4.run 0.8.32. It now installs the example on B4.run 0.10.0. To follow the code in this article, use the linked repository example and the checkout instructions above. #### What belongs in the framework? Building an example like this is a good way to find work that belongs in the framework. Every application that edits files needs to know where the files live, what they contained at the start and how to release its resources when finished. B4.run handles source capture and the workspace lifecycle. It also handles the approval pause and resume. The code-fixer moved another reusable operation into B4.run: `inspectWorkspace`. The app used to maintain its own file walker and snapshot program. Now it passes limits and expected links to the shared inspection function. You can see that change in [PR #670](https://github.com/cacheplane/b4run/pull/670). The application still decides which source files can change, what a correct repair must do and what gets exported. Those decisions depend on the project we are repairing, so they stay visible in the example's TypeScript code. #### Conclusion In conclusion, we've followed a code-fixing agent from a failing CLI command to a change ready for review. Along the way, we defined the agent's instructions, supplied file and shell tools, configured a Docker workspace, checked the proposed source independently and required approval before export. If you want to adapt the example, start with a small project and a failure you can reproduce. Write down which files may change and which behavior must remain unchanged. Then turn those requirements into tests the review tool can run. The model chooses how to investigate the problem. Your application decides what it can do and what evidence you need before accepting its work. --- ### B4.run 0.8: The framework around the agent Source: https://b4.run/blog/b4-0-8-framework-around-the-agent Published: 2026-08-09 > Learn what 0.5 through 0.8 added around the agent: evals, tool permissions, sandboxes, delegation, typed memory, AG-UI clients and production builds. An agent starts with a model and a few tools. Then the application around it raises more questions. How do we test its behavior? Which actions require approval? What should it remember? How does a browser display its progress? B4.run's route conventions give those pieces a place to live. The releases from 0.5 through 0.8.21 added evaluations, tool permissions, long-term memory and production runtime options. Let's walk through them with a support assistant that looks up an order, researches a policy and prepares a reply. **Version note:** This post rounds up everything through 0.8.21. The examples use the current `@b4run` package names, which arrived in 0.8.27, so follow the linked upgrade guide for a current installation. Those names don't exist at 0.8.21. #### Goals We want our support application to: 1. Check its behavior against repeatable scenarios. 2. Limit tool access and request approval where needed. 3. Delegate a specific task to a specialist agent. 4. Retain useful information across conversations. 5. Connect to a browser and run outside the development server. You can add each of these when you need it. A small agent doesn't need them all on day one. #### Evaluate agent behavior A unit test can tell us that `lookupOrder()` returns the expected order. It can't tell us whether a model will choose that tool when a customer asks about a delivery. An **evaluation**, or **eval**, runs a set of scenarios and scores their results. The scenarios form a **dataset**. A **scorer** checks one aspect of the result, such as required text, a tool call or a budget. A **gate** decides whether the collected scores meet our chosen threshold. ##### Start with a small eval An eval lives beside its route under `evals/*.eval.ts`. Here is a small example for a `/support` route: ```ts // src/app/support/evals/greeting.eval.ts import { contains, defineEval } from "@b4run/evals" import { script } from "@b4run/testing" export default defineEval({ name: "support greeting", dataset: [ { name: "offers help", input: "hello", fixtures: script().user("hello").replies("Hi! How can I help?"), }, ], scorers: [contains("help", { threshold: 1 })], threshold: 1, }) ``` A few things to note: 1. The file's location associates it with the support route. 2. `dataset` contains one scenario with a user input and a scripted model response. 3. `contains()` checks whether the final text contains `help`. 4. The outer `threshold` sets the mean score required for the eval to pass. A greeting alone isn't much of a quality bar for a support application. Add scenarios for the work the agent should do, including missing orders, ambiguous requests and service failures. ##### Replay and live runs By default, `b4 eval` replays model fixtures. Our tools, prompts, capabilities and state run, while the model response comes from the fixture. This makes the scenario repeatable in CI. Of course, a passing replay can't show that a live model will make the same decision, because we supplied its response. Use `b4 eval --live` to measure real model behavior, and `--record` to capture responses for later replay. Both make provider calls and cost money. Scorers run after the agent finishes. If a scorer uses another model to judge the answer, that model call also needs a fixture or mock for an offline CI run. For smaller checks, `@b4run/testing` includes harnesses for agents, tools, middleware, workspaces and protocol behavior. See [Evals](https://b4.run/docs/evals) and [Testing agents](https://b4.run/docs/testing-agents) for complete examples. #### Control tool execution Our support agent might look up orders on its own, while a refund needs a person's approval. There are three separate decisions here: | Question | Framework mechanism | | --- | --- | | Should the model have access to this tool? | Tool scoping with allow and deny rules | | Should this particular call execute? | Argument constraints and approval rules | | Where will the approved operation run? | The host environment or a configured sandbox | Let's look at the first two using a route configuration: ```ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "Help customers with their orders and refund requests.", tools: { deny: ["deleteAccount"], approve: ["issueRefund"], }, }) ``` The `deny` rule removes `deleteAccount` from the tools available to this agent. The `approve` rule pauses an `issueRefund` call for a decision before it runs. Both names refer to tools the application implements. The rules only govern them. Argument constraints can inspect the proposed input too. For example, a refund above a certain amount could need extra review. The constraint can allow the call, reject it with a reason or request approval. The tool still needs to verify the authenticated customer's access to the order. Model instructions and route parameters can't stand in for authorization in application code. ##### Isolate filesystem and shell work A workspace gives file operations a configured root directory. Permission checks govern shell commands and access outside that workspace. Still, an approved operation that runs on the application host runs with the host's access. A **sandbox** provides a separate execution environment for filesystem and shell work. B4.run has Docker and Kubernetes providers. The Docker provider uses a non-root user, drops Linux capabilities, prevents privilege escalation and makes the root filesystem read-only by default. Network restrictions depend on the selected mode. Deny-network blocks egress, while allow mode leaves egress open and does not enforce a host denylist. Kubernetes network enforcement depends on a policy-capable network plugin. Sandbox lifecycle matters too. `release()` drops warm compute but keeps the thread's volume and workspace. `destroy()` removes both the compute and the persisted workspace data, so a conversation that continues later loses its files. The [Access Control](https://b4.run/docs/access-control) and [Sandbox](https://b4.run/docs/sandbox) guides show how to configure each layer. #### Delegate a task A **subagent** is an agent that handles a bounded task for another agent. Our support assistant could delegate policy research, then use the findings to prepare its response. The parent can register children by name and declare when each may run: ```ts import { agent } from "@b4run/sdk" import researcher from "./subagents/researcher/index.js" import writer from "./subagents/writer/index.js" export default agent({ model: "gpt-5-mini", systemPrompt: "Coordinate research and writing for support requests.", subagents: { researcher, writer }, delegation: { default: "deny", rules: { researcher: { action: "allow" }, writer: { action: "approve", reason: "Approve draft generation." }, }, }, }) ``` Let's quickly review: - First, we import two child descriptors. Each file exports its own `agent()` declaration with the instructions and tools for its task. - Next, the keys in `subagents` name the children the parent can request. The delegation rules use the same names. - Finally, `researcher` is allowed to run, while dispatching `writer` requires approval. That approval covers starting the writer. Publishing or sending its output needs its own application controls. The policy applies to this parent only. Allowing its research child doesn't authorize every child the researcher might call. Delegated runs keep separate saved state, and an approval requested by a child returns through the root conversation so the client can resume it there. The current API requires a keyed `subagents` object. Array registration and old scalar resume bodies need migration. The [Subagents guide](https://b4.run/docs/subagents) covers the full configuration and resume behavior. #### Remember information across conversations Conversation state continues the current thread. Long-term memory keeps records for a later thread. For example, a support assistant may need to remember a reviewed preference about how an organization receives reports. A route declares the shape of those records in `memory.ts`: ```ts // 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(), }), }) ``` Let's quickly review: - `kind: "semantic"` describes factual records. - `scope` places the collection in the workspace and route namespace. It keeps records apart but doesn't authenticate tenants. - `identity` names the fields that describe the same fact, such as an organization and its preferred report format. - `schema` declares the data written with each record. Type generation carries this shape into the agent's `remember` tool, and runtime validation checks writes again. ##### Review before recall Agent-authored writes become candidates by default. A candidate is stored for review but left out of normal recall until approved. You can inspect and approve candidates through the CLI or the local Memory Inspector. Why? An agent can misunderstand a conversation, and its interpretation shouldn't become trusted context for later answers until someone checks it. The default SQLite store ranks recall by keywords, recency and confidence. Hybrid recall can also use vectors, which find related meaning when the wording differs. `@b4run/memory-pgvector` provides a shared Postgres option for the long-term collection. ##### Record events separately Episodic memory records what happened during a run. The runtime recorder is optional and has retention limits. Two commands process older records: - `b4 memory consolidate` compacts older episodes. - `b4 memory reflect` derives insights for review. Nothing runs these commands for you, so schedule them based on your application's needs and model cost. The [Memory guide](https://b4.run/docs/memory) explains record types, review and recall. The [Inspector guide](https://b4.run/docs/inspector) covers the local development interface. #### Connect a browser client A browser needs more than the agent's final answer. It may need to show text as it arrives, display tool activity and ask the user to approve an operation. **AG-UI** is an event protocol for that interaction. B4.run maps runtime activity into AG-UI events, preserving tool-call ids so the browser can associate a result with the call that produced it. The reference web client uses CopilotKit. The thread-scoped Agent Protocol API remains available for direct clients. A typical interaction is: 1. Create a thread for the conversation. 2. Start a run under that thread and read its result or stream. 3. If execution pauses for approval, display the pending requests. 4. Resume the root thread with decisions addressed to every pending interrupt id. The ids matter when more than one operation is waiting. A single "yes" can't safely say which requests it answers. Both client paths reach the same route code and configured runtime stores. See [AG-UI and Web Clients](https://b4.run/docs/ag-ui) and [Dev Server](https://b4.run/docs/dev-server) for the request formats. #### Deploy the application `b4 build` can generate a Node server and Dockerfile. `b4 start` runs the B4.run production runtime directly. Both support the HTTP APIs, middleware, tool rules, permissions and configured sandbox. If the host has an ephemeral filesystem, or several instances need the same saved records, `@b4run/postgres-storage` stores checkpoints, threads and permission grants in Postgres. These are runtime records. Typed long-term memory uses its own store. Note, cancellation and the one-run-per-thread guard stay process-local, so an application running several replicas needs to account for that. The optional Hono target supports a narrower edge environment. It leaves out features that need a local filesystem or shell, and it is tested on local workerd, not a live Cloudflare deployment. [B4.run at the edge](https://b4.run/blog/b4-at-the-edge) explains that design and its limits. #### Upgrade an existing application Use Node.js 24 or later, and pin every direct `@b4run/*` dependency to the same release. The framework is pre-1.0, and 0.8 patch releases have included breaking API changes. To upgrade: 1. Read the [Upgrading guide](https://b4.run/docs/upgrading) and the intervening release notes. 2. Update all direct B4.run dependencies together to the chosen published version. 3. Run `npx b4 verify` to check the application. 4. Run the route tests and evals. 5. Exercise the deployment target and approval flows the application actually uses. For a new project, follow [Getting Started](https://b4.run/docs/getting-started). The starter gives you a working application with matching package versions. #### Conclusion An agent becomes useful inside an application when we can test its behavior, control its actions, keep the right context and connect it to users. These releases add more of those pieces around the route. Start with the part your application needs next: a repeatable eval, an approval before a tool call or a memory collection you can review. Then test the whole interaction so you know what the framework handles and what's still up to your application code. --- ### B4.run at the edge: The Hono target Source: https://b4.run/blog/b4-at-the-edge Published: 2026-08-09 > B4.run's opt-in Hono target emits a Node-free Cloudflare Workers entry with request-scoped Postgres stores and a tested workerd subset. The first request to the edge build worked. The second request hung. That failure showed something a successful bundle couldn't: a database connection created during one request was being reused during another. The code looked fine for a long-running Node server, but its lifetime was wrong for the edge runtime. Let's use that bug to walk through B4.run's Hono build target. We'll look at the generated files, how a request uses storage and which parts of an agent application fit this environment. **Version note:** This post covers the edge work through 0.8.21. The configuration examples use the later `@b4run` package names, first published at 0.8.27, so use the [current edge deployment guide](https://b4.run/docs/deployment/edge) to install. The runtime evidence here comes from local workerd tests, not a live Cloudflare deployment. #### What is an edge runtime? For this post, the target is Cloudflare Workers, which runs application code in workerd. An **isolate** is the JavaScript environment where the Worker handles requests. It can handle more than one request, but it isn't a Node process, so the full filesystem and process APIs aren't there. B4.run's HTTP core accepts a web-standard `Request` and produces a `Response`. Hono supplies the web application around that handler. The build target generates the route imports, storage setup and Hono entry that connect the pieces. Of course, standard `Request` and `Response` types don't make every dependency or feature edge-compatible. We still need to know what the application imports and how it uses resources. #### Goals The edge target needs to: 1. Produce modules that the deployment bundler can inspect. 2. Run the framework's request handler without Node compatibility enabled. 3. Store conversation state outside the Worker. 4. Create and clean up database resources within the request that uses them. 5. Report unsupported framework capabilities before deployment. The [0.8 overview](https://b4.run/blog/b4-0-8-framework-around-the-agent) covers the wider framework. Here we focus on what changes when the runtime moves to Workers. #### Select the Hono target Add `hono` to the build targets in `b4.config.ts`: `b4.config.ts`: ```ts import { config } from "@b4run/cli" export default config({ build: { targets: ["node", "langsmith", "hono"] }, }) ``` Setting `build.targets` replaces the default target list. Here we keep the Node and LangSmith outputs and add Hono. If you only need some targets, list just those. The build generates these files: | File | What it does | | --- | --- | | `.b4/build/modules.edge.mjs` | Lists the route, tool, state and middleware modules as static imports, with generated schemas included. | | `.b4/build/stores.mjs` | Creates the request's database pool and the stores that share it. | | `.b4/build/app.mjs` | Exports a Hono app that forwards requests to B4.run's handler. | | `wrangler.toml` | Configures the Worker entry. The root scaffold is created only if one doesn't exist yet. | These are generated source files. Wrangler, Cloudflare's development and deployment tool, bundles them with their dependencies for the Worker. ##### When do we rebuild? If you change code inside a route or tool the manifest already imports, Wrangler bundles the updated module. Changes to discovery or generated configuration need another B4.run build. For example, rebuild after adding or renaming a tool, changing its input type, selecting another provider or changing configuration that is written into the generated entry. The serializable configuration is included in `app.mjs`. Supply credentials through the host's secret bindings so they stay out of generated source. #### Make imports explicit A Node application can use APIs such as `node:fs` and globals such as `Buffer`. A Worker without Node compatibility can't assume those APIs exist. B4.run checks two things in its edge output: 1. The generated entry and reachable framework runtime have no `node:` import dependencies. 2. The framework's own code avoids bare Node-only globals such as `process`, `Buffer` or `require`. The second check matters because `process.env` needs no import, so checking import paths alone would miss it. Provider configuration uses a runtime environment helper. On Node it reads the process environment. In workerd it reads bindings supplied by the generated entry. Either way, the model layer finds credentials and settings such as `OPENAI_BASE_URL`. ##### Why static provider imports? The build also finds the model providers the application uses and generates imports that name their packages. Wrangler follows those imports to decide what belongs in the bundle. If the build can't inspect a route or map its model to a provider, it fails with an error. Guessing a package name during a request would leave the bundler without a reliable dependency list. These checks cover B4.run's generated code and framework runtime. Your own tools can still import a Node-only dependency, so bundle and run the real application to catch those. #### Follow a request through storage The Worker needs somewhere to save the conversation and execution state. This target uses Postgres through the Neon serverless driver's WebSocket `Pool`. Three stores share the pool: - The **checkpointer** saves execution state so a run can continue. - The **thread store** retains conversation records. - The **permission store** retains permission grants. These are runtime stores. The typed long-term memory collection declared by `memory.ts` lives elsewhere. ##### The connection-lifetime bug The first implementation created a pool at module scope. In a long-running server, a pool usually lives across requests so connections can be reused. Here is what happened in workerd: 1. Request one created a connection and used it successfully. 2. The connection returned to the pool when the database operation finished. 3. Request two received that idle connection. 4. The socket still belonged to request one's completed I/O context. 5. Request two waited until workerd cancelled the operation. A later request could open a fresh connection and work again, which produced the alternating success and hang. One successful request could never prove the design correct. We needed to enter the same isolate from several request contexts. ##### Create a pool for each request The generated target now creates a pool for each incoming request and gives that pool to its three stores. Here is the sequence: 1. Receive the HTTP request and its environment bindings. 2. Create the request's Postgres pool and stores. 3. Run the runtime request with those stores. 4. Wait for the response body and any run started by the request to finish. 5. Dispose the request's pool. The fourth step is easy to miss. A streaming response may keep producing data after the handler returns a `Response` object, and run bookkeeping can outlive that return too. Closing the pool right away would remove a resource still in use. ##### Initialize the database schema Once database migrations finish, the generated target remembers that in a boolean at module scope. Later requests create stores with `assumeMigrated`. Unlike the connection pool, this boolean holds a completed result. It holds no socket or other request-bound I/O. Two initial requests can both reach the migration step before the boolean is set. The migration transactions use Postgres advisory locks to coordinate that case. The tested connection path uses a local Postgres database behind a local WebSocket proxy. It uses the driver's WebSocket pool. The driver's HTTP query function, a raw TCP `pg` connection, Neon Cloud and Cloudflare Hyperdrive are all untested. #### Check the application's capabilities Some agent features depend on a filesystem, shell, container or object that can't be serialized into an edge build. The capability check reports `B4_E1005` for unsupported configurations. As of 0.8.21, the edge work described here rejected: - Execution sandboxes. - Filesystem or shell backends. - An app-level `workspace/` directory. - Route skills that load instructions from disk. - Typed long-term memory declared in `memory.ts`. - Custom live instances of checkpointer, thread, permission or memory stores. **Update (September 2026):** As of 0.8.30, route skills, `plan.md` and route `memory.md` work on edge targets. `b4 build` bundles them into the edge manifest, so skills no longer fail the check. A later release also rejects tool-output offloading. See [Edge and Hono](https://b4.run/docs/deployment/edge#what-the-edge-cannot-serve) for the current list. The check reports all detected violations together and points to the configuration or file involved. That beats finding a missing feature during a request. In 0.8.21 the check had limits. Route marker files such as `plan.md` and `memory.md` could stay in the tree, but their filesystem readers were inactive. So a passing build check didn't mean file-backed planning or memory worked at the edge. The 0.8.30 bundling closed that gap. For an agent that edits files or runs commands, the [Node and Docker target](https://b4.run/docs/deployment/node) is a more direct starting point. #### Test more than the bundle The workerd test builds an OpenAI agent fixture and starts the generated output with `wrangler dev --local`. It uses the emitted Wrangler configuration without `nodejs_compat`, a local model fixture and the local Postgres WebSocket path. The test then sends four sequential AG-UI turns through the same isolate. AG-UI is the event protocol the browser-client adapter uses. Why four turns? Each request must reload conversation state saved by earlier requests, and each one is a fresh request context. That is what exposed the connection-lifetime bug. The test also checks database rows over a separate connection. It verifies that thread, checkpoint and write records were persisted. The text returned to the client isn't enough on its own. Here is what that evidence covers: | Check | What we learn | | --- | --- | | Bundle and load the generated entry | This fixture and its provider imports can load in workerd. | | Call `/healthz` | The runtime can respond to its health route. | | Complete four AG-UI turns | This conversation can continue across request contexts. | | Inspect persisted records | The expected runtime data reaches Postgres. | Here's what the workerd lane leaves untested: - Agent Protocol flows, tool calls, subagents and human approvals. - Other model providers and a live Cloudflare deployment. - Production connection, subrequest, upload, bundle and startup CPU limits. - Remote database latency and cross-isolate cold starts. Running the same output on Vercel, Deno or Bun would need its own testing. If you're considering this target, those are good next tests. Start with the features and traffic pattern your application actually uses. #### Try the target locally Follow the [edge deployment guide](https://b4.run/docs/deployment/edge) to install matching current B4.run packages, the Neon driver, Hono and Wrangler. Configure the database and model-provider bindings it describes. With the target selected, check and build the application: ```bash npx b4 check npx b4 build --clean npx wrangler dev --local ``` Send more than one request. Continue a conversation, exercise each tool and check that state survives between requests. A successful health response alone won't tell you whether the application works. Then test a deployment with the database, credentials and limits of the host you intend to use. The local workerd test can't stand in for that step. #### Conclusion The Hono target runs B4.run's request handler in a web-standard environment with an explicit list of supported features. The most useful lesson came from the second request. A valid bundle and a successful first response didn't tell us our storage lifetime was correct. Following the connection through several requests did. If your agent fits the target's constraints, take the same approach: inspect the generated files, know which resources belong to a request and test the whole conversation in the environment where it will run. --- ### Eve and B4.run: Choosing an agent framework Source: https://b4.run/blog/eve-validates-the-shape Published: 2026-06-18 > Eve and B4.run both organize agents as files. Here is what that structure gives us, and how to compare the client, runtime and deployment choices. On June 17, Vercel introduced [eve](https://vercel.com/changelog/introducing-eve-an-open-source-agent-framework), an open-source agent framework. Its use of files and directories caught my attention: an agent keeps its model declaration, instructions and tools in its folder. I care about that idea in B4.run too. I want a developer to open an agent application and see where its behavior comes from. Of course, a similar file structure doesn't make two frameworks interchangeable. Let's look at what the structure helps us understand, then at the runtime and integration choices behind it. **Updated September 2026:** The comparison below uses the linked documentation and B4.run's current conventions. It isn't a complete feature comparison of the June releases. #### Goals If you're evaluating an agent framework, I suggest starting with three questions: 1. Can my team understand and test the application code? 2. How will users interact with the agent? 3. Where will the application run, and which services will it need? These questions are familiar from web development, and adding a model doesn't make them go away. #### What does a file-based agent give us? An agent combines instructions with a model and tools. The model requests a tool call, and the runtime executes application code to perform the requested action. Imagine a support assistant that looks up orders and checks refund policies. As a reviewer, I want to find: - The instructions given to the assistant. - The function that looks up an order. - The policy used to approve a refund. - The tests for successful and failed requests. Files and folders give those pieces predictable locations, and we can review a change with the tools we already use for TypeScript projects. Vercel's launch example uses `agent.ts` for the model, `instructions.md` for instructions, and folders for tools and subagents. The [launch announcement](https://vercel.com/changelog/introducing-eve-an-open-source-agent-framework) shows that layout alongside its deployment workflow. Here is the corresponding idea in B4.run: ```text src/app/support/ index.ts state.ts tools/ lookupOrder.ts skills/ refund-policy/ SKILL.md subagents/ research/ index.ts ``` Let's quickly review: - First, `index.ts` defines the parent agent. - Next, `tools/` holds the route-specific functions it can call. - Then, the skill holds instructions the agent loads when needed. - Finally, the research child handles a bounded part of the work. I like this because it gives us a starting point for answering "what can this agent do?" We still need to check shared tools, configuration and permissions, but we can begin with the route. #### Compare the integration points Here are a few decisions you'll run into while building an application: | Decision | eve | B4.run | | --- | --- | --- | | Where do instructions live? | `instructions.md` beside the agent declaration | `systemPrompt` in the route descriptor, plus file conventions for prompt memory and skills | | How is an agent organized? | An agent directory | A route under `src/app/` | | How do tools declare input? | The documented tool example uses an explicit input schema | Type generation reads the TypeScript function signature | | How do users reach it? | Channel integrations, including Slack, Discord, and GitHub | HTTP APIs and an AG-UI adapter for clients. Application code connects other channels. | | Which deployment path should I examine? | The documented Vercel deployment and its services | The Node/Docker, LangSmith, and other target-specific B4.run deployment guides | The eve details here come from its [announcement](https://vercel.com/changelog/introducing-eve-an-open-source-agent-framework) and [documentation](https://vercel.com/docs/eve). B4.run's [Tools](https://b4.run/docs/tools), [AG-UI](https://b4.run/docs/ag-ui), and [Deployment](https://b4.run/docs/deployment) guides explain its corresponding APIs. A table is only a starting point. Let's unpack each decision. #### Tool types In B4.run, a tool's TypeScript input type generates the schema shown to the model. For example: ```ts export const description = "Format a greeting for a customer." export default async ({ name }: { readonly name: string }) => { return `Hello, ${name}!` } ``` The function accepts a `name` string. Type generation derives that required input from the signature, so we don't write a second schema for the same argument. This helps when your team already leans on TypeScript while refactoring, because there's one less copy of the input to keep in sync. The tool still has to validate external data and check authorization at runtime. #### Client integrations A **channel** is how a person or system interacts with an agent. A browser chat, a Slack conversation and a scheduled job can all be entry points into an application. B4.run exposes thread-based HTTP APIs. A thread holds a conversation and its saved state. A client starts a run under that thread, receives the result or stream, and can continue the conversation later. For browser clients, AG-UI describes events such as new text, a tool call, or an approval request. B4.run's adapter translates runtime activity into those events. The reference web client uses CopilotKit. If you need another channel, plan for the application work around the HTTP call: authenticating the sender, mapping conversations to threads, handling retries and displaying approval requests. A framework's built-in integration can save time here if it fits those requirements. #### Runtime and storage The **runtime** executes the agent and its tools. Storage retains the conversation and execution state across requests. B4.run builds on LangGraph.js. Its runtime keeps checkpoints, threads and permission grants in SQLite or a configured Postgres backend. Long-term memory is a separate store, so choosing Postgres for conversation state leaves the agent's memory collection to its own configuration. Vercel's [eve documentation](https://vercel.com/docs/eve) describes how its deployment uses Vercel Workflows, Sandbox, AI Gateway and related platform services. If you evaluate that path, include those services in your deployment and operations plan. For either framework, I'd test the same small scenario: start a conversation, stop the application, then continue it. Next, test an approval that pauses a run and resumes after a user decision. Those exercises tell you more about the fit than a "durable execution" checkbox. #### Tools that execute code If an agent can run shell commands, look at where those commands execute. An approval rule decides whether an operation may proceed. A sandbox decides the environment it runs in. B4.run offers optional sandbox providers, including Docker and Kubernetes. Their network and filesystem behavior depends on the provider and its configuration. For example, Docker's deny-network mode removes network access, while its allow mode leaves egress open and does not enforce a denylist. The [Sandbox guide](https://b4.run/docs/sandbox) documents those limits. Evaluate the environment your application will actually use. A container, a hosted sandbox and an approved command on the application host behave differently, even when the agent sees a similarly named tool. #### How I would evaluate the fit I'd build one small application in each candidate framework and follow it through development, testing and deployment: 1. Add one tool that calls an existing service. 2. Write a repeatable test for a successful response and a service failure. 3. Connect the client or channel the team plans to use. 4. Add a human approval before an operation that needs review. 5. Deploy to the intended host and test continuation after a restart. Then compare the application code and operational setup each version needs, along with what your team already knows about LangGraph, Vercel, TypeScript and the services involved. That gives you a decision based on a working example. #### Conclusion I'm glad to see more agent frameworks give application code a predictable structure. It makes the instructions, tools and execution easier to find and discuss. For B4.run, I value the combination of filesystem routes, TypeScript-derived tool inputs and the LangGraph.js ecosystem. Your choice also depends on the clients, services and deployment environment your team needs. Start with the [B4.run Getting Started guide](https://b4.run/docs/getting-started) and the [eve documentation](https://vercel.com/docs/eve), then try the same small use case in both. A familiar file tree is a good start. Running your application through its whole workflow will tell you much more. --- ### B4.run 0.4: Start with an agent test Source: https://b4.run/blog/b4-0-4-release Published: 2026-06-02 > Learn how the offline agent test added to the starter in 0.4 works, and what a scripted model response can verify. Testing an agent can feel strange at first. We're used to calling a function with an input and asserting its output. A model can give different answers to the same question, and calling it in every test adds network calls and cost. The 0.4 release added an offline agent test to the starter application. A new project included the test harness, Vitest and a fixture describing the model's response. Let's look at why that's useful and how the test works. **Version note:** The code below uses the current `@b4run` package names and basic starter, which arrived after 0.4. Use the current starter to follow along. #### Goals We want to: 1. Run an agent route without calling a model provider. 2. Supply a predictable model response. 3. Assert that the application returns the expected final message. 4. Understand which behavior this test verifies and which behavior needs a live model. #### What are we testing? An agent run includes more than the model request. The application loads a route, builds its instructions, makes tools available, processes model responses and maintains state. A **fixture** supplies a known response in place of the live model, and the rest of the application runs with that response. If you have mocked an HTTP API in a TypeScript test, this should sound familiar. We control the external dependency so that a failed test points to a repeatable scenario. B4.run's agent harness intercepts the model's HTTP endpoint with a local mock. Tools, prompts, capabilities and state still run. Only the model response is replaced. #### Create a project Use Node.js 24 or later and select the basic template: ```bash npm create b4-app@latest my-agents -- --template basic cd my-agents npm install npm test ``` The basic template includes the `/hello` route and `test/agent.test.ts`. Its fixture-backed test runs without a provider API key. You'll only need provider configuration when you run the agent against a real model. #### Read the test Here is the test included in the current basic starter: ```ts import { fileURLToPath } from "node:url" import { afterAll, it } from "vitest" import { createAgentHarness, expectFinalMessage, script } from "@b4run/testing" const appRoot = fileURLToPath(new URL("..", import.meta.url)) const h = await createAgentHarness({ appRoot, route: "/hello#agent" }) afterAll(() => h.close()) it("greets by name", async () => { const run = await h.run({ input: "Say hello to Ada", fixtures: script().user("Say hello to Ada").replies("Hello, Ada!"), }) expectFinalMessage(run).toContain("Hello") }, 60_000) ``` Let's break this down: ##### 1. Locate the application `fileURLToPath(new URL("..", import.meta.url))` resolves the application root relative to the test file. The harness uses that directory to find the application's configuration and routes, so the test doesn't depend on a hard-coded path on one developer's computer. ##### 2. Create the harness `createAgentHarness()` prepares the application for a test run. The `route` value selects `/hello`, and `#agent` identifies the agent entry. The harness starts the local model mock, runs type generation, and resolves the route. We register `h.close()` with Vitest's `afterAll()` hook so the test releases those resources when it finishes. ##### 3. Describe the model response The `script()` builder describes the exchange: ```ts script().user("Say hello to Ada").replies("Hello, Ada!") ``` The user message selects the fixture, and `replies()` supplies the model's response. When the route makes its model request, the local mock returns this text without contacting the real provider. ##### 4. Assert the result Finally, `expectFinalMessage(run).toContain("Hello")` checks the final message returned by the run. The `60_000` argument is Vitest's timeout in milliseconds. It gives the scenario up to a minute to finish. #### Test a tool call As the application grows, we can script a tool request as well. For a route with an `applyFilter` tool, a fixture could look like this: ```ts script() .user("Filter open items") .callsTool("applyFilter", { status: "open" }) .replies("Found 2 open items.") ``` First, the mock returns a request to call `applyFilter`. The runtime executes the actual tool. On the next model turn, the mock supplies the final message. This tests how the application handles a known tool call. We can assert the tool name and arguments with `expectToolCalled`, and test the tool's own output separately. The [Agent Test Harness guide](https://b4.run/docs/testing-agents) includes a complete example. #### What does a passing test tell us? The greeting test tells us the application can run the selected route with the supplied fixture and return the expected message. A tool scenario checks the wiring and behavior around a scripted call. It can't tell us whether a live model will choose the correct tool, because we supplied that choice in the fixture. For the same reason, it can't show that a changed prompt improves the model's answers. Use fixture-backed tests for repeatable application behavior. Use live runs and representative evaluation datasets to investigate model behavior. #### Conclusion In my opinion, the best part of this release was giving a new application a test from day one. We can run the route, control the model response and make an assertion before adding our own features. Start with that small test. As you add tools and state, add scenarios for the behavior you expect and the failures you need to handle. The [Testing agents guide](https://b4.run/docs/testing-agents) and [Evals guide](https://b4.run/docs/evals) explain how to extend that coverage. --- ### The App Router for AI Agents Source: https://b4.run/blog/app-router-for-ai-agents Published: 2026-05-19 > Learn how B4.run uses file-system routes, typed tools, planning, skills and subagents to organize a LangGraph.js agent application. Learn how B4.run uses filesystem routes to organize a TypeScript agent application. If you have used the Next.js App Router, you already know the value of a predictable file tree. You find a page, its layout and related code by following the route through the project. B4.run applies the same idea to agents. A route has an entry point, tools, state and optional features such as planning or delegation. Let's build up that structure with a customer support example. #### Goals By the end of this post, we'll know how to: 1. Define an agent route with a dynamic parameter. 2. Add a tool using a TypeScript function. 3. Declare the route's state and locate its tests. 4. Add planning, skills, and a research subagent. You don't need any previous experience building agents. In this example, an **agent** is a model that can request calls to functions we provide. Those functions are its **tools**, and the runtime executes them on its behalf. #### Start with the route Suppose we're building an assistant that helps customers check the status of an order. Each organization using the application is a tenant. Our route starts with one file: ```text src/app/support/[tenant]/index.ts ``` The folder path gives us the route id `/support/[tenant]`. The square brackets declare the `tenant` parameter. Let's define the agent: ```ts // src/app/support/[tenant]/index.ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You help customers of {tenant} check their orders. " + "Use lookupOrder to check an order before answering about its status.", }) ``` First, we select a model. Next, we provide instructions through `systemPrompt`. The `{tenant}` placeholder fills in the current tenant value. The prompt refers to a `lookupOrder` tool, which we still need to implement. A prompt can ask the model to use a tool, but the application supplies the code and enforces access to the data. #### Add a route-local tool Create a `tools/` folder beside `index.ts`: ```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, }) if (!res.ok) { throw new Error(`Order lookup failed with status ${res.status}`) } return (await res.json()) as { readonly status: string } } ``` The service URL is a placeholder. Replace it with your order service and enforce authentication and tenant ownership there. A route parameter is just data the caller sent, so it can't prove who the caller is. A few things to note about the function: 1. `description` tells the model what the tool does. 2. `input.orderId` is the argument supplied for the requested call. B4.run derives the model-facing input schema from this TypeScript type. 3. `ctx` is runtime-provided context. Its `signal` lets the HTTP request observe cancellation. 4. We check the HTTP status before returning a result. 5. The returned object becomes the tool result available to the model. Of course, the `as` expression only tells TypeScript what to expect. Nothing checks the JSON at runtime, so a production tool should validate any response it can't trust. When a customer asks about an order, the agent requests `lookupOrder` with an order id. The runtime calls the function and sends the result back to the model, which explains the status to the customer. #### Declare state State is the data carried through a route's execution. We can declare application fields in a `state.ts` file: ```ts // src/app/support/[tenant]/state.ts import { z } from "zod" export default z.object({ tenant: z.string(), orderId: z.string().optional(), }) ``` Here we use Zod to declare a required `tenant` string and an optional `orderId` string. B4.run accepts Standard Schema-compatible declarations and generates route state types from this file and the route path. Why declare the shape? Other code can use the generated types instead of repeating the interface. If we change the state, TypeScript points us to the code that depends on it. The tool input and route state serve different purposes. The tool input describes one function call. The route state describes data available during the route's execution. Adding `orderId` to state won't trigger an order lookup by itself. #### Keep tests with the route A route scenario test can live at: ```text src/app/support/[tenant]/run.test.ts ``` For our support route, useful scenarios include an existing order, a missing order and an order-service failure. Model responses vary between calls. The testing tools support fixture-backed runs, where recorded or scripted model responses let us repeat a scenario without a live model call. The [Testing agents guide](https://b4.run/docs/testing-agents) explains how to test tool calls and final responses. Keeping the test near the route makes it easy to update both when the behavior changes. #### Plan a longer request Checking one order may take one tool call. Investigating several orders and preparing a summary takes more steps. A `plan.md` file can provide an initial checklist: ```md - [ ] Review the customer request - [ ] Check order history - [ ] Write a concise response ``` B4.run gives the agent a `writeTodos` tool to update the list, a `todos` state channel to retain it, and `plan_update` events that a client can display. The checklist organizes the work and shows progress. The model can still skip or botch a step, so we still need tools, tests and application rules to check the result. #### Add detailed instructions with a skill A skill is a Markdown file containing instructions the agent can load when needed. For example: ```text src/app/support/[tenant]/skills/refund-policy/SKILL.md ``` B4.run tells the agent which skills are available and exposes `readSkill({ name })`. The agent loads the refund instructions when a customer asks about a refund, so the whole policy doesn't ride along in every prompt. A skill explains a procedure. If a refund requires authorization, enforce that in the application and its approval rules. #### Delegate research to a subagent A subagent is another agent that handles a specific piece of work. Our support agent might ask a research specialist to find the relevant policy before writing an answer. Place the specialist under `subagents/`: ```ts // src/app/support/[tenant]/subagents/research/index.ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", description: "Find policy documentation relevant to a support request.", systemPrompt: "Research the request and return concise findings with sources.", }) ``` The `description` helps the parent decide when to delegate. The `systemPrompt` instructs the child during its own run. To let it read documents, we add a document-search tool under `subagents/research/tools/`. The parent gets a `task({ subagent, input })` tool for calling its children. It can request work from `research` and use the findings in its response. Each child has its own tool configuration and doesn't inherit the parent's route-local tools. To restrict delegation, use the parent's `delegation` policy. The [Subagents guide](https://b4.run/docs/subagents) explains the defaults and named rules. #### Review the application structure With these additions, our route looks like this: ```text src/ app/ support/ [tenant]/ index.ts state.ts plan.md run.test.ts tools/ lookupOrder.ts skills/ refund-policy/ SKILL.md subagents/ research/ index.ts tools/ searchDocs.ts ``` We can follow a customer request through the files: the parent receives it, a tool retrieves order data and a specialist researches a policy. State, instructions and tests sit beside the behavior they describe. The route convention also supports other entry types. Export a `workflow` for a deterministic async function, a `graph` for a LangGraph graph or a `chain` for a LangChain runnable. Each `index.ts` declares one entry shape, so you can pick the execution model and keep the same project layout. #### Conclusion In conclusion, the App Router idea gives an agent application a structure we can navigate with plain TypeScript files and folders. We started with one agent, added a tool, then gave state, tests, planning and delegation a place to live. Start with only the pieces your application needs. To create a small project with the basic template: ```bash npm create b4-app@latest my-agents -- --template basic cd my-agents npm install ``` Use Node.js 24 or later, set your provider key as the generated environment instructions describe, and run `npm run dev`. The [Getting Started guide](https://b4.run/docs/getting-started) covers the first run, and the [Routes guide](https://b4.run/docs/routes) explains the complete route conventions. --- ### Why we built B4.run Source: https://b4.run/blog/why-we-built-b4 Published: 2026-05-12 > B4.run is a TypeScript framework for LangGraph.js agents. Here is the problem it solves and its conventions: file-system routes, local tools and typegen. I built B4.run to make TypeScript agent applications easier to understand and maintain. I wanted to open a project, find the agent I needed to change, and see its tools and state right beside it. If you've built an Express or Angular application, you've probably asked your team: where should this code live? A consistent answer makes the next feature easier to build and the next pull request easier to review. Agent applications need that consistency too. Let's look at the problem, then walk through the approach I took with B4.run. #### What is an agent application? For this discussion, an agent is a model that can request actions through tools. A tool is application code that performs an operation, such as looking up an order or reading a document. Here is roughly how it works: 1. A user sends a request to the application. 2. The runtime sends the request, instructions, and available tool definitions to the model. 3. The model responds with text or requests a tool call. 4. If it requests a tool, the runtime executes the function and returns its result to the model. 5. The model uses that result to continue working or answer the user. LangGraph.js provides the runtime for this loop. B4.run adds conventions and tooling for the TypeScript application around it. #### The problem Calling a model is only part of building an agent application. We also need to decide where tools live, how to validate their inputs, how to test the application, and how to run it in production. Without shared conventions, those decisions pile up. A tool has a function in one file, a schema in another and an entry in a registry somewhere else. Adding a feature means remembering which files describe the same thing. The problem gets worse as the application grows. If a support agent has ten tools, I want to find those tools by opening the support route. If I change a tool's input, I want TypeScript to help me find the code affected by that change. This is the work I wanted the framework to handle. #### Goals My goals for B4.run are to: 1. Give each agent or workflow a predictable location in the project. 2. Keep route-specific tools and state beside the route that uses them. 3. Derive tool input schemas from TypeScript function signatures. 4. Provide a local development server and generated deployment output. 5. Add planning, memory, and delegation as the application needs them. Let's start with the files a developer writes. #### A route is a folder A route lives under `src/app/`. Here is a small example: ```text src/ app/ (public)/ hello/ [tenant]/ index.ts state.ts tools/ greet.ts ``` A few things to note: - `index.ts` is the entry point for the route. - `[tenant]` declares a dynamic route parameter, such as the organization using the application. - `(public)` groups files without becoming part of the route id. The folder name grants no access by itself. Authentication is still up to your application code. - `tools/` contains tools specific to this route. - `state.ts` can declare the data the route accepts and maintains. The route id is `/hello/[tenant]`. If you have used a filesystem router in a web framework, this should look familiar. #### Define the agent Next, let's define the route entry: ```ts // src/app/(public)/hello/[tenant]/index.ts import { agent } from "@b4run/sdk" export default agent({ model: "gpt-5-mini", systemPrompt: "You are a helpful assistant for the {tenant} organization.", }) ``` Let's quickly review: - First, we import the `agent()` function from the SDK. It describes the agent, and the runtime uses that description when it executes the route. - Then, `model` selects the model. - Finally, `systemPrompt` gives the agent its instructions, with `{tenant}` filled in from the route context. To let the agent do something in our application, we need a tool. #### Add a tool A tool can be an ordinary 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}!` } ``` Let's break this down: 1. The `description` tells the model what the tool is for, so it can decide when to request it. 2. The default export is the function that the runtime will execute. 3. The input type declares a required `name` string. B4.run uses this signature to generate the schema provided to the model. 4. The returned string becomes the tool result, which the model can use in its response. The model requests the call and your TypeScript function does the work. In a real application, the same pattern could call a service, query a database or create a draft for review. During type generation, B4.run also writes `.b4/b4.generated.d.ts`. This gives us route and tool types without maintaining another copy of the input shape. #### From source files to a running application The CLI uses the route tree in both development and build workflows. `b4 dev` starts the local runtime and reloads application code as it changes. HTTP clients create a thread, then start a run under that thread. A **thread** holds the conversation and its saved state. A **run** is one execution of the agent within that conversation. For the LangSmith deployment target, `b4 build`: 1. Discovers the routes under `src/app/`. 2. Generates types for route state and tools. 3. Writes `.b4/build/langgraph.json` and the route entry modules it references. The generated agent entry imports our descriptor and connects the discovered tools to a LangGraph graph. A route such as `/hello/[tenant]` is addressed as `/hello/[tenant]#agent` in that deployment configuration. We keep editing TypeScript source files, and the build produces what the deployment target needs. The [Deployment guide](https://b4.run/docs/deployment) covers the available targets and their differences. #### Add capabilities as you need them A larger application may need the agent to plan work, consult instructions, or ask another agent for help. B4.run gives each of these a place in the project. ##### Memory An app's `workspace/AGENTS.md` provides persistent instructions. B4.run reads the file into the agent's prompt under a `# Memory` heading on each model turn, subject to its size limit. For example, the file might describe how the application organizes its documents. Updating the file changes what the agent sees on its next turn. This is file-based prompt memory. Typed long-term records are a separate feature, described in the [Memory guide](https://b4.run/docs/memory). ##### Planning A route's `plan.md` can seed a checklist for the agent. B4.run adds a `writeTodos` tool and stores the current todos in state. It also emits `plan_update` events so a client can display progress. ##### Skills A skill is a longer set of instructions stored at `skills//SKILL.md` under the route. B4.run lists the available skills and provides `readSkill({ name })` so the agent can load the full instructions when needed. A support application could use a skill to spell out the steps for investigating a billing question. The instructions stay readable in a Markdown file. ##### Subagents A subagent handles a bounded task for a parent agent. For example, a support agent could ask a research agent to find relevant policy documents before drafting an answer. Children can live under the route's `subagents/` directory or be registered in the descriptor's keyed `subagents` object. Each child has its own instructions and tool configuration. The [Subagents guide](https://b4.run/docs/subagents) explains registration and delegation permissions. #### Getting started For a small example, pick the basic template, which is now the default. The larger research application needs `--template research`: ```bash npm create b4-app@latest my-agents -- --template basic cd my-agents npm install ``` Use Node.js 24 or later. Follow the generated project's environment instructions to configure your model provider key, then start the development server: ```bash npm run dev ``` The [Getting Started guide](https://b4.run/docs/getting-started) walks through the starter and its first run. If you already have a LangGraph.js application, the [migration guide](https://b4.run/docs/migrating-from-langgraph) maps its existing pieces to B4.run's conventions. #### Conclusion I like frameworks that make the next change easier to understand. With B4.run, a route has a folder, its tools are typed functions, and the build derives the wiring from those files. A small application may be well served by LangGraph.js on its own. As you add routes, tools and capabilities, shared conventions cut down the setup a team has to maintain. That's why I built B4.run: so we spend more time on what an agent should do, in a structure that shows how it does it. ---