nimblebrain.json
NimbleBrain splits configuration into two files:
nimblebrain.json— instance-level settings (models, HTTP, logging, limits, feature flags). One file per deployment. This page.workspace.json— per-workspace settings (connectors, skill directories, optional model overrides). Seeworkspace.json.
Top-level skillDirs, home, and preferences entries in nimblebrain.json are stripped at load — they belong in workspace.json now. See workspace configuration for where they live. A workspace’s connectors array belongs there too; in this file the name connectors is the provider and gateway block.
Config resolution
Section titled “Config resolution”NimbleBrain looks for the config file in this order, stopping at the first match:
--config <path>— Explicit path passed as a CLI flag..nimblebrain/nimblebrain.json— Project-local config directory in the current working directory.<defaultWorkDir>/nimblebrain.json— Inside the command’s default working directory (~/.nimblebrainforbun run start, the project-local workdir for worktree dev)../nimblebrain.json— Current working directory (bare file).
If no config file exists at the resolved path, NimbleBrain auto-creates a minimal default file ({ "$schema": …, "version": "1" }) as long as the parent directory exists.
The override file
Section titled “The override file”A sibling nimblebrain.overrides.json next to the resolved config file is deep-merged on top of it at load. The seed (nimblebrain.json) is operator-managed and overwritten on every deploy; the override is written by the admin set_model_config tool and preserved across deploys. The merge is one level deep — top-level objects (e.g. models) are merged key-by-key so an override of models.fast doesn’t clobber the seed’s models.default; scalars and arrays are replaced wholesale. A malformed override file is logged loudly and skipped (the seed still boots) rather than taking down startup.
Complete annotated example
Section titled “Complete annotated example”{ "$schema": "https://schemas.nimblebrain.ai/v1/nimblebrain-config.schema.json", "version": "1",
"models": { "default": "anthropic:claude-sonnet-4-6", "fast": "anthropic:claude-haiku-4-5-20251001" },
"providers": { "anthropic": { "apiKey": "sk-ant-..." }, "openai": { "apiKey": "sk-..." } },
"maxIterations": 25, "maxInputTokens": 500000, "maxToolResultSize": 1000000,
"logging": { "dir": "~/.nimblebrain/logs", "disabled": false, "level": "normal", "retentionDays": 30 }, "http": { "port": 27247, "host": "127.0.0.1" },
"features": { "skillManagement": true, "toolDiscovery": true, "catalogSearch": true, "fileContext": true, "userManagement": true, "workspaceManagement": true, "compaction": true },
"connectors": { "providers": { "composio": { "monitorEnabled": true } } },
"notifications": { "poll": { "intervalMs": 60000, "maxIntervalMs": 300000, "maxEvents": 100, "budgetPerMinute": 10 } },
"files": { "maxFileSize": 26214400, "maxTotalSize": 104857600, "maxFilesPerMessage": 10, "maxExtractedTextSize": 204800 },
"allowInsecureRemotes": false, "telemetry": { "enabled": true }, "workDir": "~/.nimblebrain"}Top-level fields
Section titled “Top-level fields”| Field | Type | Default | Description |
|---|---|---|---|
$schema |
string |
— | JSON Schema URI. Set to "https://schemas.nimblebrain.ai/v1/nimblebrain-config.schema.json" for editor autocompletion. |
version |
string |
— | Config file version. Must be "1". |
models |
object |
— | Role-based model slots. See models. |
modelPolicy |
object |
— | Which models this organization permits. See modelPolicy. |
providers |
object |
— | Per-provider API keys. See providers. |
maxIterations |
integer |
25 |
Max agentic iterations per request. Range: 1–50. |
maxInputTokens |
integer |
500000 |
Max input tokens in one model call’s context. Older history is windowed or compacted to fit; it does not stop the run. |
maxOutputTokens |
integer |
the model’s catalog output limit | Max output tokens per LLM call. Leave unset to use each model’s own limit (limits.output in the synced catalog; 16,384 for a model the catalog lacks). A set value applies to every model, clamped to that model’s limit, and thinking counts toward it, so a low cap can leave a reasoning model little room for its answer. |
maxToolResultSize |
integer |
1000000 |
Max characters for a single tool result. 0 disables the cap. |
thinking |
"off" | "adaptive" | "enabled" |
enabled on reasoning models |
Extended-thinking mode, applied to every provider that supports reasoning. off: do not reason — not enforceable on Anthropic’s adaptive-only models (Opus 4.7/4.8, Sonnet 5, Opus 5), which have no such state: the AI SDK validates thinking.type=disabled and then never sends it, so the model applies its own default (#738). adaptive: model decides per call, at no stated depth. enabled: reason every turn, at thinkingEffort. Applies only to models the catalog carries and flags reasoning-capable — an override can ask for reasoning but cannot make the parameter exist. A pinned model id, or one served by an OpenAI-compatible proxy under its own name, isn’t in the catalog and so gets no reasoning options; the runtime logs a warning naming the model when it drops an override this way. Typically set via the admin set_model_config tool, which writes it to nimblebrain.overrides.json. |
thinkingEffort |
"low" | "medium" | "high" | "xhigh" | "max" |
medium |
How hard to think when reasoning is on. The portable control — see reasoning depth for how it reaches each provider. Ignored for off and adaptive. |
thinkingBudgetTokens |
integer |
— | Explicit token budget, for metering thinking in tokens instead of naming a depth. Only honored by providers that meter it that way (Anthropic up to 4.6, Gemini 2.5); elsewhere thinkingEffort applies — Gemini 3 takes a level, not a budget. Counts toward maxOutputTokens. Anthropic requires a minimum of 1,024. Prefer thinkingEffort unless you need a hard token cap. |
logging |
object |
— | Structured logging config. See logging. |
http |
object |
{ "port": 27247, "host": "127.0.0.1" } |
HTTP server config. Omit for programmatic-only use. |
features |
object |
all true |
Feature flags. See Feature Flags. |
connectors |
object |
— | Managed-connector provider settings (Composio). See connectors. |
notifications |
object |
— | Pacing for the notification outbox poll. See notifications. |
tasks |
object |
— | Task run slots, queue, and per-run ceilings. See tasks. |
files |
object |
— | File upload limits. See files. |
allowInsecureRemotes |
boolean |
false |
Allow HTTP (non-TLS) remote connector connections. Dev only. |
telemetry |
object |
{ "enabled": true } |
Anonymous usage telemetry. See telemetry. |
workDir |
string |
"~/.nimblebrain" |
Working directory for runtime state (conversations, logs, workspaces). |
Reasoning depth
Section titled “Reasoning depth”Providers disagree about how to ask a model to think harder. Two take a named effort tier; two meter it in tokens. NimbleBrain’s config is the effort tier, and the runtime translates.
| Provider | What it receives |
|---|---|
| Anthropic — Opus 4.7/4.8, Sonnet 5, Opus 5 | thinking: {type: "adaptive"} + output_config.effort, the tier verbatim |
| Anthropic — up to Sonnet 4.6 | thinking: {type: "enabled"} + a budgetTokens sized from the tier |
| OpenAI, and Nebius-hosted open-weight models (Qwen, Kimi, GLM, gpt-oss, Cosmos3) | reasoningEffort. Its usable range stops at high, so xhigh and max both land there. On OpenAI, acceptance is also per model — see below |
| xAI (Grok) | reasoningEffort, tops out at high. Acceptance is per model and some Grok models take no effort parameter at all — see below |
| Google Gemini 3 | thinkingConfig.thinkingLevel, tops out at high |
| Google Gemini 2.5 | thinkingConfig.thinkingBudget, sized from the tier and held inside the model’s documented range |
The translation only runs one way. A depth is converted into a token budget where a provider needs one, because the model’s output ceiling is a reasonable scale to express a chosen depth against. The reverse is not done: a token budget carries no depth to recover, and deriving one from a ceiling the operator never set would turn a number nobody chose into an instruction to reason as hard as possible on every call.
thinkingBudgetTokens is optional and independent — it does not replace the
depth. Where a provider meters thinking in tokens, the budget is used and is
capped to leave room for the answer; everywhere else the depth applies as
usual. Setting one never voids the other.
Google and OpenAI support is per model, not per generation, and is read from hand-maintained tables measured against the live APIs.
On Google, gemini-3.1-flash-lite-image accepts only minimal and high where
gemini-3.6-flash accepts all four, and gemini-2.5-pro cannot stop thinking
where the flash models can. A Gemini model with no verified entry receives no
thinking options at all and runs at its own default, because guessing the
dialect is how a stock install starts returning 400s.
On OpenAI, most models take all three tiers, but the GPT-5 -pro models do not
(o1-pro and o3-pro take all three):
gpt-5-pro accepts only high — and rejects medium, the default depth, so
it is the one to know about. gpt-5.2-pro, gpt-5.4-pro and gpt-5.5-pro
reject low. An OpenAI model with no entry is assumed to take all three, the
opposite of the Google default, because nearly every model does and the
adapter already skips non-reasoning ones.
In both cases a requested depth a model doesn’t offer steps down to the
nearest one it does, never up. Where nothing at or below the request is
offered, no depth is sent and the model runs at its own default — so an
explicitly set thinkingEffort can end up not applied on a model that doesn’t
accept it, rather than the call failing.
off is not universally enforceable, and where it isn’t, nothing is sent
rather than a flag that would be rejected or ignored:
- Anthropic — the AI SDK only serializes
thinkingforenabledandadaptive, so adisabledflag never reaches the wire for any model (#738).offworks where it works because Anthropic’s own default is not to think. - OpenAI —
reasoningEffort: "none"is documented as GPT-5.1-only and an error elsewhere. On o-series models reasoning can’t be turned off at all. - Gemini — 2.5 Flash takes a zero budget; 2.5 Pro cannot disable thinking.
On Gemini 3 it depends on the model: those offering a
minimallevel get it, and those that don’t (both Pro entries) get nothing rather than the next tier up, which would answer “don’t reason” with an instruction to reason.
models
Section titled “models”The models object declares two role-based slots. Each value is a provider:model-id string (e.g. "anthropic:claude-sonnet-4-6").
{ "models": { "default": "anthropic:claude-sonnet-4-6", "fast": "anthropic:claude-haiku-4-5-20251001" }}| Slot | Used for |
|---|---|
default |
Chat, general requests, most tool-using turns |
fast |
Conversation titles and both history folds — end-of-turn compaction and the mid-turn fold. Cheap/fast workloads, but still needs a large context window: see below |
A slot left unset resolves to the built-in default model, anthropic:claude-sonnet-4-6. The slots are independent: an unset fast does not follow default.
A workspace can override individual slots via workspace.json → models. When a per-request model specifies a slot name (e.g. "model": "fast"), it’s resolved against these slots. "alias:fast" is the same reference written explicitly; both spellings resolve identically.
A bare model string that is neither a slot name nor a model in the catalog is passed through to the provider unchanged, with anthropic: prepended — the deliberate fallback for pinned or bespoke ids served under their own name (for example through an OpenAI-compatible proxy). That means a typo here is not rejected at config load; it fails at the provider on the first call that uses the slot, reporting a model you did not configure.
Changing a slot affects new conversations only
Section titled “Changing a slot affects new conversations only”A conversation is bound to a model when it is created, and that binding does not
move. Changing models.default — in this file, through the override file, or
via the admin set_model_config tool — applies to conversations started after
the change; conversations already under way keep running on the model they began
on. Start a new conversation to pick up the new slot.
The binding is the main chat model only. Auxiliary work always follows the
current slots: title generation and history compaction take fast.
Precedence for a chat turn, highest first:
| Source | |
|---|---|
| 1 | The conversation’s binding, set when it was created |
| 2 | A per-request model, on the turn that creates the conversation |
| 3 | models.default from this file (or the override file) |
Conversations created before this behavior existed carry no binding and resolve from the current slot on every turn, as they did before.
modelPolicy
Section titled “modelPolicy”Which models this organization permits. Absent — or present with an empty
allowed — allows every chat-capable model from every configured provider, so
an existing deployment changes nothing and an org opts into restriction.
{ "modelPolicy": { "allowed": ["anthropic:claude-sonnet-5", "anthropic:claude-haiku-4-5-20251001"] }}| Field | Type | Description |
|---|---|---|
allowed |
string[] |
Qualified provider:model-id strings. |
Policy can only subtract. It narrows what the configured providers can already reach — naming a model whose provider has no key does not make it reachable, and naming a non-chat model does not make it selectable.
This file is the only place it is set. There is no runtime surface for it — the allowlist is deployment configuration, so it lives in version control with the rest of the tenant’s setup and a redeploy always restores it.
Every slot in models should name an allowed model. Nothing stops
you writing a file where one does not: the runtime logs an error naming the
slot at startup and then runs, because a deployment that boots with an error
you can act on beats one that will not start. Until you fix it, turns resolve
to that model while the picker excludes it.
Once the file is loaded the runtime does hold the line — repointing a slot at a forbidden model, or clearing one so it falls back to a forbidden model, is refused. A user’s saved model preference needs no such care: one outside the list falls back to the configured default on their next turn.
providers
Section titled “providers”Per-provider API keys. With no providers block, the runtime uses Anthropic, keyed from ANTHROPIC_API_KEY.
{ "providers": { "anthropic": { "apiKey": "sk-ant-..." }, "openai": { "apiKey": "sk-..." }, "google": { "apiKey": "AIza..." } }}Each provider key is optional. If a provider’s apiKey is omitted, the runtime falls back to that provider’s standard env var (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, NEBIUS_API_KEY, XAI_API_KEY).
Nebius Token Factory
Section titled “Nebius Token Factory”nebius is an OpenAI-compatible gateway for open-weight models (Qwen, Kimi, GLM, gpt-oss). Configure it like any other provider and reference models with the nebius: prefix:
{ "providers": { "anthropic": { "apiKey": "sk-ant-..." }, "nebius": { "apiKey": "..." } }, "models": { "default": "anthropic:claude-sonnet-4-6", "fast": "nebius:openai/gpt-oss-120b" }}The key falls back to NEBIUS_API_KEY. Unlike the other providers, this key is required when nebius is configured — the runtime fails to start if neither providers.nebius.apiKey nor NEBIUS_API_KEY is set, because the Nebius adapter reads no environment variable of its own and a keyless provider has nothing to authenticate with. Override the endpoint with baseURL (default https://api.tokenfactory.nebius.com/v1). A curated set of flagship models ships in the catalog so they appear in the model picker with pricing; any other Nebius model id still routes as free text (nebius:<org>/<model>), just without catalog metadata. Nebius requests use the Chat Completions API and rely on the provider’s automatic prefix caching — the Anthropic-style explicit cache breakpoints don’t apply.
Reasoning models hosted here stream their thinking back and it is shown in the chat. Those blocks carry no provider signature, so a Nebius turn that hits the output limit mid-answer stops there and reports why, instead of automatically continuing the way an Anthropic turn does.
The exact model lineup is account- and tier-specific and changes over time. The authoritative list for your key is GET https://api.tokenfactory.nebius.com/v1/models; use those ids verbatim (they’re case-sensitive, e.g. Qwen/Qwen3-235B-A22B-Instruct-2507).
xAI (Grok)
Section titled “xAI (Grok)”xai reaches Grok over the Chat Completions API. Configure it like any other provider and reference models with the xai: prefix:
{ "providers": { "anthropic": { "apiKey": "sk-ant-..." }, "xai": { "apiKey": "xai-..." } }, "models": { "default": "xai:grok-4.5" }}The key falls back to XAI_API_KEY, and baseURL overrides the endpoint (default https://api.x.ai/v1). Seven models ship in the catalog: grok-4.7, grok-4.6, grok-4.5, grok-4.3, grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning, and grok-build-0.1. All seven support tool calling.
Reasoning support is uneven in a way worth knowing before you point a slot at one:
| Model | Effort tiers accepted |
|---|---|
grok-4.3 |
low, medium, high, plus none to suppress reasoning entirely |
grok-4.7 |
low, medium, high |
grok-4.6 |
low, medium, high |
grok-4.5 |
low, medium, high |
grok-4.20-0309-reasoning |
none — it reasons, but rejects the effort parameter |
grok-build-0.1 |
none — same |
grok-4.20-0309-non-reasoning |
none — does not reason |
Where a model takes no effort parameter, the runtime sends no reasoning options and the model applies its own default; asking for a tier would fail the call outright. grok-4.3 is the only model in an effort dialect where thinking: "off" sends a suppressing value rather than nothing — none genuinely zeroes the reasoning trace. (Gemini 2.5 models that can disable thinking also honor off, via a zero thinkingBudget; that is the budget dialect, not a tier.)
Two other notes. xAI publishes no per-model max-output cap and accepts max_tokens up to the full context window, so grok-4.7, grok-4.6, grok-4.5 and grok-build-0.1 are pinned to a 16,384-token output ceiling in the catalog — without it the message budget resolves to zero and every turn fails. And grok-4.20-multi-agent-0309 is deliberately absent: the Chat Completions endpoint refuses it (Multi Agent requests are not allowed on chat completions).
Conversation storage
Section titled “Conversation storage”Conversations are workspace-owned and persisted automatically — there is nothing to configure. Every turn is written to a per-conversation JSONL file at {workDir}/workspaces/<wsId>/conversations/<ownerId>/<convId>.jsonl in the event-sourced format (one append-only event log per conversation), which is what enables crash-resilient run replay and history compaction.
Scaling to multiple replicas
Section titled “Scaling to multiple replicas”Run platform.replicas: 1. More than one replica is not supported: the runtime assumes it is the only process on a tenant’s data, and no combination of settings makes a second replica safe today. With two replicas:
- Work runs twice. Every pod’s scheduler fires every due task, and every pod polls every notification outbox, so runs, posts and deliveries happen once per pod.
- Writes are lost. Workspace records and notification files are rewritten whole, with locks that hold within one process only.
- State goes stale between pods. A connector installed or removed on one pod, a deactivated user, a changed model config, and live events (including an in-flight chat turn) stay on the pod that saw them.
- Sticky routing does not fix this. Load-balancer cookie affinity pins a browser, not an MCP client or a webhook sender.
logging
Section titled “logging”{ "logging": { "dir": "~/.nimblebrain/logs", "disabled": false, "level": "normal", "retentionDays": 30 }}| Field | Type | Default | Description |
|---|---|---|---|
dir |
string |
{workDir}/logs |
Log directory. |
disabled |
boolean |
false |
Disable structured logging entirely. |
level |
"normal" | "debug" |
"normal" |
"debug" persists additional verbose fields. |
retentionDays |
integer |
— | Auto-delete log files older than N days on startup. Omit for no cleanup. |
connectors
Section titled “connectors”Managed-connector providers. See Connector Providers for what one is, how it differs from a connector, and which providers ship.
A provider is registered — and its callback routes, liveness probe, and vendor SDK reached — only when everything it needs to identify itself resolves: the broker credential for both, plus a namespace for Smithery (see its table below). A deployment without one carries no provider: the /v1/composio-auth/* routes are not mounted, no revalidation sweep runs, and the vendor SDK is never loaded.
{ "connectors": { "providers": { "composio": { "apiKey": "…", "authConfigs": { "gmail": "ac_xxxxxxxx" }, "baseUrl": "https://backend.composio.dev", "monitorEnabled": true }, "smithery": { "namespace": "my-namespace", "monitorEnabled": true } } }}connectors.providers.composio
Section titled “connectors.providers.composio”| Field | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
— | Platform-wide Composio API key — the broker credential. See The broker credential. |
authConfigs |
Record<string, string> |
{} |
Per-toolkit auth-config ids (ac_…), keyed by the toolkit slug the catalog entry names. See Per-toolkit auth configs. |
baseUrl |
string |
https://backend.composio.dev |
Composio API base URL override (self-hosted / staging). Must be http(s) — any other scheme is rejected at startup, because it would turn /v1/composio-auth/proxy into an open redirect. |
monitorEnabled |
boolean |
true |
Run Composio’s arm of the connection-revalidator probe, which detects connections whose downstream vendor account lapsed while the platform key stayed valid. |
The probe’s sweep cadence is not here. One revalidator serves every provider, so the interval is provider-agnostic and is set with NB_CONNECTION_REVALIDATE_INTERVAL_SECONDS (Environment Variables). Only the enable/disable is Composio’s.
Per-toolkit auth configs
Section titled “Per-toolkit auth configs”Every auth: composio connector needs an auth config created in the Composio dashboard (how). Its ac_… id varies per Composio account, so it cannot live in the catalog, which is shared across deployments.
"authConfigs": { "gmail": "ac_xxxxxxxx", "posthog": "ac_yyyyyyyy" }The key is the catalog entry’s composio.toolkit — an identifier that entry already carries, rather than a third name invented to carry the value. It is still matched exactly, so a mistyped key resolves to nothing; the runtime checks the whole set at boot and logs any key matching no catalog toolkit.
A toolkit you leave out is not reported — wiring a subset of the catalog is the normal case, so a standing boot warning listing the rest would only teach operators to ignore [composio]. Installing a connector whose toolkit has no id fails at that moment, naming the toolkit and the config key to set.
These are identifiers, not secrets: an ac_… is inert without apiKey, the way a client_id is inert without a client_secret. They live in config next to the toolkits they name rather than in your secret store.
One instance serves one tenant, so this is a single config layer: there is no per-tenant merge. Intra-tenant isolation is unchanged — the single broker account still serves every workspace and user in the tenant, namespaced by the Composio user_id.
connectors.providers.smithery
Section titled “connectors.providers.smithery”| Field | Type | Default | Description |
|---|---|---|---|
apiKey |
string |
— | Platform-wide Smithery API key. Falls back to SMITHERY_API_KEY. Its presence, with a namespace, is the gate on registering the provider at all. |
namespace |
string |
— | Smithery namespace brokered connections are created under. Required — a namespace is globally unique and account-owned at Smithery, so there is no safe default; without one the provider stays unregistered even with a credential present. |
baseUrl |
string |
https://api.smithery.ai |
Connect API base URL override. Must be http(s) — the session URL derived from it becomes an installed connector’s remote MCP target. |
monitorEnabled |
boolean |
true |
Run Smithery’s arm of the connection-revalidator probe. It detects a connection deleted at the broker; a lapsed authorization is logged, not flipped, until a verdict can carry a remedy URL. |
Smithery contributes no callback routes: it brokers OAuth on its own hosted setup page rather than redirecting back to us, so there is no equivalent of /v1/composio-auth/* to mount. See Connectors Catalog for authoring an auth: smithery entry.
The broker credential
Section titled “The broker credential”apiKey is the platform-wide Composio API key and the gate on registering the provider at all — without it (here or in COMPOSIO_API_KEY) the integration stays dormant.
It is declarable because nothing persisted points at where it lives. A connector installed through Composio records auth: { type: "provider", provider: "composio" } in workspace.json — it names the credential it needs, not a place to find it — so the key never sits at rest and resolution stays the provider’s own business. The key itself may be a credential reference rather than a literal.
The broker credential is the only env fallback
Section titled “The broker credential is the only env fallback”Settings resolve from this block only — there is no environment fallback for them. The broker credential is the exception: apiKey also reads COMPOSIO_API_KEY / SMITHERY_API_KEY, because a secret has a legitimate home in the environment. One value, one source, with that single documented exception.
NB_TENANT_ID is likewise not part of this block. It is a deployment identity, stays env-only, and is still required when the OAuth bouncer is configured.
notifications
Section titled “notifications”An app can declare an outbox — one resource carrying facts it recorded that nobody asked for, such as a domain going active or a reply landing. The runtime reads each declared outbox on a schedule and writes what it finds to the workspace’s notification inbox. What an outbox carries is the app’s decision and who may declare one is yours; what the poll costs is the runtime’s, and this block is where you move it.
{ "notifications": { "poll": { "intervalMs": 60000, "maxIntervalMs": 300000, "maxEvents": 100, "budgetPerMinute": 10 } }}| Field | Type | Default | Description |
|---|---|---|---|
poll.intervalMs |
integer (ms) |
60000 |
Base cadence per (workspace, connector). Floored at the fixed 15,000 ms poll floor. |
poll.maxIntervalMs |
integer (ms) |
300000 |
Backoff ceiling — the slowest an idle outbox is read, and the interval an open circuit breaker half-opens on. Never resolves below intervalMs. |
poll.maxEvents |
integer |
100 |
Events requested per read. Capped at 1000. |
poll.budgetPerMinute |
integer |
10 |
Polls allowed per workspace per minute, across every connector in it. |
Nothing here is required: a deployment that sets no block polls at these defaults, and one where no app declares an outbox polls nothing at all.
The cadence is adaptive, and an app may recommend one
Section titled “The cadence is adaptive, and an app may recommend one”Each outbox is read on its own schedule, not on a fleet-wide tick:
- Consecutive reads that return no events double the interval, up to
maxIntervalMs. A read that returns events snaps straight back tointervalMs. - An answer carrying
nextPollMsis honoured, clamped between a fixed 15-second floor andmaxIntervalMs. An app watching something that is about to change asks to be read sooner this way; one that is rate-limited upstream asks to be read less. - An answer carrying
hasMore: truemeans the batch was cut atmaxEvents, and the runtime reads again immediately — out of the same budget, because it is the same cost. - An outbox that fails three reads in a row is read once per
maxIntervalMsuntil it answers, rather than on every tick.
The 15-second floor is not configurable. It bounds how much of a tenant’s request budget a standing background loop can consume, and an app that wants to be read faster than that wants a push rather than a poll.
The budget, and why the default is what it is
Section titled “The budget, and why the default is what it is”budgetPerMinute is a per-workspace allowance, not a global one. The default is
derived rather than picked: the poll may take about a quarter of a tenant’s
request rate, and one poll costs up to three requests when it has to re-establish
an idle-closed connection first — so the allowance buys a third as many polls as
requests.
When a workspace’s allowance runs out mid-pass, the connectors it did not reach are deferred to the next tick and the pass resumes with them. A workspace with more outboxes than budget reads all of them, more slowly — it never reads the first few forever.
Guardrails on task runs: how many the runtime holds in flight and waiting, across every workspace, and the most any one run may spend.
{ "tasks": { "maxConcurrentRuns": 2, "maxQueuedRuns": 50, "maxRunIterations": 50, "maxRunInputTokens": 20000000, "maxRunDurationMs": 600000 }}| Field | Type | Default | Description |
|---|---|---|---|
maxConcurrentRuns |
integer |
2 |
Unattended runs in flight at once: scheduled, Run now, and event runs, and any other run started without a person in the loop. Chat turns do not take a slot. Range: 1–100. |
maxQueuedRuns |
integer |
50 |
Run now and event runs held waiting for a slot. A freed slot goes to the waiting run whose workspace holds the fewest slots, oldest first among equals. Beyond it a run is refused and recorded as skipped. 0 refuses at the limit. Range: 0–1000. |
maxRunIterations |
integer |
50 |
Ceiling on one run’s agentic iterations. Range: 1–50. |
maxRunInputTokens |
integer |
none | Ceiling on one run’s input tokens, summed over every model call. Unset, there is no ceiling. Range: 1,000–100,000,000. |
maxRunDurationMs |
integer (ms) |
600000 |
Ceiling on one run’s wall-clock time. Range: 10,000–600,000. |
The three per-run ceilings apply when a run starts, so they cover every stored task however it was written. A cap a task sets above its ceiling is lowered to it; one it leaves unset takes the runtime default (the chat maxIterations for iterations, 120 seconds for duration) held to the ceiling. Input tokens have no runtime default: a task with no maxInputTokens runs under maxRunInputTokens when it is set, and with no input-token cap when it is not. The create and update tools report the caps that result.
A scheduled run that finds every slot busy is deferred rather than queued: it keeps its next-run time and runs on the first scheduler tick with a free slot, so its place survives a restart. The queue is held in memory: runs still queued when the runtime stops are recorded as skipped.
File upload limits applied when features.fileContext is enabled.
{ "files": { "maxFileSize": 26214400, "maxTotalSize": 104857600, "maxFilesPerMessage": 10, "maxExtractedTextSize": 204800 }}| Field | Type | Default | Description |
|---|---|---|---|
maxFileSize |
integer (bytes) |
26 MB | Maximum size of a single uploaded file. |
maxTotalSize |
integer (bytes) |
100 MB | Maximum total size of all files per message. |
maxFilesPerMessage |
integer |
10 | Maximum number of files per chat message. |
maxExtractedTextSize |
integer (bytes) |
200 KB | Maximum extracted text size per file. |
An upload to the workspace file store (the Files app, or an app’s file picker) is not a chat message: maxFilesPerMessage does not apply to it. It takes up to 100 files at a time, each within maxFileSize and all together within maxTotalSize. The chat composer shows the per-message limits before you send.
telemetry
Section titled “telemetry”NimbleBrain collects anonymized, aggregate usage data with no PII — no connector names, tool names, error messages, or file paths are sent.
{ "telemetry": { "enabled": false } }Disable with NB_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1 env vars, or set telemetry.enabled: false as shown above.
Validation
Section titled “Validation”NimbleBrain validates nimblebrain.json at startup against the JSON Schema (draft-07) using AJV.
- Unknown keys produce a warning on stderr and are ignored.
- Structural errors (wrong types, missing required fields) throw an error and prevent startup.
- Workspace-owned keys (
skillDirs,home,preferences) warn as unknown keys and are stripped before the runtime config is built. - The override file (
nimblebrain.overrides.json) is validated structurally, but unknown-key warnings are suppressed for it. Every keyset_model_configwrites is in the published schema; the suppression is for version drift, since the file is preserved across deploys and can outlive the schema of whichever image reads it.
[config] Unknown key "foo" in /home/user/.nimblebrain/nimblebrain.json (ignored)Error: Invalid config in /home/user/.nimblebrain/nimblebrain.json: - /maxIterations: must be <= 50CLI flag overrides
Section titled “CLI flag overrides”CLI flags take precedence over values in the config file:
| Flag | Overrides |
|---|---|
--config <path> |
Config file location |
--workdir <dir> |
Default location of the config file and workDir fallback |
--port <n> |
http.port field |
--debug |
Enables verbose debug event logging |
Minimal config
Section titled “Minimal config”The smallest valid config file:
{ "$schema": "https://schemas.nimblebrain.ai/v1/nimblebrain-config.schema.json", "version": "1"}This uses all defaults: Anthropic provider (key from ANTHROPIC_API_KEY), default model, no connectors installed (platform capabilities are built in), in-memory conversation storage, and structured logging enabled.