Edge and Hono
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.
Fit checkCopy link to section: 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/serverlessfits your database environment. - The host can pass the same incoming
Requestobject 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 HonoCopy link to section: Select Hono
Hono is opt-in. Setting build.targets replaces the Node and LangSmith defaults, so list every target you still want:
import { config } from "@b4run/cli"
export default config({
build: { targets: ["node", "hono"] },
})Then build normally:
b4 check
b4 build
b4 verifyEmitted artifactsCopy link to section: Emitted artifacts
The Hono target writes:
.b4/build/modules.edge.mjsis 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.mjsis the request-scoped factory for the Postgres checkpointer, thread store and permission store..b4/build/app.mjsis a Hono catch-all aroundcreateRuntimeFetchHandler.wrangler.tomlis a scaffold whosemainis.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/fetchCopy link to section: 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:
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 /<app-directory-basename>. 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 routerCopy link to section: Compose the Hono router
B4.run endpoints are rooted, so mount the generated or hand-built B4.run app at /:
import { Hono } from "hono"
import { b4App } from "./b4-edge.mjs"
const app = new Hono()
app.use("*", authenticateAndAuthorize)
app.route("/", b4App)
export default appKeep 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-requestCopy link to section: 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 productionCopy link to section: 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:
B4_PG_SCHEMA=$VERCEL_ENVA 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_PROXYCopy link to section: 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/serverlessto dial in place of the database host. The format ishost:port, optionally prefixed withws://orwss://.wss://keeps TLS on the WebSocket, and a bare value orws://turns it off. The driver appends its own/v1?address=path, so a value with a path, a query, or another scheme such ashttp://is rejected by name before any pool opens. Empty means unset.B4_PG_DRIVER(vercelonly) isneonorpg, and overrides the host detection below.pgis refused on thehonotarget, 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, 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:
B4_PG_DRIVER, when set.- Otherwise
@neondatabase/serverlesswhenB4_PG_WS_PROXYis set, so a working proxy setup keeps working. - Otherwise
@neondatabase/serverlesswhen theDATABASE_URLhost ends in.neon.tech, and a pooledpgconnection 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 locallyCopy link to section: 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:
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:
{"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:
{"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:<proxy-port>. 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 serveCopy link to section: 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 bundledCopy link to section: Skills, plan.md, and route memory.md are bundled
Route skills, a route plan.md 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 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 artifactCopy link to section: 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:
wrangler secret put DATABASE_URLThe vercel targetCopy link to section: The vercel target
vercel emits the same web runtime as hono, bundled into one self-contained Vercel Build Output API v3 function, with the same capability gate. Select it the same way (build: { targets: ["node", "vercel"] }). The Vercel guide covers it in full. The build writes:
.vercel/output/config.jsonholds the Build Output routes,version: 3..vercel/output/functions/b4.func/is the runtime function:.vc-config.json(nodejs24.x,supportsResponseStreaming: true) and a bundledindex.mjs.vercel.jsonis 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 routesCopy link to section: 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:
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.diris copied into.vercel/output/static/, with symlinks dereferenced. It's relative to the app root.static.spaFallbacknames a file insidestatic.dir. It's served, as the last route, for every path nothing else claims.functions.<name>is bundled with esbuild fromentryintofunctions/<name>.func/index.mjs, with a.vc-config.jsonholdingruntime(defaultnodejs24.x),maxDurationandsupportsResponseStreaming. Each name is one path segment.routescome first, before the filesystem phase. Vercel route keys pass through, but ahandleentry is rejected because the build manages the phases.functionNameis the runtime function's name. It defaults tob4.
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.
Deploy checklistCopy link to section: Deploy checklist
- Run
b4 checkand resolve everyB4_E1005violation. - Inspect
modules.edge.mjs,stores.mjs,app.mjs, and the selectedwrangler.toml. - Declare the emitted runtime dependencies and every discovered model-provider package.
- Configure
DATABASE_URLand model credentials as runtime secrets or bindings, andB4_PG_SCHEMAwhere preview and production share a database. - Put outer authentication and tenant authorization around all rooted B4.run paths.
- If you use generated stores, validate permission mode, static policy, hydration and refresh.
- Drive Agent Protocol, AG-UI streaming, cancellation, and request-store disposal on the deployed host.
What is proven, and what is notCopy link to section: 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.