Configuration Reference
b4.config.ts configures your B4.run app. Every B4Config field is optional,
but the file is required, because the CLI finds your app by looking for it
next to package.json. Node loads it once, and b4 dev restarts its child
runtime when you edit it. Filesystem-free runtimes receive a config object
and never read the file.
Run b4 verify before you serve the app. It checks the config,
routes, Node version, provider credentials and sandbox preflight.
Complete annotated exampleCopy link to section: Complete annotated example
You can copy this file as written. Uncomment an instance-valued field once you've imported or built its implementation:
import { config } from "@b4run/cli"
export default config({
appDir: "src/app",
// Set writable to false to make workspace/AGENTS.md read-only guidance.
agentsMd: { writable: true },
// 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,
},
approvals: {
grants: "off",
// grantTtlMs: 86_400_000,
// grantStore: sharedInterruptGrantStore,
},
// 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"],
// vercel: { outDir: "dist/vercel" }, // where the "vercel" target publishes (default .vercel/output)
},
// Cross-origin access. Omit it and the runtime sends no `Access-Control-*`
// header, so a browser on another origin cannot call it.
// `localhost` and `127.0.0.1` are different origins to a browser.
server: {
cors: { origins: ["https://app.example.com"] },
},
// sandbox: {
// provider: dockerSandbox({ scope: "my-app", image: "node:24-slim" }),
// network: { mode: "deny" },
// // network: { mode: "deny", allowlist: ["10.0.0.0/8"] }, // Kubernetes CIDRs; Docker ignores it
// // network: { mode: "allow" }, // open egress, cloud metadata endpoint included
// 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,
retry: { maxAttempts: 3 },
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: ({ principal }) => (principal ? { user: principal.id } : {}),
},
})Key referenceCopy link to section: Key reference
appDirCopy link to section: appDir
appDir?: stringDefault: "src/app". This is the directory, relative to the app root, where
B4.run looks for route index.ts files. Keep it inside the app root. There's
no environment variable override. See Routes for the layout.
agentsMdCopy link to section: agentsMd
agentsMd?: {
writable?: boolean
}Default: writable: true. B4.run injects workspace/AGENTS.md into every
agent route's system prompt on each turn. By default it's the agent's
memory: the prompt tells the model
to update the file with writeFile when it learns something worth keeping.
Set agentsMd: { writable: false } when the file is shared, reviewed house
style that one thread mustn't change for every other. The prompt then presents
it under # Project guidance as read-only, maintained by the app's authors,
and tells the model not to modify it.
This only changes the prompt. It doesn't block a write, so enforce it with a
FilesystemMiddleware that refuses writes to AGENTS.md, composed over
backends.filesystem (see Workspace middleware).
That middleware doesn't see shell commands, so also deny or gate runBash for
routes that can reach the workspace.
b4 check and route preparation reject, with B4_E1010, an agentsMd that
isn't an object (agentsMd: false included), any key other than writable
(a typo such as writeable included), and a writable that isn't a boolean.
A misspelled or mistyped key inside agentsMd is an error, not a silent
fallback to writable.
backendsCopy link to section: backends
backends?: {
filesystem?: FilesystemBackend
exec?: ExecBackend
}Defaults: the local filesystem and a local child-process executor. A sandboxed thread uses the sandbox's filesystem and executor. Every other thread uses the backends you configure here, or the local defaults. Live backend objects can't be serialized into a static edge build. See Workspace Filesystem for backend composition and the path jail.
permissionsCopy link to section: 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 allow
and deny maps, and .b4/permissions.json for saved interactive decisions.
B4_PERMISSIONS_MODE overrides permissions.mode. A deny match beats an
allow match. The reserved tool, subagent and clientTool 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.
Only an interactive Always decision saves an allow entry. See
Permissions for the modes and the interrupt/resume
lifecycle.
permissions.store
A PermissionsStore you supply replaces the file-backed store, and B4.run
loads it before first use. Your store decides its own mode and allow/deny
policy, so B4.run ignores the sibling permissions.mode, allow, deny and
B4_PERMISSIONS_MODE. In an embedded runtime, a store passed at boot wins over
this field.
approvalsCopy link to section: approvals
approvals?: {
grants?: "off" | "optional" | "required"
grantTtlMs?: number
grantRetentionMs?: number
grantStore?: InterruptGrantStore
}grants decides whether a parked approval carries a single-use grant that
must be echoed on resume. Default "off", which is the pre-grant behavior.
Under "optional" an interrupt that has a grant requires it while one
parked without a grant resumes as before; under "required" a resume with no
grant is refused and a park that cannot mint one aborts the turn. The setting
is process-wide and ratchets up only, so two app roots in one process share
the strictest mode either asks for.
grantTtlMs is omitted by default, meaning no expiry — a human approval may
legitimately sit overnight. grantStore defaults to SQLite at
.b4/interrupt-grants.sqlite on node and to a non-durable in-process store
elsewhere, so a multi-replica deployment must supply one. See Approval
Grants.
grantRetentionMs is how long a grant record voided because the thread moved
past its prompt is kept before the runtime deletes it: 604800000 (7 days) by
default, a positive integer of at most one year, anything else fails the boot.
A consumed grant is voided when its resumed turn completes. The sweep runs
wherever the runtime voids superseded grants, at most once an hour per store.
An outstanding grant, or a consumed grant whose resume never completed, is
never deleted, however old, because a parked prompt with no grant row resumes
ungated under "optional".
b4 approvals prune runs the same pass by hand. A
grantStore you supply is checked at boot while grants are on and must implement every store
method, prune included.
checkpointerCopy link to section: checkpointer
checkpointer?: BaseCheckpointSaverDefault: SQLite at .b4/checkpoints.sqlite. A checkpointer passed at boot wins,
then this field, then the default. Checkpoints hold graph state. Thread
metadata lives in threadsStore. Read Persistence and
Tenancy before you share or delete state across replicas.
threadsStoreCopy link to section: threadsStore
threadsStore?: ThreadsStoreDefault: SQLite at .b4/threads.sqlite. A thread store passed at boot wins,
then this field, then the default. It holds Agent Protocol thread metadata.
Graph state lives in checkpointer, so configure both when replicas share
threads. See Persistence and Tenancy for what each store
holds and the order to delete in.
envCopy link to section: env
env?: stringDefault: "./.env", relative to the app root. Only b4 dev and b4 inspect
load it, and they check --env-file first, then config.env, then the
default. b4 verify checks the resolved file without loading it. b4 start
ignores this field, so production variables come from the shell or your
hosting platform.
The LangSmith artifact picks its own file: .env.example when it exists, and
.env otherwise. Its JSON records an env-file path, not variable names, and it
ignores config.env. See Deployment Options for how each
target gets its environment.
toolOutputCopy link to section: toolOutput
toolOutput?: {
offloadThresholdChars?: number
previewLines?: number
maxBytes?: number
ttlMs?: number
gcThrottleMs?: number
noOffloadTools?: readonly string[]
}On a Node runtime with a workspace filesystem, the 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 readFile and listDir are always exempt. Each value you set replaces
just that default.
Offloaded data lives under workspace/tool-outputs/. It keeps large outputs
out of the context window, and it gets cleaned up. A
non-empty block fails on the filesystem-free Hono target (B4_E1005). See
Context Management for retrieval and cleanup.
summarizationCopy link to section: 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-loaded gpt-tokenizer o200k_base counter, and the built-in one-call
summarizer. Each field you set replaces its default. Summarization needs a
model, so set summarization.model if the route doesn't have one. See
Context Management for when history gets
compressed and what's kept word for word.
buildCopy link to section: build
build?: {
targets?: readonly BuildTargetName[]
vercel?: {
functionName?: string
maxDuration?: number
static?: { dir: string; spaFallback?: string }
functions?: Readonly<Record<string, {
entry: string
runtime?: string
maxDuration?: number
supportsResponseStreaming?: boolean
}>>
routes?: readonly { src: string; dest?: string; [key: string]: unknown }[]
outDir?: string
reconcileVercelJson?: boolean
}
}Default: ["node", "langsmith"]. A targets array you set replaces that list
rather than adding to it. "hono" and "vercel" are the opt-in edge targets.
Pick only targets whose runtime supports the features you use. See
Deployment Options.
BuildTargetName is the union "node" | "langsmith" | "hono" | "vercel". It's
exported from @b4run/core along with the BUILD_TARGET_NAMES tuple it comes
from, so a misspelled target fails to type-check before you ever run
b4 build.
vercel describes what the "vercel" target publishes next to the runtime
function:
- a static directory, with an optional SPA fallback document,
- extra Node functions, each bundled from an
entry, and - routes placed ahead of the filesystem phase.
Paths resolve from the app root. The runtime function is named b4, because
the Build Output API serves a function named index at /, where it would
shadow a static index.html. functionName overrides the name, and "index"
next to static fails the build. See
the vercel target for the
published tree and route order.
vercel.maxDuration is the runtime function's maxDuration in seconds, a
positive integer. Leave it out and .vc-config.json omits it, so Vercel
applies the project default. The runtime function runs the agent, so a long
tool-using run hits the limit there first. Each function under
vercel.functions sets its own. validateVercelOutput requires the published
value to match the configured one.
vercel.outDir is where the Build Output tree is published, relative to the
app root. It defaults to .vercel/output, which is where Vercel deploys from.
Point it elsewhere when a later step composes the tree before deploying.
b4 build --out-dir <dir> overrides it for one run. Each build replaces the
directory wholesale, so B4.run rejects an outDir that contains the app root.
vercel.reconcileVercelJson (default true) controls whether the "vercel"
target keeps the app-root vercel.json in line with what it needs: a
buildCommand that runs b4 build, and fluid: true.
Set it to false for a prebuilt flow, where CI runs vercel deploy --prebuilt
and there's no Vercel Git integration. Vercel never runs buildCommand in that
flow, so the target stops requiring, writing or inspecting a committed
vercel.json. Keep fluid compute on in the Vercel project settings, because
the build no longer catches a committed fluid: false.
One resolver validates all of build.vercel. Both b4 check and b4 build
fail with B4_E1003 on any near miss:
- a
vercelvalue that isn't an object, - an unknown key inside it,
- a
reconcileVercelJsonother thantrueorfalse, or - the flag placed directly on
build.
A prebuilt app sets
build: { targets: ["vercel"], vercel: { reconcileVercelJson: false } }.
sandboxCopy link to section: sandbox
sandbox?: {
workspace?: WorkspaceDefinition
thread?: ThreadSandboxResolver
workspaceRead?: "http"
workspaceReadTimeoutMs?: number
stagedWorkspaces?:
| boolean
| {
maxUploadBytes?: number
retentionMs?: number
maxStagedBytes?: number
uploadTimeoutMs?: number
}
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 run on the host backends. If you
add a sandbox, provider is required. The manager then defaults to:
- an allow-mode network policy,
{ mode: "allow" }, with open egress. The reference Docker and Kubernetes providers do not enforce adenylist, so the cloud metadata endpoint169.254.169.254stays reachable. See Network policy, - no host environment variables, and
- releasing idle compute after
600_000ms.
thread decides each thread's workspace, image and policy once, when the thread
is first admitted, and is exclusive with workspace. b4 check, b4 build and
startup refuse any other key in the sandbox block. Read Per-thread
sandboxes for what a thread may set.
workspaceRead: "http" serves a read of each thread's managed workspace at
POST /threads/:thread_id/workspace/inspect. It needs workspace or thread
and a thread-access policy; b4 check, b4 build and startup refuse it
without one. workspaceReadTimeoutMs (1,000 to 1,800,000; default 120,000)
bounds one read, open to close. Read Reading a thread's workspace over
HTTP.
stagedWorkspaces accepts a thread's workspace at creation: a creator uploads
it at PUT /workspace/sources/:digest and names it in POST /threads, and the
resolver receives it as thread.staged. It needs a resolver (thread, or a
function workspace) and a thread-access policy; b4 check, b4 build and
startup refuse it otherwise. maxUploadBytes (default and ceiling 96 MiB),
maxStagedBytes (default 1 GiB, at most 16 GiB), retentionMs (60,000 ms
to 30 days; default 24 hours) and uploadTimeoutMs (1,000 to 1,800,000 ms;
default 120,000) bound it. Read Handing a thread its workspace at
creation.
Each provider enforces resources and security in its own way. Docker's
security fields default to hardened. diskGb applies to PVC-backed providers,
and Docker ignores it. At runtime, an
injected sandboxManager takes precedence over config.sandbox, so this
field only applies when the host doesn't pass a manager. Read Execution
Sandbox before you relax isolation or network policy.
memoryCopy link to section: 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
retry?: { maxAttempts?: 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
principal: B4Principal | undefined
}) => Record<string, string>
}A route opts into typed memory with its own memory.ts file. enabled is an
inert compatibility field.
The defaults are:
- Store: SQLite at
.b4/memory.sqlite, withwrites: "candidate"andindexMaxEntries: 20. At runtime, an injectedmemoryStoretakes precedence overconfig.memory.store. - Keyword recall: relevance/recency/confidence weights
0.6/0.3/0.1, a 14-day half-life, and a256-record candidate pool. - Vector recall: off. Turn it on with an
embedder. It then defaults to keyword/vector weights1/1,rrfK: 60,vectorK: 64,recencyWeight: 0.3, andconfidenceWeight: 0.1. - Episodes: disabled, with a 30-day TTL, a cap of
500, failed runs included, and no embeddings (embed: trueisn't supported). - Distillation: runs only when you invoke it, with
gpt-5-mini, the inferred provider (falling back toopenai), five batches, and 3 attempts per model call. Consolidation takes records older than seven days in batches of 5–50, sets no summary TTL, and gives source records a seven-day TTL. Reflection waits for 10 new records, reads at most 100, and writes candidates.
distill.retry.maxAttempts is the attempts per distillation model call,
counting the first. It becomes the model's maxRetries (maxAttempts - 1),
as agent({ retry }) does for a route. There's no baseDelay
here: distillation has no rate-limit retry of its own for it to pace. On the
ollama provider maxAttempts has no effect, because Ollama's chat requests
bypass LangChain's retry (see Retry limits).
b4 check, b4 memory consolidate and b4 memory reflect reject, with
B4_E1009:
- a non-object
memory,distillorretry; - a key on
memorythat isdistillup to case and one character, such asDistill,distilordistills; retryormaxAttemptsin any casing directly onmemory, andmaxAttemptsdirectly ondistill;- any other unknown key in
distillordistill.retry, a typo such asRetryormaxattemptsincluded; - a
baseDelay, and amaxAttemptsthat isn't a whole number of at least 1.
They never fall back to the default. Only the top-level keys of distill
and distill.retry are checked, not the keys inside consolidate or
reflect.
A custom store ignores these tuning blocks. resolveScope runs per request
with routePath, appRoot and the request principal from src/auth.ts, so
derive tenant or user dimensions from the principal. A dimension the route
declares and resolveScope leaves empty makes memory unavailable for that
request.
See Long-term Memory for write governance, Recall and Retrieval for ranking, Episodes for run history, and Distillation for consolidation and reflection.
serverCopy link to section: server
server?: {
cors?: {
origins: readonly string[] | "*"
credentials?: boolean
methods?: readonly string[]
headers?: readonly string[]
exposeHeaders?: readonly string[]
maxAgeSeconds?: number
}
agui?: {
clientTools?: readonly string[]
clientForwardedProps?:
| readonly string[]
| Readonly<Record<string, true | readonly string[]>>
clientToolTtlMs?: number
clientToolRetentionMs?: number
clientToolStore?: ClientToolCallStore
}
}server.cors
By default there's no server.cors, which means no Access-Control-* header
on any response. A browser on another origin can't 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 B4.run leaves
it to you.
Set it when a browser talks to B4.run directly, without a same-origin proxy:
server: { cors: { origins: ["https://app.example.com"] } }B4.run normalizes case and a trailing slash, then compares origins as strings,
so "HTTP://LocalHost:3010/" matches an Origin: http://localhost:3010. Note,
localhost and 127.0.0.1 are different origins. List both if you open your
dev client at either one.
B4.run validates the policy at boot, so a malformed origin list fails on
startup. It rejects
origins: "*" with credentials: true, because browsers refuse a wildcard
allow-origin on a credentialed request.
The other fields default to:
credentials:falsemethods:GET, POST, DELETE, OPTIONSheaders: echoes the browser's ownAccess-Control-Request-Headers, so you can add an auth header without changing server configexposeHeaders: emptymaxAgeSeconds:600
A request from an origin that isn't on the list is still served. The response
just has no CORS header, so the browser won't hand it to the page. (A 403
would break every non-browser client that sends an Origin.) Error responses
carry the headers too, including the shutdown 503, so a cross-origin caller
sees the real 404 rather than an opaque CORS failure.
CORS controls which origins a browser lets read a response. It's not
authentication, and it doesn't decide who may call the server. Pair it with
defineThreadAccess.
server.agui
Which client-supplied AG-UI envelope fields POST /agui/:routeId honors, per
route. Both lists are empty unless set, and empty means the runtime rejects
a non-empty tools or forwardedProps with a 422 rather than ignoring it —
see envelope validation.
server: { agui: { clientTools: ["/chat"] } }Entries are route ids as they appear in the route tree ("/chat",
"/support/billing"), matched exactly. clientTools and
clientForwardedProps are independent: opening one does not open the other,
and a route that is not named stays closed even when a sibling is open.
An empty tools: [] or forwardedProps: {} — what an AG-UI client sends when
it has nothing to add — is accepted with or without the opt-in, so the default
costs an ordinary client nothing.
clientForwardedProps also opens single forwardedProps keys. In the object
form, a route id, or "*" for every route, maps to true (any key) or to the
key names it accepts. A route gets its own keys plus "*"'s, so
clientForwardedProps: { "*": ["responseSchema"], "/chat": true } lets every
route accept responseSchema and /chat accept any key.
A key the route does not accept is refused with 422
forwarded_props_not_allowed, and the message names it. The array form is
shorthand for true on each listed route. B4.run acts on one key,
responseSchema, which it binds as the root model's
structured output. It accepts the others
without acting on them. The setting is checked at boot, so any other shape
fails on startup.
On a route named in clientTools, the model can call the client's tools —
see Client-provided tools. Each call the
model makes is recorded before the turn ends, so the client's role: "tool"
result on its next run can be matched to a call this server actually issued.
clientToolTtlMs is how long that call waits for its result before it is
abandoned — 600000 (10 minutes) by default; it must be a positive integer of
at most one year (31536000000), and anything else fails the boot.
With a store resolved, every tool call on an AG-UI run is recorded, server tools included (identity only, no result text), and a store that cannot be written fails the tool call; see The tool-call record.
clientToolStore is where tool calls on AG-UI runs are recorded. On node it defaults to a
SQLite file at .b4/client-tool-calls.sqlite, opened when some route is
listed in clientTools (or when the file already exists, so calls parked
before the opt-in was removed can still be closed). Listing a route in clientTools, or setting clientToolStore, is also what turns on recording of server tool calls; a leftover file alone does not, and boot warns about it. Edge and serverless
deployments have no default and must set one, and a deployment with more than
one instance needs a shared one — createPostgresClientToolCallStore from
@b4run/postgres-storage, for example. With no store at all, a run that sends
client tools is refused with 503 client_tool_store_unavailable rather than
parked on a call nobody could answer. A configured store missing any of the
store methods fails the boot.
Settled records are not kept forever. When an AG-UI turn settles, the runtime
deletes answered or voided records older than clientToolRetentionMs,
outstanding records whose expiry is older than it, and server tool call
records settled before it, at most once an hour per store. The default is
604800000 (7 days); it must be a positive integer of at most one year,
anything else fails the boot, and the window is never shorter than
clientToolTtlMs. A record that is outstanding and unexpired, or a server
tool call that has not settled, is never deleted.
b4 client-tools prune runs the same
pass by hand, and a store you supply must implement prune as well.
Postgres backendCopy link to section: Postgres backend
Shared Postgres persistence takes three explicit entries: the checkpointer, the thread store and the permission store. Each holds different data with its own lifecycle. Install the package and the driver:
npm install @b4run/postgres-storage pgThen wire all three to one pool:
import { config } from "@b4run/cli"
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" }),
},
})Always attach an error listener to the pool. Your application owns the pool,
so end it yourself once B4.run has stopped accepting and draining requests.
The stores leave it open. Postgres shares durable rows between replicas, but it
does not distribute active-run or cancel coordination between processes. Use
sticky or thread-aware routing, or add your own distributed coordination.
See Persistence and Tenancy for migrations, lifecycle, retention and deletion, then Production Topology for replica routing and shutdown order.