Configuration Reference

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.

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

ts
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

ts
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

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

ts
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

ts
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

ts
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

ts
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

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

ts
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

ts
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

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

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

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

bash
pnpm add @b4run/postgres-storage pg
b4.config.ts
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.