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 can also check or replace an AG-UI run's final assistant message before the client sees it.

File locationCopy link to section: File location

Define middleware as a default-exported function in src/middleware.ts (or middleware.ts at the app root):

src/middleware.ts
import { allow, defineMiddleware, reject } from "@b4run/sdk"
 
export default defineMiddleware(async (req) => {
  if (!req.headers["x-api-key"]) {
    return reject(401, { error: "Missing x-api-key" })
  }
  return allow()
})

If no middleware file is present, every request is allowed. B4.run probes four paths, in this order, and the first one that exists is the one it loads: src/middleware.ts, src/middleware.js, middleware.ts, middleware.js. A later candidate is never a fallback for an earlier one that fails to load. See When middleware fails to load.

APICopy link to section: API

defineMiddleware(middleware)Copy link to section: defineMiddleware(middleware)

A type-preserving identity helper for editor inference. It accepts either the handler function shown above or a lifecycle definition:

ts
type MiddlewareHandler = (
  req: MiddlewareRequest,
) => Promise<MiddlewareResult> | MiddlewareResult
 
interface MiddlewareDefinition {
  setup?: (ctx: { readonly appRoot: string }) => Promise<void> | void
  dispose?: () => Promise<void> | void
  handle: MiddlewareHandler
  after?: MiddlewareAfterHook
}
 
type B4Middleware = MiddlewareHandler | MiddlewareDefinition

MiddlewareRequestCopy link to section: MiddlewareRequest

Every middleware receives this argument. All values are pre-parsed:

FieldShapeNotes
bodyunknown (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.
headersReadonly<Record<string, string>>Lowercase keys (Node convention). Multi-value headers joined with , .
principalB4Principal | undefinedThe caller src/auth.ts resolved for this request; undefined without an auth file or for an anonymous request. Authorize against this, not headers.
paramsReadonly<Record<string, string>>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).
routeIdstringE.g. "/hello/[tenant]".
assistantIdstringE.g. "/hello/[tenant]#agent".
methodstringHTTP method. "POST" on every gated endpoint except /pending_interrupts, which is a "GET".
urlstringPath + 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. 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?)Copy link to section: 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?)Copy link to section: 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 below.

ts
return allow()
return allow({ userId, plan: "pro" })

Context flow to toolsCopy link to section: Context flow to tools

Who is calling doesn't need middleware: src/auth.ts resolves it once per request and every tool reads it as ctx.principal. Use allow({ ... }) for anything else a request should carry to its tools, such as a per-request database handle or a feature flag. Whatever you pass is delivered to every tool call for that request via the second argument:

export default defineMiddleware(async (req) => {
  const userId = await verifyJwt(req.headers.authorization)
  return allow({ userId })
})

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 hooksCopy link to section: 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 and nothing closes it, or write a memoised getter that resets itself on rejection. Use the object form instead:

src/middleware.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)Copy link to section: 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()Copy link to section: 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 disposeCopy link to section: Which targets call dispose

TargetShutdown path
b4 devThe 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 imageSIGTERM/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.
langsmithNo middleware is materialized.

Validating the final assistant messageCopy link to section: 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
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)Copy link to section: 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:

FieldShapeNotes
finalMessagestringThe 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.
contextReadonly<Record<string, unknown>> | undefinedWhatever this request's handle returned through allow(context).
messagesreadonly { role, content, id? }[]The conversation the client sent with the run.
threadId, runIdstringThe AG-UI run identity.
routeId, assistantIdstringSame 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 seesCopy link to section: 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 modelCopy link to section: 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 runsCopy link to section: 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:

Endpointreq.methodreq.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 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.

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, and the build warns when src/middleware.ts exists. Put identity in src/auth.ts, which that target compiles, or authenticate on the platform.

When middleware fails to loadCopy link to section: 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:

text
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 middlewareCopy link to section: 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.