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:

text
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.

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:

src/app/(public)/hello/[tenant]/reducers/context.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.

RulesCopy link to section: Rules

  1. 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. 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. 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 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 <routeId>#<kind>. 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.