Configuration Reference
September 8, 2026 · View on GitHub
b4.config.ts is the typed application contract at the app root. The CLI
uses it with package.json to find a B4.run app, so the file is required even
though every B4Config field is optional. Node processes load the module once;
b4 dev picks up an edit by restarting its child runtime. Filesystem-free
runtimes receive an already-constructed config object instead of loading this
file.
Use b4 verify to check the config, route tree, Node version,
provider credentials, and configured sandbox preflight before serving the app.
Complete annotated example
This file is copyable as written. Uncomment the instance-valued fields only after importing or constructing the corresponding implementation.
import { config } from "@b4run/core"
export default config({
appDir: "src/app",
// A configured sandbox overrides these backends for sandboxed threads.
backends: {
// filesystem: customFilesystem,
// exec: customExec,
},
permissions: {
mode: "interactive",
allow: { bash: ["ls", "cat"], tool: ["deployPreview"] },
deny: { bash: ["rm -rf"] },
// store: sharedPermissionsStore,
},
// Defaults are local SQLite/file stores under .b4/.
// checkpointer: sharedCheckpointer,
// threadsStore: sharedThreadsStore,
// Used by b4 dev/inspect; b4 start receives env from its host.
env: "./.env",
toolOutput: {
offloadThresholdChars: 40_000,
previewLines: 10,
maxBytes: 268_435_456,
ttlMs: 10_800_000,
gcThrottleMs: 10_000,
noOffloadTools: [],
},
summarization: {
enabled: false,
maxTokens: 12_000,
keepRecentTurns: 6,
// model: "gpt-5-mini",
// tokenCounter: async (text) => countTokens(text),
// summarize: async ({ messages, model, previousSummary, signal }) => "...",
},
build: { targets: ["node", "langsmith"] },
// Cross-origin access. Omit and the runtime sends no `Access-Control-*`
// header at all, so a browser on another origin cannot call it — the
// default, because opening a server to other origins is a deployment
// decision. `localhost` and `127.0.0.1` are different origins to a browser.
server: {
cors: { origins: ["https://app.example.com"] },
},
// sandbox: {
// provider: dockerSandbox({ image: "node:24-slim" }),
// network: { mode: "allow", denylist: ["169.254.169.254"] },
// // network: { mode: "deny", allowlist: ["api.openai.com"] },
// env: { NODE_ENV: "production" },
// resources: { memoryMb: 1024, cpus: 1, timeoutMs: 120_000, diskGb: 10 },
// security: {
// dropAllCapabilities: true,
// noNewPrivileges: true,
// readOnlyRootFilesystem: true,
// runAsNonRoot: true,
// // runAsNonRoot: { uid: 1000, gid: 1000 },
// pidsLimit: 512,
// },
// idleTimeoutMs: 600_000,
// },
memory: {
// enabled: true, // inert compatibility field; memory.ts opts a route in
// store: sharedMemoryStore,
writes: "candidate",
indexMaxEntries: 20,
recall: {
weights: { relevance: 0.6, recency: 0.3, confidence: 0.1 },
recencyHalfLifeMs: 1_209_600_000,
candidatePool: 256,
},
// vector: {
// embedder,
// weights: { keyword: 1, vector: 1 },
// rrfK: 60,
// vectorK: 64,
// recencyWeight: 0.3,
// confidenceWeight: 0.1,
// },
episodes: {
enabled: false,
ttlMs: 2_592_000_000,
cap: 500,
includeFailedRuns: true,
embed: false,
},
distill: {
model: "gpt-5-mini",
provider: "openai",
maxBatches: 5,
consolidate: {
olderThanMs: 604_800_000,
minBatchSize: 5,
maxBatchSize: 50,
// ttlMs: 2_592_000_000,
sourceTtlMs: 604_800_000,
},
reflect: { minNewRecords: 10, maxRecords: 100, writes: "candidate" },
},
// resolveScope: ({ routePath, appRoot }) => ({ route: routePath, app: appRoot }),
},
})
Key reference
appDir
appDir?: string
Default: "src/app". The path is relative to the app root and selects the tree
where B4.run discovers route index.ts files. An authored value replaces the
default; there is no environment override. Keep it inside the app root. See
Routes for the discovered layout.
backends
backends?: {
filesystem?: FilesystemBackend
exec?: ExecBackend
}
Defaults: the local filesystem and local child-process executor. For a sandboxed thread, the sandbox handle's filesystem and executor take precedence; otherwise configured backends beat the local defaults. Live backend objects cannot cross a static edge build boundary. See Workspace Filesystem for backend composition and path-jail behavior.
permissions
permissions?: {
mode?: "interactive" | "non-interactive" | "bypass"
allow?: Readonly<Record<string, readonly string[]>>
deny?: Readonly<Record<string, readonly string[]>>
store?: PermissionsStore
}
Without a custom store, the defaults are mode: "interactive", empty config
maps, and .b4/permissions.json for persisted interactive decisions.
B4_PERMISSIONS_MODE overrides permissions.mode; a deny match beats an
allow match. The reserved tool and subagent keys use exact matching, while
resource paths, bash commands, and memory scopes use prefix matching.
Config allow and deny maps stay in memory and do not seed the runtime permissions store.
An interactive Always decision is what persists a runtime
allow entry. See Permissions for mode behavior and the
interrupt/resume lifecycle.
permissions.store
A supplied PermissionsStore replaces the file-backed store and is loaded
before use. The custom store owns its mode and allow/deny policy: sibling
permissions.mode, allow, deny, and B4_PERMISSIONS_MODE are not applied
again. In an embedded runtime, a boot-supplied store takes precedence over this
config field.
checkpointer
checkpointer?: BaseCheckpointSaver
Default: SQLite at .b4/checkpoints.sqlite. A boot-supplied checkpointer
takes precedence, then this field, then the default. Checkpoints hold graph
state; they are not thread metadata. See Persistence and
Tenancy before sharing or deleting state across replicas.
threadsStore
threadsStore?: ThreadsStore
Default: SQLite at .b4/threads.sqlite. A boot-supplied thread store takes
precedence, then this field, then the default. It stores Agent Protocol thread
metadata, not graph checkpoints; configure both stores when replicas share
threads. See Persistence and Tenancy for the storage
boundary and deletion order.
env
env?: string
Default: "./.env", relative to the app root. For b4 dev and b4 inspect, precedence is --env-file, then config.env, then the default.
b4 verify checks the resolved file without loading it into the current
process. config.env is loaded by b4 dev and b4 inspect, not by b4 start; production variables must come from the shell or hosting platform.
The LangSmith artifact independently chooses .env.example when present and
.env otherwise. Its JSON records an env-file path, not variable names, and it
does not use config.env. See Deployment Options for each
target's environment contract.
toolOutput
toolOutput?: {
offloadThresholdChars?: number
previewLines?: number
maxBytes?: number
ttlMs?: number
gcThrottleMs?: number
noOffloadTools?: readonly string[]
}
On a Node runtime with a workspace filesystem, defaults are 40_000 characters,
10 preview lines, 268_435_456 bytes (256 MB), a 10_800_000 ms (3 hour)
TTL, and a 10_000 ms GC throttle. noOffloadTools defaults to [] and is
unioned with the always-exempt readFile and listDir. Authored values replace
their individual defaults.
Offloaded data lives under workspace/tool-outputs/; retention is a context
budget, not durable storage. A non-empty block is incompatible with the
filesystem-free Hono target (B4_E1005). See Context
Management for retrieval and cleanup behavior.
summarization
summarization?: {
enabled?: boolean
maxTokens?: number
keepRecentTurns?: number
model?: string
tokenCounter?: (text: string) => number | Promise<number>
summarize?: (args: {
messages: readonly unknown[]
model: string
previousSummary?: string
signal: AbortSignal
}) => Promise<string>
}
Defaults: disabled, maxTokens: 12_000, keepRecentTurns: 6, the route model,
a lazy gpt-tokenizer o200k_base counter, and the built-in one-call
summarizer. Each authored field replaces its default. Enabling summarization
without either a route model or summarization.model cannot create a
summarizer. See Context Management for when history
is compressed and what remains verbatim.
build
build?: { targets?: readonly string[] }
Default: ["node", "langsmith"]. An authored targets array replaces that
list; it does not add to it. Supported values are "node", "langsmith", and
the opt-in "hono" subset. Choose only targets whose runtime can materialize
the configured capabilities. See Deployment Options for
the artifact and compatibility matrix.
sandbox
sandbox?: {
provider: SandboxProvider
network?:
| { mode: "allow"; denylist?: readonly string[] }
| { mode: "deny"; allowlist?: readonly string[] }
env?: Readonly<Record<string, string>>
resources?: { memoryMb?: number; cpus?: number; timeoutMs?: number; diskGb?: number }
security?: {
dropAllCapabilities?: boolean
noNewPrivileges?: boolean
readOnlyRootFilesystem?: boolean
runAsNonRoot?: boolean | { uid: number; gid: number }
pidsLimit?: number
}
idleTimeoutMs?: number
}
Default: no sandbox, so workspace operations use the selected host backends.
When configured, provider is required; the manager defaults to an allow-mode
network policy that denies 169.254.169.254, injects no host environment, and
releases idle compute after 600_000 ms. Resource and security enforcement is
provider-specific; Docker's security fields default hardened. diskGb matters
to PVC-backed providers and Docker ignores it. At runtime, an
injected sandboxManager takes precedence over config.sandbox; config is
used only when the host does not supply a manager. See Execution
Sandbox before relaxing isolation or network policy.
memory
memory?: {
enabled?: boolean
store?: MemoryStoreLike
writes?: "off" | "candidate" | "auto" | "ask"
indexMaxEntries?: number
recall?: {
weights?: { relevance?: number; recency?: number; confidence?: number }
recencyHalfLifeMs?: number
candidatePool?: number
}
vector?: {
embedder: Embedder
weights?: { keyword?: number; vector?: number }
rrfK?: number
vectorK?: number
recencyWeight?: number
confidenceWeight?: number
}
episodes?: {
enabled?: boolean
ttlMs?: number
cap?: number
includeFailedRuns?: boolean
embed?: boolean
}
distill?: {
model?: string
provider?: ModelProviderId
maxBatches?: number
consolidate?: {
olderThanMs?: number
minBatchSize?: number
maxBatchSize?: number
ttlMs?: number
sourceTtlMs?: number
}
reflect?: {
minNewRecords?: number
maxRecords?: number
writes?: "candidate" | "auto"
}
}
resolveScope?: (ctx: { routePath: string; appRoot: string }) => Record<string, string>
}
The route's memory.ts, not memory.enabled, opts into typed memory;
enabled is an inert compatibility field. Defaults are SQLite at
.b4/memory.sqlite, writes: "candidate", and indexMaxEntries: 20.
Keyword recall defaults to relevance/recency/confidence weights
0.6/0.3/0.1, a 14-day half-life, and a 256-record candidate pool.
Vector recall is absent by default; when present it requires embedder and
defaults to keyword/vector weights 1/1, rrfK: 60, vectorK: 64,
recencyWeight: 0.3, and confidenceWeight: 0.1.
Episodes default disabled with a 30-day TTL, cap 500, failed runs included,
and no embeddings (embed: true is unsupported). Distillation runs only when
invoked: defaults are gpt-5-mini, inferred provider then openai, five
batches; consolidate after seven days in batches of 5–50 with no summary TTL
and a seven-day source TTL; reflect after 10 new records over at most 100,
writing candidates. resolveScope receives only routePath and appRoot, so
derive tenant or user dimensions from application-owned context. A custom store
owns its own retrieval behavior rather than these default-store tuning blocks.
At runtime, an injected memoryStore takes precedence over config.memory.store;
config precedes the default SQLite store. See Long-term
Memory for governance, Recall and
Retrieval for ranking, Episodes
for run history, and Distillation for explicit
consolidation and reflection.
server
server?: {
cors?: {
origins: readonly string[] | "*"
credentials?: boolean
methods?: readonly string[]
headers?: readonly string[]
exposeHeaders?: readonly string[]
maxAgeSeconds?: number
}
}
Absent by default, and absence means no Access-Control-* header on any
response — a browser on another origin cannot call /agui/*, /threads/* or
/memory/*, and OPTIONS falls through to the router's 404. Opening a server
to other origins is a deployment decision, so nothing here is inferred.
Set it when a browser client talks to B4.run directly instead of through a same-origin proxy:
server: { cors: { origins: ["https://app.example.com"] } }
Origins are compared exactly, after normalizing case and a trailing slash, so
"HTTP://LocalHost:3010/" matches an Origin: http://localhost:3010. Note that
localhost and 127.0.0.1 are different origins — list both if your dev
client may be opened at either.
The policy is resolved and validated once at boot, so a malformed origin list
fails on startup rather than on the first cross-origin request. origins: "*"
with credentials: true is rejected outright: browsers refuse a wildcard
allow-origin on a credentialed request, and accepting it would produce a server
that looks configured and fails only in the console.
Defaults for the rest: credentials false; methods GET, POST, DELETE, OPTIONS; headers echoes the browser's own
Access-Control-Request-Headers (so an app can add an auth header without
changing server config); exposeHeaders empty; maxAgeSeconds 600.
Two behaviors worth knowing. A request from an origin not on the list is
still served normally — it just carries no CORS header, and the browser is what
refuses to hand it to the page; answering 403 there would break every
non-browser client that happens to send an Origin. And error responses are
stamped too, including the shutdown 503, because a cross-origin caller that
cannot read a 404 sees only an opaque CORS failure.
CORS is not authentication. It controls which origins a browser will let read a
response; it does not decide who may call the server. Pair it with
defineThreadAccess.
Postgres backend
Shared Postgres persistence requires three explicit entries: the checkpointer, thread store, and permission store. They are separate because checkpoints, thread metadata, and runtime grants have different contracts.
pnpm add @b4run/postgres-storage pg
import { config } from "@b4run/core"
import {
createPostgresPermissionsStore,
createPostgresThreadsStore,
postgresCheckpointer,
} from "@b4run/postgres-storage"
import { Pool } from "pg"
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
pool.on("error", (error) => {
console.error("Postgres pool client error:", error)
})
export default config({
checkpointer: postgresCheckpointer({ pool }),
threadsStore: createPostgresThreadsStore({ pool }),
permissions: {
store: createPostgresPermissionsStore({ pool, mode: "non-interactive" }),
},
})
Attach an error listener to an injected pg.Pool. The application owns the pool
and must end it after B4.run has stopped accepting and draining requests;
the stores do not close an injected pool. Postgres shares durable rows, but it
does not distribute active-run or cancel coordination between processes. Use
sticky or thread-aware routing, or provide a distributed coordination layer.
See Persistence and Tenancy for migration, lifecycle, retention, and deletion contracts, then Production Topology for replica routing and shutdown order.