Embed the Runtime
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 embeddedCopy link to section: Choose standalone or embedded
- Use
b4 startor the generated Node server when B4.run can own the listener. - Use
serveRuntimewhen your code should create and close that listener. - Use
createRuntimeFetchHandlerwhen an existing server, worker, test harness, or Hono app owns the transport and dependency lifetimes. - Use
servewhen 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 serveRuntimeCopy link to section: Own a Node server with serveRuntime
serveRuntime and loadStaticModules are stable exports from the package root:
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 runtimeCopy link to section: 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:
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.
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 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 runtimeCopy link to section: 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:
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<Request, Env>()
let handlerPromise: ReturnType<typeof createRuntimeFetchHandler> | 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_ROOTmust exactly match the rooted namespace baked intomodules.edge.mjs, currently/<app-directory-basename>. 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 HonoCopy link to section: 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:
import { Hono } from "hono"
import b4App from "./.b4/build/app.mjs"
const app = new Hono()
app.use("*", authenticateAndAuthorize)
app.route("/", b4App)
export default appThe 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 surfaceCopy link to section: 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 precedenceCopy link to section: 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 pathsCopy link to section: Endpoint paths
Embedding changes who runs the listener. The API layout stays put. The handler dispatches these rooted paths:
/healthzfor liveness (never builds request stores) and/readyzfor dependency readiness (builds them and probes each durable store, as Production Topology explains)./threadsand/threads/:thread_id/...for Agent Protocol management and execution./agui/:routeIdfor 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 shutdownCopy link to section: 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:
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<PostgresPermissionsStoreOptions, "mode" | "config">
>
// 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. Ininteractivemode, that call hydrates persisted runtime grants into the synchronous match cache. Other modes skip those grants on purpose. - B4.run calls neither
ready()norload()for arequestStoresoverride. It also skips layering the sibling B4.run permission config or theB4_PERMISSIONS_MODEoverride onto a custom store. Your application owns the effective mode, static allow/deny policy, hydration, refresh, and disposal. disposeruns after the response and any run it started have settled.- If construction or hydration throws, the runtime has nothing to dispose, so the
catchcloses 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:
await handler.close()
await pool.end()That way active runs and response bodies can't write through an ended pool.
AuthenticationCopy link to section: 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 before you expose an embedded handler.
Test the embedded hostCopy link to section: Test the embedded host
Test the assembled host as well as the bare handler:
- Verify unauthenticated requests fail for
/healthz,/readyz,/threads,/agui, and/memorysurfaces according to your outer policy. - Create a thread, run a route, read state, and confirm cross-tenant access is rejected.
- Exercise an Agent Protocol viewer disconnect and explicit cancellation through the real proxy.
- Exercise AG-UI disconnect behavior and unbuffered SSE delivery.
- Make request-store construction fail after opening a resource and verify the factory closes it.
- Invoke shutdown with an active run, await
close(), then close caller-owned pools. - For Hono, drive the composed
app.route("/", b4App)path with the deployment's real environment binding.
See Edge and Hono for generated edge artifacts and target constraints, or Node and Docker for the standalone production server.