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 shapeCopy link to section: 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<typeof state> gives you the TypeScript type:
import { z } from "zod"
export default z.object({
tenant: z.string().default(""),
/** Accumulated context from tool call results */
context: z.string().default(""),
})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.
Dynamic segments are route paramsCopy link to section: 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:
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.
Custom reducersCopy link to section: 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:
export default (current: string, incoming: string) =>
current ? `${current}\n${incoming}` : incomingThe 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.
RulesCopy link to section: Rules
- 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. - 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. - 3
Outputs accumulate
B4.run returns the workflow's result as-is. To keep the incoming state, return
{ ...state, newField }.
State flowCopy link to section: State flow
Here's how state gets in and out at each entry point:
b4 runreads JSON from stdin and writes JSON to stdout.runs/waitandruns/stream(underb4 dev, the Node runtime, and Hono builds) take B4.run's{ route, input }HTTP body, whererouteis<routeId>#<kind>. The Node runtime isb4 startor.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.