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:

b4.config.ts
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

ts
appDir?: string

Default: "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

ts
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

ts
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

ts
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

ts
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

ts
checkpointer?: BaseCheckpointSaver

Default: 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

ts
threadsStore?: ThreadsStore

Default: 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

ts
env?: string

Default: "./.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

ts
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

ts
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

ts
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 vercel value that isn't an object,
  • an unknown key inside it,
  • a reconcileVercelJson other than true or false, or
  • the flag placed directly on build.

A prebuilt app sets build: { targets: ["vercel"], vercel: { reconcileVercelJson: false } }.

sandboxCopy link to section: sandbox

ts
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 a denylist, so the cloud metadata endpoint 169.254.169.254 stays reachable. See Network policy,
  • no host environment variables, and
  • releasing idle compute after 600_000 ms.

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

ts
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, with writes: "candidate" and indexMaxEntries: 20. At runtime, an injected memoryStore takes precedence over config.memory.store.
  • Keyword recall: relevance/recency/confidence weights 0.6/0.3/0.1, a 14-day half-life, and a 256-record candidate pool.
  • Vector recall: off. Turn it on with an embedder. It then defaults to keyword/vector weights 1/1, rrfK: 60, vectorK: 64, recencyWeight: 0.3, and confidenceWeight: 0.1.
  • Episodes: disabled, with a 30-day TTL, a cap of 500, failed runs included, and no embeddings (embed: true isn't supported).
  • Distillation: runs only when you invoke it, with gpt-5-mini, the inferred provider (falling back to openai), 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, distill or retry;
  • a key on memory that is distill up to case and one character, such as Distill, distil or distills;
  • retry or maxAttempts in any casing directly on memory, and maxAttempts directly on distill;
  • any other unknown key in distill or distill.retry, a typo such as Retry or maxattempts included;
  • a baseDelay, and a maxAttempts that 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

ts
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:

ts
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: false
  • methods: GET, POST, DELETE, OPTIONS
  • headers: echoes the browser's own Access-Control-Request-Headers, so you can add an auth header without changing server config
  • exposeHeaders: empty
  • maxAgeSeconds: 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.

ts
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:

bash
npm install @b4run/postgres-storage pg

Then wire all three to one pool:

b4.config.ts
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.