Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

## [Unreleased]

### Highlights

- **Every secret goes through one door.** `CredentialStore` now takes a scope — `instance`, `workspace`, or `user` — and config carries a `{ ref: "credential", key }` reference instead of a value. A connector's `transport.auth` token, its arbitrary headers, and the instance-level provider / broker / gateway / IdP keys all accept a reference; the built-in `credential` credential provider lets one catalog entry install into two workspaces and have each present its own customer-owned secret. Rotation is a `put` on the same key — no config edit, no restart. Every secret the platform presents emits `audit.credential_read` (scope, key, caller, purpose — never the value). Workspace admins manage keys with `manage_connectors set_secret` / `delete_secret` / `list_secret_keys`. **Breaking:** `${VAR}` expansion in a connector's transport headers is gone — see Breaking. [Docs](https://docs.nimblebrain.ai/config/secrets/).

- **The runtime orchestrates over remote MCP. It no longer acquires, verifies, or executes server code.** A connector is one shape: a URL plus its transport, OAuth and skills-lock fields. The by-name and by-path `BundleRef` variants are gone, and with them the registry client, the download-unpack-spawn path, the per-bundle credential store the spawn resolved from, the host env allowlist that fed a subprocess, and the install-time manifest gate. Supply-chain review belongs before the process that would run the code exists — at image build and publish — not in a pod that also holds tenant credentials. Discovery stays: the connectors catalog is unchanged, and a `ServerDetail` entry advertising a `remotes[]` endpoint installs exactly as before. **Breaking** for any workspace still carrying a `name:` or `path:` entry — see Breaking.

- **Tool names are bare on the wire.** A workspace tool is now `<source>__<tool>` — the `ws_<id>-` prefix is gone. It carried no information a session did not already have (a session is walled to one workspace), and at 39 opaque characters for a personal workspace it was a constant the model had to echo byte-exact on every call. Models on non-Anthropic providers dropped it, which failed every workspace tool call for that tenant. Personal connectors now carry a reserved `my_` marker (`my_gmail__send`) so they stay distinguishable from a workspace source of the same name — the two are different credentials. **Breaking for `/mcp` clients**, below; no data migration.

- **New visual identity.** NimbleBrain Blue (`#315EDB`) is now the single accent on pure white / pure black grounds, set in Hanken Grotesk with a screen-text serif (Newsreader) reserved for agent prose in chat. The terracotta `warm` accent is **removed** — blue carries every primary action, focus ring, and live state, so the brand hue means one thing. The web faces are served from Google Fonts (`fonts.googleapis.com` / `fonts.gstatic.com`) rather than Fontshare — self-hosted deployments behind an egress allowlist or a font-blocking CSP will fall back to `system-ui` unless those hosts are permitted. A contrast guard (`web/src/theme/__tests__/contrast.test.ts`) now derives WCAG ratios from the palette itself; it caught the outgoing palette's accessibility failures on the way in, including warning text at 2.15:1 and tertiary metadata at 2.40:1. The logo is redrawn to match, with its wordmark in Hanken Grotesk, and ships as SVG with light and dark variants and a full favicon set across the web client and docs.

- **Files are workspace-owned storage.** An uploaded or agent-created file now lives under the workspace it was made in — `workspaces/<wsId>/files/<ownerId>/` — so the directory is the boundary: a file created in one workspace can't be read from another, and deleting a workspace archives its files with it. The `files://<id>` URI stays bare; the workspace comes from the request, and the browser serve endpoint is bare too — `GET /v1/files/:id` resolves the workspace from the globally-unique id (within the caller's own files), no `?ws=` needed. **Breaking** — upgrade floor: v0.10.0, whose workspace-owned layout a deployment must hold before upgrading.
- **Conversations are workspace-owned storage.** Every conversation now lives under the workspace it runs in — `workspaces/<wsId>/conversations/<ownerId>/<convId>.jsonl` — so the directory *is* the boundary, not a soft `workspaceId` breadcrumb. Workspace-scoped conversation lists become directory listings, deleting a workspace archives its chats with it, and a chat cannot carry one workspace's context into another. **Breaking** — upgrade floor: v0.10.0, whose workspace-owned layout a deployment must hold before upgrading.
- **Automations are workspace-owned, and a run produces a result — not a conversation.** Each automation now lives at `workspaces/<wsId>/automations/<ownerId>/<automationId>.json`, so the directory is the boundary and a workspace delete archives its automations with it. A scheduled run no longer creates a chat: it leaves a *run result* — the deliverable, the activity log, and refs to any files it wrote — under `…/automations/<ownerId>/runs/<automationId>/` (an `index.jsonl` of run summaries plus a per-run `<runId>.result.json`). `runtime.executeTask` returns a `runId` instead of a `conversationId`. **Breaking** — upgrade floor: v0.10.0, whose workspace-owned layout a deployment must hold before upgrading.
- **Composio-backed Gmail and Outlook connectors** (stopgap until our own restricted-scope verification at Google / Microsoft lands). New `auth: composio` discriminator in the catalog routes through `@composio/core` as a remote OAuth aggregator: install → adopt-or-create Composio session → user does vendor OAuth → tool calls flow through Composio's MCP endpoint. The platform persists only an opaque `connectedAccountId` per workspace; no vendor tokens ever land in `workspace.json`. Catalog entries carry a curated `composio.tools` allowlist so the agent's tool-search sees a working set instead of every tool the toolkit publishes (Outlook alone has 282). Operator setup is one secret — the broker API key — for the whole integration; adding a new Composio toolkit is one catalog entry + one line of config (no code change). See the [Composio operator guide](https://docs.nimblebrain.ai/deploy/composio/) for end-to-end setup.
- **Platform sources unified on MCP.** Every tool/resource provider — built-in platform capabilities and user-installed bundles alike — is now an MCP server. Built-ins run in-process over `InMemoryTransport`; bundles continue to run as subprocess or remote MCP. One contract, one shape, one set of capabilities.
- **Task-aware iframe tools** — widgets can now `callToolAsTask` for long-running tools (research runs, batch imports), over the MCP tasks extension on the `2026-07-28` leg of `/mcp`. The iframe bridge always routes `tools/call` and `resources/read` through `/mcp` (legacy REST branches in the bridge are gone). The workspace's `tools/call` and `resources/read` REST endpoints stay for the web shell.
- **Bundle-side custom instructions.** Bundles publish `app://instructions`; the platform reads it on every prompt assembly and wraps non-empty bodies in `<app-custom-instructions>` containment in the system prompt. Zero opt-in flag, zero new platform-side infrastructure per bundle ([docs](https://docs.nimblebrain.ai/apps/custom-instructions/), [#98](https://github.com/NimbleBrainInc/nimblebrain/pull/98)).
- **Settings IA refactor.** Nav grouped by scope (This Workspace / Organization). Profile un-nested to top-level `/profile`. New shell `UserMenu` (avatar + popover) is the canonical path for identity actions; sidebar collapse moved to a half-overflow edge button. Old URLs redirect.
- **Connector registries unified on upstream MCP `ServerDetail`** — both the bundled curated catalog and mpak now flow through one wire shape and one client-side projection. mpak is consumed via `@nimblebrain/mpak-sdk@0.8.0`'s `searchServers` (native `/v1/servers/search`), so brand icons land in the Browse list, in installed-stdio-bundle rows, and on the Configure page through one `iconUrl` field — no more parallel stdio-catalog YAML lookup ([#195](https://github.com/NimbleBrainInc/nimblebrain/pull/195)).
- **`ConnectorDirectory` facade** — single read-side seam for the registry layer. Sources (`StaticSource`, `MpakSource`) implement just `fetch(): Promise<ServerDetail[]>`; the directory owns scope filtering, projection, error aggregation, dedup, and the lookup tables (`catalogByUrl`, `iconByPackage`, `catalogById`). Adding a new source type is a ~20-line `fetch()` body with everything else inherited. Replaces `DirectoryAggregator` + the four standalone helper functions that previously bypassed the registry instances.
- **`scopes[]` filter on `RegistryConfig`** — restrict a registry instance to one or more namespaces. Match is OR-of-prefixes against either the reverse-DNS prefix of `ServerDetail.name` (e.g. `ai.nimblebrain` matches `ai.nimblebrain/echo`) or the npm scope of `packages[].identifier` (e.g. `nimblebraininc` matches `@nimblebraininc/echo`). Operators can run multiple mpak rows with different scopes side-by-side. The seeded mpak row defaults to `scopes: ["nimblebraininc"]` — narrow-by-default for first installs; broaden by editing the row.
- **15 new DCR remote-OAuth connectors in the curated catalog** — Linear, Canva, Cloudflare, Intercom, Mercury, Neon, PayPal, Sentry, Stripe, Vercel, Webflow, Wix (workspace-scoped, all DCR — point-and-click installs, no operator setup). Granola moves from user to workspace scope so teams share one connection. Notion was already in the catalog. Brand icons sourced from `static.nimblebrain.ai/icons/<name>.png`.
- **Connector serverName slugified from canonical reverse-DNS form.** A new `slugifyServerName(canonicalName)` rule transforms `ServerDetail.name` (e.g. `com.canva/mcp`) into a URL-safe, filesystem-safe, collision-free single-segment slug (`com-canva-mcp`) at install time. Both source types use the same rule so a connector's `serverName` is uniform regardless of whether it came from the curated catalog or mpak. The whole-namespace preservation eliminates the rightmost-segment collision class (`com.acme.crm/mcp` and `com.foobar.crm/mcp` produce distinct slugs, where last-segment derivation would have collapsed both to `crm`).
- **Curated connector catalog is deployment config, not image code.** The catalog moves out of the compiled image into a mounted directory (`NB_CURATED_CATALOG_DIR`), so changing the connector set is a ConfigMap change rather than a code release, and production curation stays out of the OSS repo. `StaticSource` reads a single file or a directory of catalog files (split curation rolls up to one registry); the image ships a minimal DCR-only example as the locked fallback so fresh installs aren't empty. The DCR rot-detector (`scripts/check-catalog-dcr.ts`) becomes a path-argument CLI tool; its automated gate re-homes to the deploy repo alongside the real catalog.
- **Conversations moved to top-level user-owned storage** (Stage 1). Every conversation now lives at `{workDir}/conversations/{convId}.jsonl` instead of `{workDir}/workspaces/{wsId}/conversations/...`, with single-owner authorization (`Conversation.ownerId === access.userId`). Multi-participant / sharing semantics are deferred to a future stage with explicit policy gates. **Breaking**; superseded by workspace-owned conversations above.
- **Workspace-bounded tool reach (the wall).** A chat or task session reaches exactly one workspace's tools plus the caller's identity tools — never a cross-workspace union. The cross-workspace tool aggregator and its watcher cache are removed; `routeToolCall` denies any workspace other than the session's (`CrossWorkspaceReachDenied`). User-scope credentials live in the personal workspace; `oauthScope: "user"` is deleted. Header switcher → sidebar workspace+apps navigator. **Breaking** — upgrade floor: v0.10.0, whose workspace-owned layout a deployment must hold before upgrading; see Breaking for the `/mcp` impact.
- **Files and automations are now identity-owned** (Phase B / C). A file or automation belongs to the user, not a workspace: files live at `users/{userId}/files/`, automations owner-partitioned at `users/{userId}/automations/`, both surfacing as bare identity tools (`files__*`, `automations__*`) reachable from any workspace. A `files://` URI resolves from the owner's store regardless of which workspace created it; a scheduled automation **fires as its owner**, walled to its provenance workspace (the scheduler is now multi-owner). **Breaking**; superseded by workspace-owned files and automations above.
- **CLI slimmed to `bun run start` + `bun run dev`.** The Ink TUI, the operator subcommands, and the `nb` binary are gone; the runtime is launched via bun and its interfaces are the web UI and `/mcp`. See Removed.

### Added

- **Apps learn which of a connector's tools the viewer may call.** The host context's `connector` key carries `allowedTools` on the connector's settings page and on the app's own page, so an app can leave out an action the viewer would be refused ([docs](https://docs.nimblebrain.ai/host/settings-sections/#which-tools-the-viewer-may-call), [#1879](https://github.com/NimbleBrainInc/nimblebrain/pull/1879)).
- **Apps get the host's page gutter.** Every app frame's injected style block carries `--nb-gutter-wide` (1.5rem) and `--nb-gutter-narrow` (1rem), which `AppFrame` in `@nimblebrain/synapse` 0.38.0 picks between by its own width ([docs](https://docs.nimblebrain.ai/apps/theming/#nimblebrain-extensions---nb-), [#1887](https://github.com/NimbleBrainInc/nimblebrain/pull/1887)).
- **A tool can take another connector's records by URI.** A tool marks which arguments hold resource URIs (`ai.nimblebrain/resource-arguments` in its `_meta`); before the call, the host reads each from the connector in the same workspace that serves it and sends the contents in the call's `_meta`, on both protocol eras. [Docs](https://docs.nimblebrain.ai/extensions/resource-arguments/).
- **One OAuth client for the whole instance.** `connectors.oauthClients` declares an `auth: static` connector's client id and secret once, so it installs in every workspace without **Set up** and can be a personal connector, each person signing in with their own account. The client is bound to the URL the catalog publishes for it. [Docs](https://docs.nimblebrain.ai/config/connectors-catalog/#an-oauth-client-for-the-whole-instance).
- **Claude Haiku 5.5, and prices that change with prompt length.** `anthropic:claude-haiku-5-5` is in the model catalog and takes adaptive thinking with an effort tier. A catalog model can declare rate cards for longer prompts (Haiku 5.5 above 100K tokens, xAI's Grok models above 200K), and cost recording bills each call on the card for its prompt length.
- **A task sets its own reasoning depth.** `thinkingEffort` on `tasks__create` / `tasks__update` (`null` clears it) replaces the instance's `thinkingEffort` for that task's runs, so a frequent, mechanical task can reason less without changing chat. An operator `thinking` of `off` or `adaptive` still wins. [Docs](https://docs.nimblebrain.ai/using/tasks/#limiting-spend-and-tools).
- **Apps and the agent open things by URI.** `ai.nimblebrain/open { uri }` and `nb__open_app { uri }` open a conversation (`conversation://<id>`), an app's home (its `ui://` resource), or the record of whichever app advertises a resource template matching the URI; the host answers whether it opened. `ai.nimblebrain/open-connector-settings` opens the sender's own settings. Needs `@nimblebrain/synapse` 0.33.0 ([ADR-0052](adr/0052-a-view-opens-what-it-names-by-uri.md)). [Docs](https://docs.nimblebrain.ai/apps/bridge/#ainimblebrainopen).
- **Platform tools declare their output.** Every tool of the built-in apps (skills, files, conversations, tasks, notifications, usage, hooks, compose), and `nb__use_skill`, publishes an `outputSchema` on `tools/list` and returns matching `structuredContent`, so a client, `/mcp/<wsId>` included, reads a result without parsing text.
- **A connector's prompt can ask an outside client for input.** `prompts/get` through `/mcp/<wsId>` relays a connector's `input_required` answer as `tools/call` does. [Docs](https://docs.nimblebrain.ai/mcp/protocol-support/#as-a-server-to-external-clients).
- **`/mcp/<wsId>` serves its connectors' prompts and completions.** Each prompt is named `<source>__<prompt>`, as tools are. [Docs](https://docs.nimblebrain.ai/mcp/protocol-support/#as-a-server-to-external-clients).
- **`/mcp/<wsId>` serves its connectors' instructions and skills.** `server/discover` answers each connector's server instructions under its source name, and the MCP Skills extension (`skills/list`, `skills/get`) lists the skills the workspace's connectors serve, so a client outside NimbleBrain learns how their tools are meant to be used. [Docs](https://docs.nimblebrain.ai/mcp/protocol-support/#as-a-server-to-external-clients).
- **Edit a user from Organization → Users.** An org admin changes a user's display name, email, and role, each saving as it changes; under WorkOS the name and admin/member role are written to WorkOS, and email is locked under WorkOS and OIDC ([docs](https://docs.nimblebrain.ai/using/users/#what-an-edit-can-change)).
- **The agent reads a connector's records whole.** A connector's resource templates are listed under it in the system prompt, `nb__read_resource` returns a non-skill resource up to the 50,000-character tool-result limit, and the `tool.done` log line ends with the result's size ([ADR-0049](adr/0049-the-agent-reads-a-connectors-records-as-resources.md)).
- **`/mcp/<wsId>` carries the caller's side of a connector call.** A connector sees the outside client's capabilities, its `input_required` answers (elicitation, sampling, roots) reach the client and the client's answers come back, its `-32021` reaches the client as it is, and its progress arrives under the client's token. Each relayed elicitation names its connector, and the `requestState` the client holds is signed and bound to the caller, workspace, tool and arguments ([ADR-0050](adr/0050-a-servers-url-request-reaches-a-person-or-comes-back-as-a-link.md)). [Docs](https://docs.nimblebrain.ai/mcp/protocol-support/#as-a-server-to-external-clients).
- **Every OAuth connector can show which account it is signed in as.** A catalog entry's `account` block names the tool to ask a service whose sign-in does not say (no OIDC, a broker that records none); the runtime asks once and keeps the answer with the connection. See [Showing the connected account](https://docs.nimblebrain.ai/config/connectors-catalog/#showing-the-connected-account) ([#1612](https://github.com/NimbleBrainInc/nimblebrain/issues/1612)).
- **Task batches.** `tasks__run_batch` runs one task over up to 10,000 inputs, every item checked up front, with a concurrency limit, a whole-batch budget enforced before every model call, and a stop rule that pauses on a collapsed pass rate; `tasks__batch` pages its results, `tasks__batch_control` pauses, resumes, cancels, or re-runs failed items, and the Tasks panel shows each batch with its results ([docs](https://docs.nimblebrain.ai/using/tasks/#batches)).
- **Task runs are judged.** A task names acceptance criteria (boolean, score, or choice), a confidence threshold, and what a poor result does (`record`, `notify`, `retry_once`); each run that leaves a deliverable is checked against its output schema, then judged by a judge server the workspace connected, and reads Succeeded, Poor result, or Needs review. `tasks__assess` records your verdict or judges a run again ([docs](https://docs.nimblebrain.ai/using/tasks/#judging-results)).
- **A server declares its lifecycle handlers on the wire.** The host claims `ai.nimblebrain/lifecycle`; a server that advertises it marks its `ready` and `removing` handlers with `_meta["ai.nimblebrain/lifecycle"]`, and those are called and withheld ([docs](https://docs.nimblebrain.ai/extensions/lifecycle/)).
- **Folders, search, and filters in Files.** Files sit in folders you drill into, with the path in the top bar; search and the kind, source, and date filters run on the server across every file, not the newest 200; files move in bulk; the list pages 50 at a time; and uploads take files dropped on the view as well as picked (`ai.nimblebrain/upload-files`, a new bridge extension). Agents get `files__move`, `files__create_folder`, `files__update_folder`, `files__delete_folder`, and a `folder` path on `files__create`, and every file an agent writes records its conversation or run. [Docs](https://docs.nimblebrain.ai/using/files/).
- **A connector can name tools only a workspace admin may call.** `admin_tools` in `_meta["ai.nimblebrain/host"]`, read from the catalog only: those tools leave a non-admin's tool listings and are refused on every path with `workspace_admin_required`, before the call reaches the server. A malformed list fails closed: every tool on that connector becomes admin-only ([docs](https://docs.nimblebrain.ai/extensions/admin-tools/)).
- **Facets: apps report open counts over MCP.** The platform claims `ai.nimblebrain/facets` on both protocol eras; a server that advertises it lists each count as a resource marked `_meta["ai.nimblebrain/facets"]` and answers `resources/read` with `{ "count": n }`. `nb__briefing` returns `{ items, generated_at }`, one `<count> <title>` item per non-zero facet, read per facet with a 5-second timeout and a 60-second cache, with no model in the path. See [Facets](https://docs.nimblebrain.ai/apps/facets/).
- **Automation runs that finished with lost work read `degraded`, not `success`.** A run whose failed tool call no later call made good (an email not sent, a record not written) now records `status: "degraded"` naming the tools, whatever its final answer claims, and `nb_automation_runs_total{status}` counts every run by status ([docs](https://docs.nimblebrain.ai/using/automations/#run-history)).
- **MCP Skills Extension (SEP-2640).** The platform claims `io.modelcontextprotocol/skills` on both protocol eras, discovers a server's skills from `skills/list`, fetches a `SKILL.md` only when the skill is composed or activated, and uses it only when it matches the listed digest, size, and frontmatter. A `2026-07-28` server must declare the extension; a 2025-era server is asked regardless, since the legacy handshake may not carry the declaration ([docs](https://docs.nimblebrain.ai/using/skills/#skills-published-by-an-mcp-server)).
- **Sealed-secret failures are metrics.** `nb_credential_seal_failures_total{reason}` counts every secret that failed to open, re-seal, or was refused as plaintext, and `nb_credential_store_plaintext_accepted` reads `1` while a sealed deployment still accepts plaintext, and `nb_credential_store_sealed` reads `0` for a deployment with no sealing key ([docs](https://docs.nimblebrain.ai/deploy/observability/#credential-store-metrics)).
- **The runtime speaks MCP `2026-07-28`, and still speaks 2025.** Every connector connection negotiates its protocol revision (`server/discover`, falling back to the 2025 `initialize`), logs the version it landed on, and exposes the extensions the server advertised. `/mcp/<wsId>` serves a `2026-07-28` client per request and a 2025-era client in its session as before, and the two are equivalent apart from tasks: long-running tools run as tasks through the tasks extension on `2026-07-28` only ([docs](https://docs.nimblebrain.ai/connect/mcp-endpoint/#protocol-versions)).
- **Tool visibility follows the MCP Apps spec.** A tool's `_meta.ui.visibility` is honored for every tool, a connector's included: without `"model"` it is left out of every agent tool list (chat, `nb__search`, `/mcp` `tools/list`) yet stays callable by name, and an app's `tools/call` to a tool without `"app"` is refused ([docs](https://docs.nimblebrain.ai/apps/bridge/#toolscall)).
- **White-label branding.** A `brand` block in `nimblebrain.json` sets the product name, logos, accent colour, fonts and default colour theme. The runtime rejects at startup an accent that fails any contrast pair the canonical palette is held to, and serves the block unauthenticated at `GET /v1/brand.js`, a script the web client loads before its bundle. The web shell and embedded apps paint it before the first render: logo, tab title, favicon, accent and fonts. The agent's identity, OAuth consent screens, and the OAuth success and error pages carry the brand name; connections registered before a change keep the old consent-screen name until reconnected ([docs](https://docs.nimblebrain.ai/config/branding/)).
- **Organization → Archives.** Org admins see every deleted workspace's archive (name, ID, size, archived-at) and can purge one permanently, behind a confirm. Nothing is purged automatically ([docs](https://docs.nimblebrain.ai/guide/settings/#archives-orgarchives)).
- **Three spec requests an app could send but the host never answered.** `ui/download-file` hands the user a file as MCP resource blocks (embed the bytes; a `ResourceLink` is refused rather than fetched, which would make the host an SSRF proxy), `ui/request-display-mode` reports the mode actually in effect, and `notifications/message` reaches the browser console. `downloadFile` is now advertised alongside the `logging` capability that was already claimed ([docs](https://docs.nimblebrain.ai/apps/bridge/)).
- **An app can be told it was installed, and that it is being removed.** A server that advertises `ai.nimblebrain/lifecycle` marks two of its own tools: a `ready` handler, called with `{ reason: "install" | "resume" }` the first time the app is reachable in a workspace, and a `removing` handler, called just before the connector is torn down — the only moment an app can release per-workspace state it holds at a third party. Delivery is at least once (a fresh install delivers two `ready` calls), so handlers must be idempotent; a failed `ready` call never fails the install, and a failed `removing` call never fails the uninstall — which waits a few seconds for the call and then proceeds without it. The `ready` handler's text result is surfaced to the user as a notice on the install ([docs](https://docs.nimblebrain.ai/extensions/lifecycle/)).
- **`customer-communication` core skill, always-on for every workspace.** The agent talks about the customer's goal and the work, not the tool names, IDs, schemas, and internal state it used to get there. Ships with the platform alongside `soul`; override or disable it by authoring a skill of the same name at org, workspace, or user scope ([docs](https://docs.nimblebrain.ai/using/skills/#platform-defaults-and-overriding-one)).
- **The notification inbox fills itself.** An app that declares an outbox — one MCP resource carrying facts it recorded that nobody asked for — is now read on a schedule, and what it returns lands in the workspace's inbox. The cadence is per `(workspace, connector)` and adaptive: empty reads back off toward a ceiling, a read that returns events snaps back to the base, and an app may recommend its own cadence inside a fixed 15-second floor. A per-workspace budget bounds the whole loop against the tenant's request rate, deferring in round-robin rather than starving. Configured under `notifications.poll` ([docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#notifications)) and metered by `nb_notifications_*` ([docs](https://docs.nimblebrain.ai/deploy/observability/#notification-poll-metrics)). Nothing is routed or delivered yet — an item reaching the inbox is where this stops. `replicas: 1` remains the supported topology.
- **`nb__status scope:skills` reports what the always-on channel costs, per tier.** Every skill in that channel is paid on every turn, and nothing aggregated that per tier — the per-skill figures were reachable only through `compose__assembled_context`, which reads one conversation's recorded run events, so it needs a turn to have happened and returns a list rather than a rollup. That is how a workspace came to carry ~13k tokens of one operator's personal doctrine without anyone noticing. User-tier skills follow their author into every workspace by design, so a skill authored at the wrong scope is not wrong, just invisible. Splitting the report by tier makes a lopsided workspace legible; moving a skill to the right scope is already `read` → `create` at the new scope → `delete`.
- **`usage__report --group-by turn`** — per-turn spend. The ledger stored one id per call (`sessionId`) holding either a conversation or an automation run, so the finest question it could answer about a chat was what a whole thread cost. Each id now lands under its own name (`conversationId`, `taskRunId`, and the engine's per-turn `runId`, which pairs with the existing `parentRunId` to form the delegation tree), and `turn` groups by the last of those. Records written before the split are still read correctly; they group under `none` for `turn`, having no turn recorded.
- **A skill enumeration that comes back short now says so.** When a bundle's `resources/list` fails mid-walk, hits the pagination ceiling, lists a skill whose read then fails, or a source the lifecycle believes is running is missing from the workspace registry, the runtime emits a `skills.composition.degraded` warn carrying the workspace, the server, a machine-readable reason, and how many skills it did recover — so an alert can key on it. Silent degradation was the defect behind the skills-composition incident: a workspace composed none of the `always` skills its bundles publish and nothing anywhere distinguished that from having no skills at all. None of the short results is cached as complete, so the condition stays visible instead of being swallowed by the discovery TTL for five minutes at a time.
- **`skills__history` and `skills__restore`** make a skill's saved versions reachable. Every `update`, `delete`, and `restore` already snapshotted the file to `_versions/` first, but nothing could list or read that directory, so the backup was unreachable from any path an agent or operator had. `skills__history` lists the snapshots, `skills__read` takes a `version` to show one, and `skills__restore` puts one back — snapshotting the current file first, so a restore is itself undoable. Partially addresses [#121](https://github.com/NimbleBrainInc/nimblebrain/issues/121) (`skills__diff` is still open).
- **Reasoning depth is a first-class setting, and it works on every provider.** A new `thinkingEffort` (`low` … `max`) says how hard to think, and the runtime translates it per provider: an effort tier to Anthropic's Opus 4.7/4.8 and 5-series and to OpenAI/Nebius-hosted models (Qwen, Kimi, GLM, gpt-oss) (on OpenAI, per-model — the GPT-5 `-pro` models accept only part of the ladder and `gpt-5-pro` takes `high` alone, while `o1-pro`/`o3-pro` take all three, so a tier it rejects steps down or is dropped rather than 400ing the call), a token budget sized from it to Anthropic up to 4.6 and to Gemini 2.5, `thinkingLevel` to Gemini 3 (per-model — support varies within a generation, and a Gemini model with no verified entry receives nothing rather than a guessed dialect), and an effort tier to xAI (per-model — two Grok models take no effort parameter at all, and `grok-4.3` accepts `none`, making it the only model in an effort dialect where `thinking: "off"` sends a suppressing value rather than nothing). OpenAI and Google reasoning were previously **ignored entirely** — the translation layer was Anthropic-only. The default depth is `medium`, which is deliberately cheaper than most provider defaults; on budget-shaped Anthropic models it also shrinks the derived thinking budget (Sonnet 4.6 at a 16,384 output ceiling: 12,288 → 4,300). Set `thinkingEffort` to restore a deeper default. `thinkingBudgetTokens` still works and takes precedence where a provider meters tokens ([docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#reasoning-depth), [#774](https://github.com/NimbleBrainInc/nimblebrain/issues/774)).

  This replaces a derivation that ran the other way. Effort used to be inferred from a token budget, and with no budget configured the only number in scope was the model's own catalog ceiling — so an adaptive-only model got a "reason as hard as possible" directive on every call, derived from a figure nobody chose. Nothing in the resolver reads `maxOutputTokens` now. **Behavior change:** a `thinking` override is now honored only for models the catalog carries and flags reasoning-capable. Previously an explicit `enabled` bypassed that check, which was inert while non-Anthropic providers were dropped and became a live send of a parameter the model doesn't take once they weren't. If you pin a bespoke model id or point `providers.openai.baseURL` at a proxy with its own model names, add the model to the catalog to keep reasoning on it — the runtime logs a warning naming the model when it drops an override. Two further consequences: `xhigh` is reachable on Anthropic for the first time (no token band ever produced it; OpenAI and Gemini cap at their own `high`), and `thinking: "enabled"` no longer requires `thinkingBudgetTokens` — it falls back to the default tier, so that combination is now accepted where it used to be rejected.

- **The home briefing no longer breaks on most OpenAI models, or on Gemini 3.** It asked for the `minimal` reasoning tier on every OpenAI reasoning model, and 22 of the 25 reachable ones reject it outright; on Google it sent a `thinkingBudget`, which is the Gemini 2.5 dialect that Gemini 3 rejects. Either way the briefing 400'd on each home load for anyone whose fast slot pointed at an affected model. It now sends each model only a dialect that model accepts — the cheapest reasoning level a Gemini 3 model offers, and nothing where the model has no accepted way to ask for less ([#800](https://github.com/NimbleBrainInc/nimblebrain/pull/800), [#816](https://github.com/NimbleBrainInc/nimblebrain/pull/816)).

- **Claude Opus 5 is selectable** ($5/$25 per 1M, 1M context, 128K output). Pick it for any model slot in Settings → Model or in `nimblebrain.json`. No slot's default changes, so an install that doesn't choose it is unaffected. Like Opus 4.7/4.8 and Sonnet 5, Opus 5 rejects `thinking.type=enabled` and takes `thinking.type=adaptive` + `output_config.effort` instead; the engine translates on the fly. That translation derives an effort tier from a token budget and is lossy in ways worth knowing before you make Opus 5 a default — see [#774](https://github.com/NimbleBrainInc/nimblebrain/issues/774).
- **Managed-connector provider settings are declared in `nimblebrain.json`.** A new `connectors.providers.composio` block (`apiKey`, `baseUrl`, `monitorEnabled`) configures the provider on the config interface instead of ambient env, per-field: a field declared in the block wins, a field left out reads its `COMPOSIO_*` fallback, so nothing changes on upgrade. A connector's persisted transport credential now names a credential provider rather than a `${COMPOSIO_API_KEY}` env reference, which is what makes the broker credential declarable; connectors installed earlier are mapped forward when they start, with no operator action. **Forward-only** — a connector installed on this build does not start on an earlier image, so reinstall after a rollback ([#750](https://github.com/NimbleBrainInc/nimblebrain/pull/750), [docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#connectors)).
- **Smithery is a second managed-connector provider.** `connectors.providers.smithery` (`apiKey`, `namespace`, `baseUrl`, `monitorEnabled`) registers Smithery through the same seam as Composio — a brokered provider that hosts a config-authed MCP session with no OAuth arm of its own, so it contributes just `createSession` and a liveness probe. Its vendor-returned session URL takes the full SSRF gate (no fleet-internal plain-HTTP exception), and its credential is a named credential provider from the start rather than a `${SMITHERY_API_KEY}` reference in workspace state. Registered only when both its broker credential and a namespace resolve — a namespace is account-owned at Smithery with no safe default, so half-configured means off — and a deploy without Smithery carries nothing ([#723](https://github.com/NimbleBrainInc/nimblebrain/pull/723), [docs](https://docs.nimblebrain.ai/config/connector-providers/)).
- **Context Ledger — skill activation is visible in the transcript.** Each assistant turn that loads skills shows a quiet line above its activity (`Following <skill> · matched <tool>`), expandable to each skill's scope, load reason, and token cost; a turn that loaded none shows nothing. Reports every loading mechanism — tool-affinity, trigger phrase, and always-on (workspace persona / house-rules), excluding the platform's own built-in core skills. The chat-header **In context** panel aggregates the current turn's skills (replacing the skills-only lightbulb), grouped by why each one loaded. The lines persist — reopening a conversation shows the same ledger ([docs](https://docs.nimblebrain.ai/using/chat/#the-context-ledger)).
- **Per-toolkit Composio auth-config ids are declared in `nimblebrain.json`.** `connectors.providers.composio.authConfigs` maps a toolkit slug to its `ac_…` id — keyed by the slug the catalog entry already carries, so adding a toolkit is one catalog entry plus one line of config instead of a hand-typed env-var name repeated across two repos and a Helm regex guarding its spelling. The id is an identifier, not a secret (inert without the broker key), so it belongs in config rather than your secret store. A boot audit reports any declared key matching no catalog toolkit — the one string still shared between the catalog and a deployment's config. The `COMPOSIO_<TOOLKIT>_AUTH_CONFIG_ID` env vars and the catalog's `composio.authConfigEnv` field are **removed**; every id is declared config ([#786](https://github.com/NimbleBrainInc/nimblebrain/pull/786), [docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#per-toolkit-auth-configs)).
- **xAI (Grok) model provider (`xai:<model-id>`).** Configure via `providers.xai` or `XAI_API_KEY`; five models ship in the catalog (`grok-4.5`, `grok-4.3`, the two `grok-4.20-0309` variants, `grok-build-0.1`), all supporting tool calling, over Chat Completions. Effort support is per-model and measured: `grok-4.3` takes the full ladder plus `none`, `grok-4.5` everything but `none`, and two models take no effort parameter at all — a model with no measured ladder is sent nothing rather than a guessed tier. That `none` makes `grok-4.3` the only model in an effort dialect where `thinking: "off"` sends a suppressing value rather than nothing. See the [providers config docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#xai-grok).
- **Nebius Token Factory model provider (`nebius:<model-id>`).** Optional OpenAI-compatible gateway for open-weight models — Qwen3, Kimi, GLM, gpt-oss, and more. Configure via `providers.nebius` or `NEBIUS_API_KEY`; a curated catalog surfaces flagship models in the picker with pricing while any other Nebius id still routes as free text. Requests use Chat Completions (Nebius has no Responses API) and rely on automatic prefix caching. See the [providers config docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#providers).
- **Connector skill overlays.** Installing a connector binds a curated usage overlay (fetched by connector identity from a pinned public repo) and surfaces it into the conversation history exactly once — on the first call to a matching tool, never the system prompt — so the guidance is relevant (only when the connector's tools are used) and cache-friendly (rides the append-only history instead of re-varying the cached prefix). Always on and fail-soft: an overlay exists only if curated for that connector, and a missing overlay or fetch error is a no-op, never failing the install. Deduped against the bundle's own `skill://usage`, removed on uninstall, and listed via `manage_connectors list_bound_skills` ([docs](https://docs.nimblebrain.ai/using/skills/#connector-skill-overlays)).
- **Optional Sentry error reporting (`NB_SENTRY_ENABLED`, off by default).** Captures uncaught exceptions and unhandled promise rejections, initialized as an out-of-kernel Bun preload so the kernel stays vendor-neutral OTel (`check-no-sentry-in-kernel` still green) and Sentry does **not** take over tracing — additive to the OTLP→Tempo pipeline, not a replacement. Switched explicitly via `NB_SENTRY_ENABLED=true` (our own knobs, mirroring the web client; never inferred from DSN presence), and the SDK loads only when enabled. Per-tenant via the chart (`runtime.config.sentry.enabled`); events carry only the boot-time `tenant_id` and opaque ids, with headers/cookies/bodies/query strings scrubbed ([docs](https://docs.nimblebrain.ai/deploy/observability/#error-reporting-sentry-optional)).
- **"New version available — reload" prompt.** A long-lived browser tab keeps running the web build it loaded with; after a deploy it can sit on stale client code for hours. The shell now polls `/config.js` (already served `no-store`) every ~90s and on tab focus, and when the deployed web `release` differs from the one the tab booted with, shows a quiet, dismissible prompt at the foot of the sidebar (a refresh icon with a status dot when the sidebar is collapsed) with a Reload button. Never auto-reloads (you may be mid-turn). No effect in local dev / unconfigured builds.
- **Cache-hit rate in usage reports.** The usage aggregator now derives a `cacheHitRate` (0–1) — `cacheRead / (input + cacheRead + cacheWrite)` — on report totals, per model, and per breakdown row (including per-conversation), surfaced through the `usage` tool, the usage bundle, and the UI. It's the standing signal the prompt-cache work keeps high: a healthy long conversation reads its growing prefix back (high rate); a thrashing one re-writes it every turn (low rate). Makes the cache-cost win observable and catches regressions without a one-off forensic pass.
- **History compaction (opt-in, `features.compaction`).** Long conversations grow across runs until the prompt approaches the model's context window. The naive fix — windowing out the oldest messages every turn — busts the prompt cache every turn. Compaction instead folds the oldest turns into a single summary at run start as a deliberate, infrequent re-anchor: one cache re-write when it fires, then a smaller prefix that re-caches and stays stable. Triggers at ~70% of the message budget, keeps the recent ~35% verbatim, snaps the boundary to a user-turn start (so tool-call/result pairs stay intact), and summarizes the rest with the `fast`-slot model. Persisted as a `history.compacted` event and replayed by `reconstructMessages`, so the model context is identical whether freshly computed in a run or rebuilt on load. Compaction scopes the **model context only**: the web client, export, and fork read the full verbatim history, so users keep complete scrollback and forks copy the real turns while the model sees the re-anchored prefix. Event-sourced stores only; best-effort (a summarizer failure falls back to the full history, never failing the turn). Default off — enable per tenant.
- **`nimblebrain-bundle-sdk` Python package — Phase 2b.** Bundle-side wrapper for the `ai.nimblebrain/host-resources` extension. Bundle authors write `await host(ctx).read("files://fl_abc")` instead of hand-rolling the JSON-RPC call. Capability detection from `ClientCapabilities.extensions` with a fallback to the legacy `experimental` slot. Lives in `packages/bundle-sdk-py/`, released independently via `bundle-sdk-py/v*` tags (separate from the platform's `v*` tags). Initial version `0.1.0`. Published to PyPI as `nimblebrain-bundle-sdk`.
- **Host-resources extension — Phase 2a (handlers + resolver).** Bundle subprocesses can now call `ai.nimblebrain/resources/read` and `ai.nimblebrain/resources/list` over their MCP transport and the platform resolves the URIs against the workspace `FileStore` shared with the agent's `files__read`. One chokepoint, one security surface — same scheme allowlist, same workspace isolation. Bundle requests carry only the URI; workspace identity comes from the bundle's session, never the URI, so a workspace can't enumerate another's files. Per-`(workspaceId, bundleId)` token-bucket rate limit (100/sec, 1000 burst; returns `-32004 Rate limited` on exhaustion) prevents a runaway bundle from DoSing the FileStore. Whole-file reads cap at 10 MiB and return `-32005 ResponseTooLarge` above; range reads and write are reserved for a later phase. Threaded through every production bundle-spawn path: lifecycle `installNamed` / `installLocal` / `installRemote`, `installBundleInWorkspace` (agent-facing `manage_app install`), `configureBundle` restart, Composio eager-start, post-credential-change respawn, `ensureSourceRegistered`, and boot reload.
- **Host-resources extension capability — Phase 1 (advertisement + manifest gate).** Platform advertises `ai.nimblebrain/host-resources` in `ClientCapabilities.extensions` on every bundle's `initialize` handshake — the MCP-spec-blessed vendor-extension surface ([overview](https://modelcontextprotocol.io/extensions/overview)). Bundles declare requirements via `_meta["ai.nimblebrain/host"].host_capabilities` (keyed object mirroring the platform-side advertisement); entries with `required: true` cause install to fail loudly if the platform doesn't provide them, no silent runtime degradation. v1 payload: `read.enabled=true`, `list.enabled=true`, `write.enabled=false`, `schemes=["files"]`; `read.maxSize=10 MiB` until v2 ships range reads. Operations are object-shaped (not bare booleans) so future sub-fields slot in without breaking compat. Inbound `ai.nimblebrain/resources/{read,list}` request handlers land in Phase 2.
- `auth: composio` third connector auth kind alongside `dcr` and `static`. Discriminator lives in `_meta["ai.nimblebrain/connector"].auth` on `ServerDetail` entries; the install path branches at `handleInstallRemoteOAuth`, eager-starts the source via Composio's session URL (no native MCP OAuth handshake — Composio's static `x-api-key` is the transport credential), and writes a parallel state file at `credentials/composio/<connectorId>/connection.json`. Disconnect deletes the Composio-side account (best-effort) plus the local `connection.json`. Catalog entries declare `composio.toolkit` and an optional `composio.tools` allowlist; the toolkit's `ac_…` id is deployment config, keyed by that same slug.
- `src/connectors/providers/composio/sdk.ts` — single SDK adapter module. Owns every `@composio/core` import: `composio.create` (with `sessionPreset: "direct_tools"` so the toolkit's real tools appear on MCP, not the meta-router), `connectedAccounts.{list,initiate,delete}`, eager `validateComposioConfig` (open-redirect mitigation on `connectors.providers.composio.baseUrl`; multi-tenant `NB_TENANT_ID` enforcement when the bouncer is configured), 10-second timeout wrapper on every SDK call, and the canonical `composioUserId(wsId)` formula that the route and install layers both consume.
- `/v1/composio-auth/{initiate,callback,proxy}` routes. `initiate` adopts an existing ACTIVE Composio connected account when one exists (skips the redirect dance entirely) or kicks off a fresh flow with `allowMultiple: true`. `callback` verifies a sha256-bound nonce cookie + URL params + cid, writes `connection.json`, transitions lifecycle state to `running`. `proxy` is the white-label forwarder for the vendor-side redirect URI.
- Gmail (`com.google/gmail`) and Outlook (`com.microsoft/outlook`) connectors with curated 15-tool allowlists. Without the allowlist Outlook would surface 282 tools and the agent's `nb__search` would dump every match's full description (≈220K tokens) into context — gpt-class models would 400, Claude would burn 400K tokens per turn. The allowlist is per-catalog-entry; adding a new Composio toolkit is one catalog entry + one line of config + one Composio dashboard config.
- Workspace files are now first-class MCP resources at `files://<id>`. The `files` in-process source advertises every registry entry via `resources/list` and serves bytes via `resources/read` (text MIMEs as `text`, others as `blob`), reachable from any MCP client.
- `bun run dev:worktree` — runs the platform from any git worktree against a worktree-local `.nimblebrain-worktree/` workdir on alt ports (27271 API / 27270 web), in dev mode with no auth. For smoke-testing a feature branch without disturbing your primary dev or another worktree's state; suitable for Chrome DevTools E2E. See `AGENTS.md` § Worktree dev.
- `compose__effective_context` debug tool — single-call answer to "what's in the system prompt for this conversation, with provenance per layer." Live mode returns the full traced composition; historical mode (`run_id` set) reads the recorded `skills.loaded` event for that run and verifies each layer-3 skill's `contentHash` against current source, with `_versions/` snapshot recovery on drift. Bundle filter narrows to one app's contributions ([#119](https://github.com/NimbleBrainInc/nimblebrain/issues/119)).
- `skills.loaded` events now carry a `contentHash` (SHA-256 hex) per skill — telemetry foundation for the `compose__effective_context` debug tool. ~64 bytes per skill per turn.
- Extended-thinking config (`thinking: off | adaptive | enabled` + `thinkingBudgetTokens`) in runtime config and Settings → Model; defaults to `adaptive` for catalog-flagged reasoning models, `off` otherwise ([#109](https://github.com/NimbleBrainInc/nimblebrain/pull/109)).
- Reasoning (extended-thinking) content is now captured end-to-end: `stream.ts` handles `reasoning-*` parts, the engine emits `reasoning.delta` SSE events, and the chat UI renders a collapsed "Thoughts" block above the assistant text. Empty turns with non-stop finishReason now emit a placeholder so replay surfaces truncations.
- `nb__read_resource` system tool — the agent can now load `skill://` / `ui://` resources advertised by an installed bundle's MCP server ([#3](https://github.com/NimbleBrainInc/nimblebrain/pull/25)).
- `defineInProcessApp` helper for building in-process MCP sources from JSON Schema tool defs and a resource map — same authoring ergonomic as the former `InlineSource`, with the full MCP capability surface (resources, instructions, tasks, future capabilities).
- `McpSource` per-phase task methods (`startToolAsTask`, `awaitToolTaskResult`, `getTaskStatus`, `cancelTask`) with owner-context enforcement and TTL sweeper.
- `app://instructions` convention. Bundles publish; platform reads on every prompt assembly. Bundle owns storage, write tool, settings UI; platform owns URI, containment escape, fetch lifecycle. See [bundle-author docs](https://docs.nimblebrain.ai/apps/custom-instructions/).
- Org / workspace instructions overlays. `instructions://org` and `instructions://workspace` resources backed by `InstructionsStore` (8 KiB UTF-8 cap, atomic writes). Single tool `instructions__write_instructions(scope, text)` with role gates. Workspace overlay editor on `/settings/workspace/general`; org UI deferred (agent-writeable now).
- `useScopedRole` hook + `RouteGuard` component — centralized role determination for the web shell. Backend tools enforce roles independently — defense in depth.
- `UserMenu` in the shell sidebar (avatar + display name + popover with Profile settings + Sign out).
- Top-level `/profile` route. Identity isn't a setting; un-nested from `/settings/*`.
- Org-admin gate on `set_model_config` — backend now refuses non-org-admin writes (was UI-only via RouteGuard). Distinguishes "no identity" (cron, automations) from "wrong role" so debug logs make non-user code paths obvious.
- `PersonalWorkspaceInvariantError` typed error (`src/workspace/errors.ts`) → HTTP 422 `personal_workspace_invariant` with structured `{ workspaceId, reason }` body. Mirrors the `ConversationCorruptedError` → 422 precedent; raised by `WorkspaceStore` on attempts to mutate the locked members / `isPersonal` / `ownerUserId` fields on personal workspaces.
- `src/orchestrator/` — per-call workspace routing (the wall): a session reaches one workspace's tools plus identity tools, and any other workspace is denied. Errors: `UnknownNamespacedToolName` / `CrossWorkspaceReachDenied` / `WorkspaceToolUnavailable` / `UnknownToolSource` / `UnknownIdentitySource`, identical `data.reason` shapes on REST and `/mcp`.
- `src/tools/namespace.ts` — `namespacedToolName` / `parseNamespacedToolName`. Single construction + parse site; `check:tool-namespace` AST lint enforces.
- Sidebar workspace+apps navigator (Q1): role badges, composer breadcrumbs, per-call workspace provenance in the transcript.
- Connector install workspace target picker with typed-confirmation gate for shared installs (T010).
- `LegacyOAuthScopeError` — operator-actionable error on legacy `oauthScope: "user"` records (T008).
- AST lint `check:credential-paths` — flags `join(..., "users", X, "credentials", ...)` and equivalent shapes in `src/`. Joins the three other cross-workspace lints in `verify:static`.
- `src/workspace/workspace-id-pattern.ts` — shared workspace-id regex literal; web tier imports via codegen at `web/src/_generated/workspace-id-pattern.ts`. `check:codegen` + a regex-equality test pin the contract.
- External MCP cross-workspace smoke test (Claude Desktop-style client).

### Changed

- **Host pages and the top bar's title inset by one page gutter** (24px, or 16px when the main column is 640px wide or less), so page content lines up with the title and with app content ([#1887](https://github.com/NimbleBrainInc/nimblebrain/pull/1887), [#1890](https://github.com/NimbleBrainInc/nimblebrain/pull/1890)).
- **The top bar names a multi-view app ahead of its view**, as "Outreach › Campaigns" rather than "Campaigns" ([#1885](https://github.com/NimbleBrainInc/nimblebrain/pull/1885)).
- **App UI resources use the browser's HTTP cache.** They are served `private, no-cache` with an ETag, so an unchanged view answers `304` instead of downloading again, and a new release still shows on the next load ([#1883](https://github.com/NimbleBrainInc/nimblebrain/pull/1883)).
- **A model call the model gateway never answers names the gateway.** The run fails with `The model gateway didn't respond: <cause>` (`ModelGatewayNoResponseError`) instead of a bare stream stall or connect error; retries are unchanged ([docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#through-a-model-gateway), [#1881](https://github.com/NimbleBrainInc/nimblebrain/pull/1881)).
- **Organization → Usage names a deleted workspace** from its archive, marked "Deleted workspace".
- **Nebius catalog refreshed.** Adds Qwen3.5 397B, DeepSeek V4 Flash, Nemotron 3 Ultra and GLM 5.3; drops Qwen3 Next 80B Thinking and Cosmos3 Super Reasoner, which the account no longer serves, and GLM 5.1 for GLM 5.3. A dropped id still routes as free text. `sync-nebius` records a model's published cache-read price; Nebius publishes none today, so cached tokens bill at the input rate.
- **A task names the skills its runs need.** `skills` (a list, replacing `skill`, which no run ever read) gives every run those skills in full, in the cached part of its system prompt, before its first model call, so runs of one task over many inputs share one cached copy; an unknown name refuses the run, and each run's result reports `skillsDelivered`. `nb__use_skill` now passes any `allowedTools` list, so every run can open the skill catalog. A stored `skill` value is not read: set `skills` instead. [Docs](https://docs.nimblebrain.ai/using/tasks/).
- **A connector can publish a catalog-only skill.** `tool-affinity: []` binds a server-published skill or curated overlay to no tool, so it loads only when the model asks for it; an omitted field still binds the whole server. Settings and `skills__list` say such a skill loads "when the agent asks for it" (`on_request`) instead of "won't auto-load", and `loading.wouldLoad` is removed, since every skill can load. [Docs](https://docs.nimblebrain.ai/using/skills/#skills-published-by-an-mcp-server).
- **A skill bound to a tool reaches the model before that tool's first call.** The first call to a governed tool is held until its skill has been read, then re-issued, in chat and in task runs, and a workspace skill bound to a connector's tool arrives with the connector's own. `skills__loading_log` records `before_call` deliveries and any skill that did not arrive ([ADR-0053](adr/0053-a-bound-skill-reaches-the-model-before-its-tools-first-call.md)). [Docs](https://docs.nimblebrain.ai/using/skills/#how-a-skill-loads--loading-strategy).
- **A bound skill stays out of the system prompt until its tool is called.** A turn no longer composes the skills bound to whatever tools it starts with, so a greeting in a focused app carries only `always` skills, the catalog and the app guide, and the cached prompt prefix no longer changes with the tool set ([ADR-0018](adr/0018-skill-channels-and-the-cache-breakpoints.md)). `nb__status` lists bound skills under the tools that deliver them.
- **Uninstalling a static-auth connector removes its operator OAuth app** (client id and secret) when no other installed connector uses it, so a reinstall starts at Set up.
- **Automations are now Tasks.** The source, its tools (`tasks__*`), the sidebar view (`/w/<slug>/tasks`) and storage (`workspaces/<wsId>/tasks/<ownerId>/`) take the name. A deployment on v0.34.x or earlier upgrades through v0.35.x, whose boot moves storage from `automations/`. [Docs](https://docs.nimblebrain.ai/using/tasks/).
- **The host block has no `name`, `icon`, `category` or `primaryView`.** A connector's display name is its catalog entry's `title` (or `name`) on every surface — the connector page, the workspace listing, the briefing, and the system prompt — and a valid placement is projected without a `name`. A block that still declares them loads; the host ignores them, and placements alone declare a connector's views. [Docs](https://docs.nimblebrain.ai/apps/manifest/).
- **The event streams carry only what a client reads.** A conversation's stream no longer sends `run.start` (with its system prompt), connector skill bodies, or run accounting events, and `/v1/events` no longer sends `skill.*` or `bridge.tool.*`. Those events still reach logs, metrics and the conversation record.
- **Every engine event has one typed payload.** Connector crash, restart and liveness events are `connector.health` (they were carried on `run.error`), and MCP task status is `tool.task_status` (it was carried on `tool.progress`); a structured-log query on the old names needs the new ones. The console line for a crashed source is unchanged. The detached turn's `done` event now carries the same fields as `POST …/chat`.
- **`host_version` is a major version, written `"1.0"`.** Every block in `_meta["ai.nimblebrain/host"]` (`placements`, `hooks`, `notifications`, `admin_tools`) is part of `1.0` and is turned on by being declared; adding a block no longer bumps the version, and the version never gated a block. Any `1.x` still reads as `1.0`, so no manifest needs to change. A catalog entry whose `host_version` is not major 1 is now dropped at the catalog boundary with a warning, the same as an unsafe URL, because it declares a contract this host predates and would otherwise be read with its unknown fields ignored ([docs](https://docs.nimblebrain.ai/apps/manifest/)).
- **A connector's `settings` placement renders on its settings page** (Settings → Connectors → the connector), after the host's sections, and receives `hostContext.connector.canManage`. Settings → Apps is removed ([docs](https://docs.nimblebrain.ai/extensions/settings-sections/)).
- **The dev identity provider gets the same request rate limits, CORS policy and secure cookies as any other.** Raise a limit locally with `NB_CHAT_RATE_LIMIT` / `NB_TOOL_RATE_LIMIT` / `NB_MCP_RATE_LIMIT` (ADR-0040).
- **A user's first workspace is an ordinary workspace.** The workspace created for a user who belongs to none gets an opaque `ws_<16-hex>` id and the name "<first name>'s workspace", with the user as admin — and members can be added to it. There is no longer a personal workspace: no `isPersonal` / `ownerUserId`, no sole-owner membership lock, and no "Home · Personal" row in the sidebar, which now lists every workspace by name. At boot, workspaces created as personal ones keep their `ws_user_<userId>` ids, are renamed when they still carry the old provisioned name, and lose the legacy fields. The workspace is provisioned at bootstrap rather than on every authenticated request. [ADR-0039](adr/0039-the-first-workspace-is-an-ordinary-workspace.md).
- **The workspace briefing is one briefing per workspace, written only from app facets.** Every member sees the same briefing, and the fast model runs once per workspace rather than once per member. It no longer reads conversations or platform logs, and `nb__briefing` output drops `greeting` and `date`. A facet must answer for the workspace, never for the caller ([docs](https://docs.nimblebrain.ai/extensions/facets/)).
- **`GET /v1/health` returns only `{"status":"ok"}`.** It is unauthenticated and reachable through the web proxy, so it no longer lists connector names or the build identity. Probes are unaffected. Read the build from `GET /v1/bootstrap` or the **About** page in organization settings, and connector state from the `nb_connector_unhealthy` metric ([docs](https://docs.nimblebrain.ai/deploy/observability/#health-and-readiness-probes)).
- **A connector's OAuth records go through the credential store.** Its tokens, PKCE verifier, DCR registration (which for a confidential client holds a `client_secret`) and captured OIDC identity were four plaintext JSON files outside the store, with their own atomic-write discipline; they are now four keys — `mcp-oauth.<serverName>.{tokens,verifier,client,identity}` — at the connection's scope, so mode, rotation, audit and a future encrypted backend are the store's. Connection state is derived by probing those keys, and disconnect / uninstall delete them. An existing file goes the first time a connector touches that record — imported by a read, superseded by a write — with no script, no downtime and no reconnect ([docs](https://docs.nimblebrain.ai/config/credentials/)).

- **A brokered connector is one shape, and adding a broker is a folder plus a registry line.** The managed-connector seam already carried the verbs (mint a session, initiate, connect a key, probe, mount routes); it now carries the nouns. A `BundleRef` records `brokered: { provider, connectorId, providerRef }` in place of the per-vendor `composio` / `smithery` blocks, provider-owned state lives at `credentials/<provider>/<connectorId>/` under one path builder, and the auth-kind enum shrinks to the runtime-native kinds (`dcr`, `static`, `provider`) — a brokered kind is now the provider's own id, validated against what is registered instead of against a literal union hand-mirrored across four files. Teardown and per-owner readiness become two optional seam arms (`cleanup`, `hasConnection`), so uninstall, disconnect and boot-state derivation each make one call rather than branching per vendor. Existing installs are read forward from the old blocks and keep working with no config edit. **Behavior change:** installing a brokered connector now re-resolves its provider block from the operator-published catalog by id and refuses an entry the catalog does not publish — Composio joins the bound Smithery and `provider` entries already had, because a brokered install spends the platform's broker credential on the operator's account.

- **`skills__activate` / `skills__deactivate` now steer one conversation instead of editing a shared file.** They used to write `status:` into the skill's frontmatter — a file read by every conversation that user has, in every workspace — so one chat's "not relevant right now" silently reconfigured all the others, with no signal to any of them. They now record a conversation-scoped mute and write nothing; `nb__use_skill` already loaded a skill into one conversation, and this is the matching remove. **The agent can no longer disable a skill permanently at all.** The durable toggle moves to `skills__set_status`, an internal tool the Skills settings page calls and the model never sees — the Settings UI is unchanged. `set_status` is the one door, and the others are shut behind it: neither `skills__create` nor `skills__update` accepts `manifest.status` (both refuse it rather than dropping it, since a silent drop would report a disable that never happened), and `skills__restore` recovers a snapshot's content while leaving the live on/off state alone — a skill toggled off and back on leaves a disabled snapshot in its history, and restoring one used to write that state back. A mute called outside a chat is refused rather than silently doing nothing.
- **A conversation is bound to one model for its life.** The model is resolved when the conversation is created and recorded on it; every later turn runs on that model. Changing a model slot — in `nimblebrain.json`, the override file, or via `set_model_config` — therefore applies to conversations started **after** the change, and a conversation already under way keeps running on the model it began on. Previously the slot was re-read on every turn, so an operator changing the default silently retargeted live conversations mid-thread; one production conversation ended up spanning two providers, and because the tool-call ids one provider mints aren't portable to another, it could no longer take a turn on the original provider at all. The binding covers the main chat model only — title generation and compaction still take `fast`, and `nb__delegate` still resolves the slot it names. Conversations created before this carry no binding and resolve from the current slot exactly as before; they are deliberately **not** backfilled, because a binding derived from history can capture a model string that no longer resolves, and a binding cannot be repaired. The consequence is worth stating plainly: **a conversation that already spans two providers is not repaired by this change** and still fails on the provider whose id format it cannot replay — that is fixed by normalizing tool-call ids ([#874](https://github.com/NimbleBrainInc/nimblebrain/issues/874)), not here. Forks inherit the source's binding. No migration. Note there is also currently **no way to re-point a bound conversation**: if the provider or model it names is removed from config, resuming it fails until that config is restored ([#896](https://github.com/NimbleBrainInc/nimblebrain/issues/896)).
- **Skills settings is one composition list.** Every tier shaping the agent — the skills you own here, your personal skills, organization policy, and the built-in system skills — sits in a single list, grouped agency-first with a segment filter to slice by tier. Each row states how it loads at rest (`Always on · every conversation`, `` On tool match `<glob>` ``, `On trigger "<phrase>"`, `Won't auto-load yet`); tiers you don't own here are read-only and link to where they're managed. The workspace, org, and profile tabs are one component: each is a vantage that shows the tiers it has and drops the filter when there's only one. Vocabulary unified on "Skills".
- **Workspace instructions are human-authored; the agent routes "remember this" instead of writing the overlay.** `instructions__write_instructions` is now an internal tool — invoked by the settings UI, never on the model's surface (the write is whole-blob overwrite of always-injected prose, a human-shaped edit made where the author sees the full text they are replacing). The always-on capability briefing teaches the routing: asked to remember a rule or convention, the agent drafts the exact text and points the user to workspace settings; asked to remember a fact or a piece of state, it records it in a memory app when one is installed. `skills__*` stays proxied and named in the briefing (the authoring surface for a capability, reached deliberately). An instruction write from inside an **unattended run is refused** at the source, behind the same wall as the automation-authoring surface.
- **Kernel identity tools are always surfaced directly.** `files__*`, `conversations__*`, and `automations__*` — the kernel identity sources — now join the `nb__` system core in the always-direct tool set, instead of being proxied and discovered via `nb__search` once a workspace exceeds the direct-tool cap. They're hot substrate the model reaches for unprompted, so proxying them forced a `nb__manage_tools` promote on first use — and every promote mutates the tools block ahead of the messages, busting the conversation's cached prefix. Keeping them direct removes those promotes and their cache-writes; the always-direct set is now "kernel tools" (system core ∪ identity sources), not `nb__`-only. Bundle/app tools are unaffected — they still proxy and promote on demand.
- **An automation run requires current membership of its workspace.** An automation fires as its owner, walled to the workspace it was created in. If the owner has since been removed from that workspace, the run is now denied (`executeTask` throws before binding any tools) instead of continuing to act in a workspace they left. The scheduler records the denial as a **skipped** run — it doesn't count as a failure, so it never trips the consecutive-error auto-disable, and the automation resumes automatically if the owner is re-added. Completes the offboarding-revocation story alongside the conversation-resume gate; personal-workspace automations are never affected.
- **Resuming a conversation requires current membership of its workspace.** A conversation is sealed to the workspace it was created in (its tools/skills/apps resolve there), so a member removed from that workspace can no longer **resume** conversations they own in it — continuing would grant that workspace's tools to someone offboarded from it. Both `POST /v1/chat` and `/v1/chat/start` return `403` (`conversation_access_denied`) on such a resume. **Read access is unchanged** — a removed member can still load and read their own authored conversations; only active continuation is gated. Personal-workspace conversations are never affected (sole-member). This replaces the earlier "conversations outlive their workspace context" behavior, a holdover from when conversations were stored outside any workspace. Files and automations share the same offboarding shape and will follow.
- **The chat send endpoints require a workspace.** A chat turn names the workspace it acts from, in its URL (`/v1/workspaces/<wsId>/chat`, `/chat/stream`, `/chat/start`), instead of silently defaulting to the caller's personal workspace. Embedded `Runtime.chat()`/`startTurn()` callers still default to the personal workspace. `/cancel` is unchanged (owner-gated, no workspace needed). This also removes a Hono `.use("*")` wildcard that was leaking the chat router's workspace middleware onto `/v1/events` (identity-scoped, never gated on the header by design).
- **The chat panel re-scopes to the focused workspace.** Switching workspaces now clears the open conversation and resets the panel to a fresh draft in the newly-focused workspace, instead of carrying the previous workspace's conversation across. The panel stays open; only the conversation re-scopes. Opening a conversation from within its own workspace is unaffected.
- **Conversation list is always workspace-scoped** — the cross-workspace "All workspaces" view is removed from the conversations app. The list shows the focused workspace's chats, with the workspace name shown ambiently; switch workspaces to see another's. The owner-across-workspaces enumeration remains an internal primitive (skills, usage), never a user surface.
- **Workspace ids are now opaque and name-independent.** A non-personal workspace's id is generated as an opaque token (`ws_<16-hex>`, via `generateWorkspaceId()`) at create time instead of being derived from the name (`ws_<slugify(name)>`). The workspace name is now a freely-editable field: renaming a workspace via `manage_workspaces` no longer changes its id, its on-disk dir, or its URL (`/w/<id>`) — the pre-existing behavior left the URL permanently stamped with the original name. Two workspaces with the same name now coexist with distinct ids (previously a slug conflict). **No migration required and no breaking change for existing deployments**: existing name-derived ids remain valid (they already match the id pattern and `update` already froze the id on rename), so every existing `/w/<slug>` deep link and on-disk dir keeps working untouched; only newly-created workspaces get opaque ids. Personal workspaces are unchanged — they stay deterministic at `ws_user_<userId>` for O(1) lookup. `WorkspaceStore.create(name, slug)` still honors an explicit slug for personal-workspace provisioning and deliberate operator/test overrides.
- **`files__read` text extraction broadens slightly.** `extractText` now treats any `text/*` mime as UTF-8-decodable (previously only an exact-match allowlist of nine mimes). Practical effect: `text/sql`, `text/css`, `text/javascript`, etc. are now extractable from `files__read` instead of returning null. The decode path falls back to null safely if the bytes don't decode. Consolidates a third `isTextMime` duplicate with the agent-side and bundle-side predicates.
- Uploaded images are persisted in conversation JSONL as MCP `resource_link` blocks pointing to `files://<id>` instead of inline `Uint8Array` bytes. The runtime rehydrates them to AI SDK V3 `file` parts at the `model.doStream` boundary, so vision content is now stable across multi-turn agent loops (previously dropped on turn 2+). Existing JSONL files are read forward without migration; legacy `image` blocks are quietly omitted on reconstruction (same as the pre-fix behavior).
- Apps list in the system prompt now surfaces each bundle's `initialize.instructions` inside `<app-instructions>` containment tags, so per-bundle guidance reaches the LLM.
- Iframe bridge uses the MCP transport (`StreamableHTTPClientTransport` against `/mcp`) for `tools/call` and `resources/read`. Bridge advertises `hostCapabilities.tasks` to iframes and forwards `notifications/tasks/status` on a per-bridge subscription.
- Inline (non-task) `tools/call` handler on `/mcp` now preserves `structuredContent` (was dropping it).
- Settings nav grouped by scope. "This Workspace" (General / Members / Usage / Apps) and "Organization" (Model / Workspaces / Users) replace the flat tab list. About is the footer. Per-bundle settings panels nest under "Workspace > Apps."
- Settings re-scoped by data ownership. Model → Organization (writes global `nimblebrain.json`, affects every workspace). Usage → This Workspace (uses `runtime.getWorkspaceScopedDir()`). MCP Connection card → Workspace > General.
- Sidebar collapse toggle moved to a half-overflow edge button on the sidebar's right border (Linear pattern).
- `manage_workspaces.list` now returns the requesting user's role within each workspace (`userRole?: "admin" | "member"`) so the web client can gate workspace-admin UI without an extra `list_members` round-trip.
- `POST /v1/chat` validation error wording. Hand-rolled checks (`"metadata must be a JSON object"`, `"allowedTools must be an array of strings"`) replaced with TypeBox path-prefixed errors (`"/metadata: Expected object"`, `"/allowedTools: Expected array"`). HTTP status (400) and `error: "bad_request"` are unchanged. External callers asserting on the exact wording need to update; the field name still appears in the message.
- Skill manifest writes now always include `metadata.keywords` and `metadata.triggers` as arrays (defaulting to `[]` when omitted by the caller). Previously a partial `metadata: { category: "X" }` could write a manifest with no `keywords`/`triggers` keys at all; the loader's domain type required them, so the divergence was a latent type lie. The on-disk JSON shape is now what the type always claimed.
- API responses now carry HSTS (`max-age=31536000; includeSubDomains`) and CSP (`default-src 'none'; frame-ancestors 'none'; base-uri 'none'`) by default so direct-exposure self-hosted deployments aren't naked. Operators terminating TLS at a reverse proxy that already emits these can disable via `NB_HSTS=""` / `NB_CSP=""`, or override to a custom value via env var or middleware option ([#20](https://github.com/NimbleBrainInc/nimblebrain/pull/20)).
- Catalog field `defaultScope` renamed to `defaultBinding` with values `"workspace" | "personal"` (Stage 2 / T008). 19 catalog entries updated.
- Web prop `scope: "user" | "workspace"` renamed to `mode: "personal" | "workspace"` across four UI components (Stage 2 / T009).
- Tool name display in the conversation transcript renders as a workspace badge + friendly tool name (Stage 2 / Q2); raw `ws_<id>/<tool>` stays in JSONL.

### Breaking

- **A remote connector with `auth: {"type": "none"}` connects without OAuth.** It starts at boot and never offers Connect, as the docs describe. A connector that signs in with OAuth must omit `auth` rather than set `none` ([#1848](https://github.com/NimbleBrainInc/nimblebrain/issues/1848), [docs](https://docs.nimblebrain.ai/config/connectors/#auth-types)).
- **`execution` runtimes now name an agent worker.** The experimental block's `driver: "nimblebrain"` (the in-process reference agent) is retired; a runtime entry is now `{ "driver": "nimblebrain-agent", "url" }`, a worker the runtime dials, beside one `execution.modelGateway`, and an entry in the old shape fails validation at boot. Outside the `dev` identity provider external runtimes need the run key and a `modelGateway` ([docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#execution), [ADR-0057](adr/0057-an-external-agent-runs-out-of-process-on-credentials-only-the-runtime-mints.md)).
- **A `ws_<id>-` prefixed glob in `allowedTools` or `toolAffinity` no longer matches.** Patterns are matched against the bare names as written; rewrite any such glob to `<source>__*`.
- **The host no longer reads `app://instructions` from connectors.** Guidance for one app is a workspace skill bound to its tools (`tool-affinity: <connector>__*`), which loads before the agent's first call to them; move any overlay text into one. Removes a read, and on `2026-07-28` a `400`, per connector on every prompt assembly ([docs](https://docs.nimblebrain.ai/extensions/custom-instructions/)).
- **A workspace's own OAuth client is no longer called an "operator app".** `manage_connectors` actions `setup_operator` / `remove_operator_setup` are now `set_oauth_client` / `remove_oauth_client`, and the fields `operatorConfigured`, `missingOperatorSetup` and `operatorOAuth` are now `oauthClientConfigured`, `missingOAuthClient` and `workspaceOAuthClient`; the old names are refused. `workspace.json` records live under `oauthClients`: v0.49.0 copied them from `oauthOperatorApps` and v0.50.0 deleted the old key; nothing reads `oauthOperatorApps` now. The catalog field is `oauthClientSetup`; `operatorSetup` is no longer read. Upgrade floor: v0.50.0, so a deployment below it upgrades to v0.50.0 first. [Docs](https://docs.nimblebrain.ai/config/connectors-catalog/#field-reference).
- **`nb__open_app` takes `uri` or `app`, and no `target`.** A record opens by its URI alone; the platform finds the app that shows it.
- **`ai.nimblebrain/action` is removed.** The host no longer declares or serves it, so an app built on `@nimblebrain/synapse` below 0.33.0 sends nothing when it asks to open an app, a conversation or its settings. Upgrade floor: every app on 0.33.0, using `open` and `openConnectorSettings` ([ADR-0052](adr/0052-a-view-opens-what-it-names-by-uri.md)).
- **A model id is `provider:model` everywhere it is written.** A bare id (`claude-sonnet-4-6`) is refused, naming the qualified form, instead of being given a provider: at startup in `nimblebrain.json` / `nimblebrain.overrides.json` (`models.*`, `modelPolicy.allowed`), by `set_model_config`, `set_preferences` and task create/update, as a chat request's `model` (`400 model_not_qualified`), and on a turn whose `workspace.json` `models` override is bare. `resolveModelString` is no longer exported. Upgrade floor: every `models.*` and `modelPolicy.allowed` value in config, every `workspace.json` `models` value, every user's `preferences.models.default`, and every task's `model` must be `provider:model` (or a slot name) before upgrading ([docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#models)).
- **A stored model id with no provider names no model.** A usage-ledger line or conversation event whose `model` has no `provider:` is no longer read as Anthropic, so it is unpriced and gets no provider-specific handling. Upgrade floor: every usage-ledger line and conversation event's `model` is `provider:model`.
- **Every usage-ledger line names its workspace and user.** A model call with neither in scope is logged as an error and not written, and the usage report has no `none` workspace or `unknown` user. `usage__report` drops `groupBy: "turn"`, and `groupBy: "conversation"` lists chat conversations only. Upgrade floor: every usage-ledger line has `workspaceId` and `userId`.
- **Workspace instructions are removed; a standing workspace rule is an always-on workspace skill.** The General tab's instructions box, `instructions://workspace` and `instructions__write_instructions` are gone, and the runtime no longer reads `workspaces/<wsId>/instructions.md`. Before upgrading, move each non-empty `instructions.md` into a workspace skill with `loading-strategy: always`. [Docs](https://docs.nimblebrain.ai/guide/settings/#custom-instruction-layers).
- **A task run's deliverable is read only from its result.** The Tasks panel and a `tasks__run` task's result no longer fall back to the run's `resultPreview`. Upgrade floor: every run index line, hot and archived, has its `<runId>.result.json`.
- **An oidc provider must send `email_verified: true`.** A token without it is refused, including for users who have signed in before; configure the provider to include the claim before upgrading ([docs](https://docs.nimblebrain.ai/using/users/#signing-in-with-oidc)).
- **The `owner` org role is gone; `admin` is the top role.** It granted nothing beyond `admin`. `manage_users` accepts only `admin` or `member`, keeps at least one active admin, and the dev user is an admin. Upgrade floor: a deployment must hold no user with `orgRole: "owner"` before upgrading, because nothing reads it as an admin. Set each one's `orgRole` to `admin` in `users/<id>/profile.json` with the runtime stopped; the Users page cannot demote the last owner on earlier releases. Under OIDC, a user's email is no longer editable.
- **`/mcp/<wsId>` speaks MCP 2026-07-28 only.** Each request is served on its own, with no `initialize` and no `Mcp-Session-Id`; a 2025-era client gets `-32022` naming the supported version. The `sessionStore` config key and the `MCP_SESSION_TTL_SECONDS` / `MCP_MAX_SESSIONS` env vars go with the sessions (a config that keeps `sessionStore` starts with a warning). The runtime still connects to a 2025-only connector. See [ADR-0048](adr/0048-mcp-serves-2026-07-28-only.md).
- **With `NB_FLEET_AUTHORIZER_ISSUER` set, the runtime refuses to start without a valid `NB_TENANT_ID` and `NB_MCP_AUTHORIZER_TENANT_KEY`.** It no longer sends a fleet token request without a tenant assertion, which an authorizer that requires one refuses anyway. Provision the key before upgrading.
- **A WorkOS instance with an empty `organizationId` refuses to start.** A blank or whitespace-only value in `instance.json` is a startup error. Omitting the field still runs without an organization, which admits every user in the AuthKit environment ([#1153](https://github.com/NimbleBrainInc/nimblebrain/issues/1153)).
- **The theme tokens that change between light and dark and have no spec key move off the iframe's style block onto the `ai.nimblebrain/styles` host-context extension.** `--color-text-accent`, `--nb-color-processing` and `--nb-color-processing-light` now arrive in `hostContext["ai.nimblebrain/styles"].variables` on `ui/initialize` and every `host-context-changed`, so they follow a theme toggle instead of keeping the mode the iframe loaded in. An app that applies theme tokens itself applies that field as well as `styles.variables`; `@nimblebrain/synapse` 0.31.0 does. [Theming](https://docs.nimblebrain.ai/apps/theming/) ([#817](https://github.com/NimbleBrainInc/nimblebrain/issues/817)).
- **The host no longer sends a `--nb-*` theme key that has a spec twin.** An app reads the status colours, the info ground and on-fill text from `--color-text-danger`, `--color-text-success`, `--color-text-warning`, `--color-background-info` and `--color-text-inverse`, as `@nimblebrain/synapse` 0.30.0 does; `--nb-color-processing`, `--nb-color-processing-light` and `--nb-font-heading` remain. [Theming](https://docs.nimblebrain.ai/apps/theming/).
- **`tasks__run` selects a saved task with `taskId` (as `tasks__run_batch` does); `name` is removed.**
- **Only the `fl_<24 hex>` file id form is served.** A file stored under the older `fl_<base36>_<8 hex>` id returns 400. **Breaking**: a deployment holding files under the older form must upgrade to `v0.35.0` and re-key them, and every reference to them, with that release's tooling before taking a later release.
- **`POST /v1/workspaces/:wsId/chat` and `POST /v1/workspaces/:wsId/chat/stream` are removed.** Start a turn with `POST /v1/workspaces/:wsId/chat/start`, which answers the conversation id, and read it from `GET /v1/conversations/:id/events`, which resumes by sequence and ends with the `done` frame the synchronous route returned. The shell response drops `chatEndpoint`, the `subscribed` frame drops `subscriberId`, and the `X-Origin-Subscriber-Id` header is no longer read.
- **`/mcp` serves task augmentation on `2026-07-28` only (ADR-0046).** A 2025-era session declares no `tasks` capability: every `tools/call` runs to completion and answers its result, a `params.task` on it is ignored, and `tasks/get` / `tasks/result` / `tasks/cancel` answer `-32601`. A client that needs a long-running call speaks `2026-07-28` and opts in to `io.modelcontextprotocol/tasks`. [Docs](https://docs.nimblebrain.ai/connect/mcp-endpoint/#protocol-versions).
- **The 2025 era of `/mcp/<wsId>` runs on MCP SDK 2, and two of its answers change.** A `resources/read` whose URI resolves nowhere answers `-32602` instead of `-32002`, and a tool whose `outputSchema` root is not an object is listed with it wrapped under `result`, its `structuredContent` wrapped to match ([docs](https://docs.nimblebrain.ai/connect/mcp-endpoint/#protocol-versions)).
- **`automations__*` tools are removed; use `tasks__*`.** REST `tools/call`, `/mcp` clients, and saved `allowedTools` or skill `tool-affinity` patterns naming `automations__*` must name `tasks__*` instead. Task handles minted before the upgrade no longer resolve. The `/w/<slug>/automations` route and the `/using/automations` docs URL are gone. The `nimblebrain.json` block is now `tasks` (an `automations` key is warned and ignored), the `list` output field and the notifications settings field are `tasks`, an `agent` route target names its task as `task` (a deployment holding targets under the old `automation` key upgrades through v0.35.x, whose boot rewrites them), run records name it `taskId` (rewritten when storage moves), and the metric is `nb_task_runs_total`.
- **The catalog `lifecycle` block is no longer read.** For a server that still names its handlers in `_meta["ai.nimblebrain/host"].lifecycle` instead of advertising the [`ai.nimblebrain/lifecycle` extension](https://docs.nimblebrain.ai/extensions/lifecycle/), the host stops calling those tools and stops withholding them, so every workspace member can list and call them: **breaking** until the server advertises the extension.
- **`model`, `defaultModel` and the `--model` flag are removed.** Set `providers` for keys and `models.default` / `models.fast` for selection; an unset slot resolves to `anthropic:claude-sonnet-4-6`. `model`, `defaultModel`, `skills`, `identity` and `contextFile` in `nimblebrain.json` now warn as unknown keys and are ignored, `set_model_config` refuses a `defaultModel` input, and a `defaultModel` left in `nimblebrain.overrides.json` is dropped with a warning. Embedders pass `languageModel` to `Runtime.start` in place of `model: { provider: "custom", adapter }`. [Docs](https://docs.nimblebrain.ai/config/nimblebrain-json/#models).
- **`tasks` is a reserved source name.** A workspace connector installed as `tasks` no longer starts (its tools would be shadowed by the tasks identity source); reinstall it under another source name, or set an explicit `serverName` on its ref.
- **`nb__manage_workspaces` takes no `slug`, and every new workspace id is generated (`ws_<16-hex>`).** The tool's input schema is closed, so `slug`, or any other argument it does not declare, is refused as invalid arguments rather than ignored. **Breaking for REST `tools/call` and `/mcp` clients** passing `slug` or undeclared fields ([docs](https://docs.nimblebrain.ai/using/workspaces/)).
- **A workspace id is `ws_` and 16 lowercase hex chars, and the server refuses to boot on any other.** `Runtime.start` throws `NonConformingWorkspaceIdError`, naming each offender, when `workspaces/` holds a workspace whose directory is a slug (`ws_<name>`), a user-derived id (`ws_user_<id>`), or any other form. **Migration:** a self-hosted deployment renames each such workspace to a generated id (its directory, the `id` in its `workspace.json`, and every stored reference to it) before upgrading ([docs](https://docs.nimblebrain.ai/using/workspaces/), ADR-0042).
- **`ConnectorUiMeta` has no `name` or `icon`.** The exported type and the `connector.installed` event's `ui` carry only `placements`; read a connector's display name from its catalog entry. See Changed.
- **A tool error reaches an app's view as a result, not a JSON-RPC error.** A view's `tools/call` answers the `CallToolResult` with `isError: true` and its `structuredContent`, so a connector's structured refusal arrives intact; a pushed `ui/notifications/tool-result` carries `isError` too. A JSON-RPC error means no result came back, and a server's refusal keeps its code and `data`. **Migration:** a view that reported tool failures from `catch` checks `result.isError`, or wraps `useCallTool` to throw on it ([docs](https://docs.nimblebrain.ai/apps/bridge/#toolscall)).
- **The server does not start without `instance.json`.** A missing file never selects dev mode: dev is the `dev` adapter, chosen by name. **Migration:** a deployment that ran without the file writes `{"auth":{"adapter":"dev"}}` to `<workDir>/instance.json` to keep the single local user, or configures `oidc` / `workos`; `docker compose` now mounts `./instance.json`, and the dev launchers write the dev adapter themselves ([docs](https://docs.nimblebrain.ai/config/instance-json/), ADR-0040).
- **`Runtime.start` refuses a workdir with no `instance.json` unless the caller passes `identityProvider`, and `chat` / `startTurn` / `executeTask` require `identity` and `workspaceId`.** There is no dev-user or default-workspace fallback for an in-process caller (ADR-0040).
- **The personal-workspace contract is gone.** `422 personal_workspace_invariant` is no longer returned; `manage_workspaces list` and the MCP App `hostContext.workspace` no longer carry `isPersonal`; the `conversation.title` event carries `ownerId` instead of `wsId`. `GET /v1/bootstrap` no longer sends `workspaces[].isPersonal`. A runtime request with no `workspaceId` throws. A connector installed in a former personal workspace blocks adding a personal connector of the same name, as a connector installed in any workspace does.
- **The server chooses no workspace.** `GET /v1/bootstrap` no longer sends `activeWorkspace` or `shell`; a client opens the workspace its URL names and reads its shell from `GET /v1/workspaces/<wsId>/shell`. `preferences.defaultWorkspaceId` is no longer read or written, and `/` takes a user in exactly one workspace into it. [ADR-0044](adr/0044-the-url-is-the-only-thing-that-names-a-workspace.md).
- **Server-published skills require the MCP Skills Extension.** A server that only lists `skill://…/SKILL.md` resources no longer publishes skills. Server authors: implement `skills/list` and `skills/get`, and declare `io.modelcontextprotocol/skills` in the server's capabilities (required on `2026-07-28` connections) ([docs](https://docs.nimblebrain.ai/using/skills/#skills-published-by-an-mcp-server)).
- **The workspace identity override is removed.** `nb__manage_identity` is gone, so a REST `tools/call` or `/mcp` caller of it gets an unknown-tool error, and an `identity` key in `workspace.json` is ignored. Put a workspace persona in an always-on workspace skill ([docs](https://docs.nimblebrain.ai/guide/settings/#skills-widsettingsskills)).
- **REST names its workspace in the URL, and `X-Workspace-Id` is gone.** Every workspace-scoped route moved under `/v1/workspaces/<wsId>/` — `chat`, `chat/stream`, `chat/start`, `tools/call`, `resources/read`, `resources` (upload), `shell`, `apps/:name/resources/*`, `mcp-auth/initiate`, `composio-auth/initiate`. The old paths return `404`; they are not redirected. Identity-scoped routes (`/v1/bootstrap`, `/v1/events`, `/v1/conversations/:id/*`, `/v1/files/:id`) take no workspace. A REST call never falls back to the personal workspace, and the header is ignored everywhere, bootstrap included. A malformed, unknown or non-member `<wsId>` gets one answer, `404 workspace_error`. A browser write from another origin is refused on every route (`403 cross_site_request`) unless that origin is in `ALLOWED_ORIGINS`. A tab on the previous web build shows errors until it reloads; the "New version available" prompt offers that. See [ADR-0037](https://github.com/NimbleBrainInc/nimblebrain/blob/main/adr/0037-a-workspace-is-addressed-by-url-on-every-surface.md).
- **The NimbleBrain host extensions are `ai.nimblebrain/action`, `ai.nimblebrain/request-file` and `ai.nimblebrain/keydown`, and the host declares them.** The `synapse/*` spellings are gone — no dual handling. Each name is both the wire method and the identifier the host publishes in `hostCapabilities.experimental`, which is the one slot in the handshake result a spec client does not strip. `@nimblebrain/synapse` 0.20.0 sends an extension only where the host declared it, so an app below 0.20.0 loses the file picker, keyboard forwarding and in-app navigation on this host, and one on 0.20.0 needs this release to get them. Migration: move every app to `@nimblebrain/synapse` 0.20.0; app code is unchanged.
- **The host declares every spec capability it serves**, which 0.20.0 requires before the SDK will send: `message` and `updateModelContext` join `openLinks`, `downloadFile`, `serverTools` and `serverResources`. Without them `sendMessage` and `updateModelContext` were silent no-ops for any app on 0.20.0. The top-level `hostCapabilities.tasks` is removed; the tasks capability is served only under `experimental["io.modelcontextprotocol/tasks"]`, where every supported SDK reads it.
- **The host reads no chat context from `ui/message`, and `ai.nimblebrain/action` serves only `openApp` and `openConversation`.** An app hands the agent its state with the spec's `ui/update-model-context`, puts a message in the chat with `ui/message` (not `startChat`), and opens an app by name with `openApp` (not `navigate`). The host ignores the retired key and actions ([docs](https://docs.nimblebrain.ai/apps/bridge/#uimessage)).
- **Host fonts arrive as the spec's `hostContext.styles.css.fonts`.** The `synapse/fontFaces` host-context key and its descriptor array are gone; the host sends `@font-face` CSS and the app's SDK injects it. The CSS is read once, at the handshake — a typeface change mid-session no longer reaches a running app. An app below 0.20.0 renders in `system-ui`.
- **The host sends an app nothing before `ui/notifications/initialized` but the answers to its requests.** The load-time `ui/initialize` notification is gone; theme tokens and context arrive in the `ui/initialize` response. Notifications raised earlier are held and delivered once the handshake completes. An app that waited for the host to speak first must send the `ui/initialize` request. [Docs](https://docs.nimblebrain.ai/apps/bridge/#initialization-handshake).
- **The host no longer sends `synapse/data-changed`, and the `data.changed` event is gone.** An app refreshes only when its own server sends `notifications/resources/list_changed`, which the host relays. An app built on `@nimblebrain/synapse` below 0.19.0 stops live-refreshing. Migration: move to 0.19.0 and have the server send `notifications/resources/list_changed` after a committed write. [Docs](https://docs.nimblebrain.ai/apps/bridge/#notificationsresourceslist_changed).
- **The connector catalog is one directory of `ServerDetail` files, and that is the whole of it.** The multi-registry layer is gone: `registries.json`, `NB_REGISTRIES`, the per-registry `scopes[]` filter, and the `manage_registries` tool and its Org → Registries settings tab are all removed. `NB_CURATED_CATALOG_DIR` names the directory; every `*.yaml`/`*.yml`/`*.json` in it is read, validated per file, and aggregated first-wins, re-read on each lookup so a ConfigMap edit lands without a restart. Pointing the platform at another source of connectors now means writing files into that directory rather than configuring the runtime. An operator who set `NB_REGISTRIES` must move that curation into the catalog directory; a stale `registries.json` in the work dir is ignored and can be deleted. [Docs](https://docs.nimblebrain.ai/config/connectors-catalog/).
- **`nb__search` takes `scope: "catalog"`.** The `"registry"` value is gone — **breaking for `/mcp` clients** passing it. Same results, same shape.
- **`features.bundleDiscovery` is now `features.catalogSearch`, and `features.bundleManagement` is removed.** Operator config keys in `nimblebrain.json`: the old names are unknown keys, ignored with a warning, so a deployment that set either falls back to the default (`true`) — set the new key explicitly to keep catalog search off. `bundleManagement` gated no tool on the current tool set and is deleted rather than renamed; connector install and configuration are governed by workspace admin rights, not a flag. [Docs](https://docs.nimblebrain.ai/config/features/).
- **A workspace's connectors live under `connectors[]`.** `workspace.json` is read on the new key only, and a record without it refuses to boot. Upgrade floor: v0.34.x, whose boot refuses a record without it and names the fix.
- **`GET /v1/health` reports `connectors[]`, and `/v1/mcp-auth` answers `connector_not_found`.** Same shapes, new names; repoint anything parsing either. [Docs](https://docs.nimblebrain.ai/deploy/observability/#health-endpoint).
- **`nb__status` takes `scope: "connectors"`, and `home__activity` takes `category: "connectors"` and returns `connector_events`.** **Breaking for `/mcp` clients** passing the old values.
- **`nb__list_apps` and `nb__version` are removed.** Nothing in the platform called them; `nb__workspace_info` returns the platform version. **Breaking for REST `tools/call` and `/mcp` clients** calling either.
- **`nb__manage_workspaces` takes and returns `connectors`.** **Breaking for `/mcp` clients** passing `bundles`.
- **`NB_BUNDLE_START_CONCURRENCY` is now `NB_CONNECTOR_START_CONCURRENCY`.** [Docs](https://docs.nimblebrain.ai/config/environment/#runtime--server).
- **A workspace's connector array is no longer stripped from `nimblebrain.json`.** In that file the name `connectors` is the provider and gateway block; a connector array placed there is now rejected as a type error at startup.
- **Every event whose name said `bundle` now says `connector`.** `bundle.installed` → `connector.installed`, `bundle.uninstalled` → `connector.uninstalled`, `bundle.crashed` → `connector.crashed`, `bundle.restarting` → `connector.restarting`, `bundle.cooldown` → `connector.cooldown`, `bundle.recovered` → `connector.recovered`, `bundle.stderr` → `connector.stderr` (which also moves its JSON log record's `ns` from `bundle` to `connector`). The first two are SSE event types on `GET /v1/events` and workspace-log `event` values — **breaking for `/mcp` and SSE clients** subscribing by name, and for any saved `jq`, Loki or log-search query that matches them; a grep across a retention window that spans the upgrade needs both spellings. The liveness four are the nested `event` discriminator on `run.error`, read by the connector-crash metric. The names now say what the event is a fact about: a connector's presence and its transport, distinct from `connection.*`, which is one workspace-connector-principal triple and its credential (ADR-0033). PostHog captures `connector.installed` / `connector.uninstalled` under the new names, so those two series split at the release and cannot be backfilled.
- **A skill's tier is `provided`, and a connector-published skill's name is `connector:<connector>:<skill>`.** The tier names where a skill is stored — the three writable directory tiers, plus everything the instance did not author and cannot edit; the name prefix names who published it (ADR-0034). **Breaking for `/mcp` clients** filtering `skills__list` by `scope`, and for anything parsing the `scope` field on `skills.loaded`. Nothing on disk changes: a skill's tier is stamped at load from its path and appears in no file.
- **Existing conversation-scoped mutes of a connector-published skill stop applying.** A mute is stored on the conversation as the skill's name and compared on every later turn, so the name change above ends the mutes standing under the old one — the skill composes again from the next turn and has to be muted again. The delivery-dedup marker keys on the same name, so a body already delivered in a live conversation is delivered once more. Recorded conversation events are not rewritten to match, and no reader is taught the old spelling: history is a record, not state. Stored skill statuses in Skills settings, and mutes of org-, workspace- and user-tier skills, are unaffected.
- **`automations__list` no longer accepts `source: "bundle"`.** An automation's source is `user` or `agent`. **Breaking for `/mcp` clients** passing the removed value.
- **The connector health metrics are renamed — `nb_bundle_crashed_total` → `nb_connector_crashed_total`, `nb_bundle_unhealthy` → `nb_connector_unhealthy`.** `bundle` names nothing in this runtime; both series have always measured connectors. Labels, semantics and help text are unchanged: the counter is still a per-sweep crash rate and the gauge is still the scrape-time down-detector that stays asserted for a whole outage. The `nb_bundle_*` spellings are gone — anything scraping them directly (recording rules, alerts, dashboards, exported panels) reads nothing until it is repointed. Note that a runtime image predating the rename still emits the old names, so a rule that has to cover a pinned-channel deployment alongside a current one needs to read both.

- **`${VAR}` expansion in a connector's transport config is gone.** A `transport.auth` token / header value or a `transport.headers` entry containing `${SOME_VAR}` is now sent verbatim instead of being filled from `process.env`, so a vendor sees the literal placeholder and rejects it. Nothing in the shipped catalog produces such a ref, and legacy Composio installs (the one shape that did) are mapped forward on read to name the `composio` credential provider — so no migration is expected. Audit with `grep -l '\${' <workDir>/workspaces/*/workspace.json` and replace any hit with a [credential reference](https://docs.nimblebrain.ai/config/secrets/) — `{ "ref": "credential", "key": "<key>" }` plus `manage_connectors set_secret` — which resolves at the connection's own workspace instead of the process environment.

- **A connector is addressed by URL, and nothing else.** `workspace.json → connectors[]` accepts only `{ url, serverName?, ... }`. An entry carrying `name:` (a registry package) or `path:` (a local directory) no longer starts, and its `env` / `allowedEnv` / `trustScore` fields are no longer read. There is no automatic migration, because there is no URL to migrate to: a server previously downloaded and spawned in-pod has to be deployed somewhere reachable and re-installed from the catalog against its endpoint. Audit with `grep -l '"name"\|"path"' <workDir>/workspaces/*/workspace.json` before upgrading; pin the instance until every such entry is converted. The removals that follow from it: `manage_apps` (org-wide app version management, including check-for-updates and upgrade), the `set_user_config` / `clear_user_config` actions on `manage_connectors` and the Configure page's bundle-credential form, the `mpak-bundle` install kind and the seeded `mpak` registry row, and `AppInfo.type` / `AppInfo.trustScore` on `GET /v1/apps`. **Breaking for `/mcp` clients** calling `manage_apps` or those two `manage_connectors` actions.
- **`skills__update` now requires `body_mode` whenever `body` is given** — `append` to add to the skill, `replace` to overwrite the whole body. It previously replaced unconditionally, so an agent asked to "add a rule" that sent only the new section silently discarded everything else. Neither default is safe (`replace` destroys content, `append` duplicates a full rewrite), so the tool asks rather than guesses; a body with no mode is a clear error and writes nothing. Callers that pass `body` must add `body_mode`. Manifest-only patches are unaffected.
- **The org-wide instructions overlay is removed, and `write_instructions` no longer takes a `scope`.** `instructions://org` is gone; `write_instructions({ body })` writes the workspace overlay and nothing else. Org-wide standing guidance is an **org-tier skill** (`{workDir}/skills/`, authored at Organization → Skills), which is file-backed and org-admin-gated exactly as the overlay was, and additionally carries per-topic granularity, dynamic loading, and triggers — so org content that is only sometimes relevant costs nothing on the turns it isn't. No migration ships: an `{workDir}/org/instructions.md` left on disk is simply no longer read. Copy any such file into an org-tier skill before upgrading. **A caller that still sends `scope` is refused** with a message naming the replacement — the argument is not ignored, because ignoring it would land an org-intended body on the workspace overlay, which is overwrite-only and would lose whatever was there. **Breaking for `/mcp` clients** that call `instructions__write_instructions` with a `scope` argument, and for any that read the `instructions://org` resource.
- **The `reasoning` model slot is removed.** Nothing in the runtime ever read it — `default` serves every chat turn and `fast` serves titles, the briefing and both history folds — so it was a configurable field that implied routing the engine never did. `models.reasoning` in `nimblebrain.json` or `workspace.json` is now ignored (in `nimblebrain.json` it warns as an unknown key and boots; delete it at your convenience), and the Reasoning Model control is gone from settings. **Breaking for agent profiles**: `"model": "reasoning"` no longer resolves to a slot. It falls through to the bare-id fallback, becomes `anthropic:reasoning`, and fails at the provider on the first delegate call. Name the model directly instead — `"model": "anthropic:claude-opus-4-6"` — which is what the slot was standing in for. `"default"` and `"fast"` are unaffected, in both the bare and `alias:` spellings.
- **Top-level `personalWorkspaceId` is removed from the `GET /v1/bootstrap` response — any out-of-tree client reading it should switch to `workspaces[].isPersonal`.** It carries the same fact per entry and is what in-tree callers already use. No migration; no in-tree consumer read the field.
- **Five Composio/Smithery settings env vars are removed; those settings now live in `nimblebrain.json` only.** `COMPOSIO_API_BASE_URL` → `connectors.providers.composio.baseUrl`, `COMPOSIO_MONITOR_ENABLED` → `…composio.monitorEnabled`, `SMITHERY_NAMESPACE` → `connectors.providers.smithery.namespace`, `SMITHERY_API_BASE_URL` → `…smithery.baseUrl`, `SMITHERY_MONITOR_ENABLED` → `…smithery.monitorEnabled`. A deployment that configured Smithery through the documented `SMITHERY_API_KEY` + `SMITHERY_NAMESPACE` pair loses its namespace and the provider unregisters — brokered connectors stop until the namespace is declared; boot logs name the key to set. **The two `*_MONITOR_ENABLED` removals fail silently instead**: a deployment that had the revalidation probe switched off gets it back on with no boot output, and the sweep resumes marking lapsed connectors `reauth_required` — declare `monitorEnabled: false` in the block before upgrading. A removed `COMPOSIO_API_BASE_URL` reverts to the public backend, visible in the `[composio] integration: configured (base=…)` boot line. The broker API keys (`COMPOSIO_API_KEY`, `SMITHERY_API_KEY`) are unchanged and remain the one value with an env fallback, because a secret has a legitimate home in the environment ([#840](https://github.com/NimbleBrainInc/nimblebrain/pull/840), [#839](https://github.com/NimbleBrainInc/nimblebrain/issues/839)).
- **The `ws_<id>-<source>__<tool>` wire form is retired — `/mcp` clients that send it must re-list.** It is rejected rather than routed (`-32602`, `data.reason: "invalid_tool_name"`), which is what makes a second workspace *unnameable* rather than merely denied: no tool name can address a workspace other than the session's own, which comes from its `/mcp/<wsId>` URL. A cached `tools/list` is the migration — re-list and use the bare names. Two smaller shape changes ride along: `data.reason: "cross_workspace_reach_denied"` no longer exists (that class is deleted; the retired form is refused earlier), and `data.wsId` on `workspace_access_denied` is now always `""` — its one remaining producer is a session with no workspace to name.
- **Personal connectors are renamed on the wire — `gmail__send` is now `my_gmail__send`.** Affects any caller that hard-codes a connector tool name: `/mcp` clients, and on-disk `allowedTools` / `toolAffinity` globs in skill frontmatter or automations. A glob written against the bare name (`granola__*`) no longer matches that connector and fails closed — degraded surfacing, not a leak. No stored state changes — `serverName` is untouched, so credentials, grants, permissions and placements are unaffected.
- **`conversations`, `files`, `automations`, and any `my_`-prefixed name are reserved source names — an already-installed source using one stops starting on upgrade.** With workspace names bare, a workspace bundle named `conversations` is no longer distinguishable from the kernel identity source, and the identity door is consulted first — so such a source is unreachable regardless. The check runs at every boot (`validateServerName`) and on the personal-connector install path (`isReservedServerName`), so it covers **both** workspace bundles and users' own connectors. Failure is contained per-bundle: one dead source and a log line, not a failed tenant start. Before rolling out, check installed workspace `serverName`s **and** `users/<id>/connectors.json` for those four shapes. Only an explicitly-set `serverName` can collide — `slugifyServerName` never emits `_`.
- **Files relocated to workspace-owned storage.** Upgrade floor: v0.10.0. Files move from identity-owned `users/<userId>/files/` to `workspaces/<wsId>/files/<ownerId>/` (per-owner registry + bytes + sidecars). The `files://<id>` URI stays bare; the workspace is resolved from the ambient request (`RequestContext.fileWorkspaceId`, set on both doors). Two consumer-visible changes: **(1)** the browser serve endpoint is now `GET /v1/files/:id?ws=<wsId>` — bare links (no `?ws=`) get `400 workspace_required`; the web client appends it automatically. **(2)** an external MCP client reads a `files://` in the workspace its `/mcp/<wsId>` URL names; the iframe bridge does the same, so synapse apps are unaffected.
- **Conversations relocated to workspace-owned storage.** Upgrade floor: v0.10.0. Conversations move from the flat top-level `{workDir}/conversations/<convId>.jsonl` to `workspaces/<wsId>/conversations/<ownerId>/<convId>.jsonl`. The path is now the binding: `Conversation.workspaceId` is fixed at create (the workspace of the first message) and a chat no longer switches workspaces mid-session. Workspace deletion becomes **archive-then-cascade** — the workspace's subtree is tombstoned to `archived/<wsId>/` (recoverable) rather than hard-deleted, since the workspace now owns its members' private chats.
- **Automations relocated to workspace-owned storage + runs decoupled from conversations.** Upgrade floor: v0.10.0. Automations move from identity-owned `users/<userId>/automations/automations.json` (one collection) to per-automation files at `workspaces/<wsId>/automations/<ownerId>/<automationId>.json`; the scheduler now scans `workspaces/*/automations/*/` and keys runs by `${wsId}/${ownerId}/${id}`. A run no longer writes a conversation: it persists a **run result** under `…/automations/<ownerId>/runs/<automationId>/` — an append-only `index.jsonl` of `AutomationRun` summaries (the `conversationId` field is gone) plus a per-run `<runId>.result.json` (full output + activity log + output-file refs). `runtime.executeTask` returns `runId` instead of `conversationId`; the `_runs/<automationId>/` conversation/file partition is removed. Historical automation-run *conversations* under `_runs/` are no longer read — the run history is the run results going forward.
- **Every MCP connection names its workspace in the URL: `<origin>/mcp/<wsId>`.** Bare `/mcp` returns `404`; no workspace is chosen by default. A token from the MCP authorization server is accepted only when its `aud` is exactly that URL, and on no `/v1/*` route; membership of the workspace is checked on every request, and a session is bound to (identity, workspace). **Every external MCP client must reconnect** with the URL under Workspace settings → MCP; tokens minted for the origin are refused. **Operator action:** register a resource indicator covering `<origin>/mcp/*` on the authorization server for each public host, or it ignores `resource` and every MCP token is refused. See [The MCP Endpoint](https://docs.nimblebrain.ai/connect/mcp-endpoint/) and ADR-0036.
- **Skill manifest on-disk format change: tenant skills in the legacy flat format must be rewritten to the canonical shape, with the tooling in any release from `v0.8.0` through `v0.35.0`, before taking a later release.** The skill manifest is now one strict-validated schema: NimbleBrain config nests under `metadata.nimblebrain.*` (`loading-strategy`, `priority`, `status`, `tool-affinity`, `triggers`), `type` is replaced by `loading-strategy` (`always` | `dynamic`), `keywords` fold into `description`, and `requires-bundles` / `overrides` / `derived-from` are removed. The `skills__create` tool now constrains `priority` to 11–99 (0–10 reserved for core skills) — an input-contract narrowing only; the on-disk schema still accepts 0–100. The loader **fails soft** — a skill still in the legacy flat format fails validation and is **silently skipped** (dropped from agent prompts), so a deployment that upgrades without rewriting them loses every legacy-format tenant skill.
- **Files and automations move to identity-owned storage** (Phase B / C), superseded by workspace-owned storage (upgrade floor: v0.10.0). Wire-contract change: external `/mcp` clients now see bare `files__*` / `automations__*` (identity door), and `files://` reads are identity-scoped (no `X-Workspace-Id` required).
- **Registries unified on upstream MCP `ServerDetail`** ([#195](https://github.com/NimbleBrainInc/nimblebrain/pull/195)). Operator-facing migrations:
  - `RegistryType` values `"curated"` / `"directory"` renamed to `"static"` / `"mcp"`. Any `NB_REGISTRIES` JSON pinning the old strings must be re-typed; `registries.json` is auto-migrated on next read by re-seeding the locked default.
  - The locked seeded registry id `"curated"` is now `"bundled-static"`. Existing `registries.json` rows are preserved; the new id is re-added if missing.
  - Seeded mpak registry rows no longer carry a hardcoded `url` — the mpak SDK owns its default registry host. Operators with a stale `url: "https://mpak.dev"` (the marketing site, not the registry) on a persisted mpak row should drop the field; self-hosted operators set `url` explicitly via the admin UI or `NB_REGISTRIES`.
  - `DirectoryEntry.id` (and `InstalledConnector.catalogId`) is now the reverse-DNS `ServerDetail.name` (e.g. `io.asana/mcp`) instead of bare slugs (`asana`). Catalog-keyed state — `workspace.json#oauthOperatorApps[<id>]` and the matching `<id>.client_secret` credential entries — must be rekeyed to the reverse-DNS form before existing static-auth connectors will resolve.
  - `NB_CATALOG_PATH` removed (was previously deprecated; never deployed). Use `NB_REGISTRIES` with a `static`-type registry pointing at a `ServerDetail[]` YAML/JSON file.
  - `BundleInstance.serverName` for new installs is now the slugified canonical reverse-DNS form (`com-canva-mcp`, `dev-mpak-nimblebraininc-echo`) instead of a short brand slug. Existing installs keep their persisted `serverName` and continue to resolve under the legacy form via `serverNameFromRef`'s fallback. Operators querying by `serverName` (logs, audit, `manage_connectors.uninstall`) need both forms during the transition; new installs of an existing bundle use the new slug.
- **Token-usage shape unified.** The runtime, engine, and conversation events now share a single `TokenUsage` struct (`{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }`). Internal layers that exposed flat fields are restructured:
  - `EngineResult.inputTokens` / `outputTokens` → `EngineResult.usage`. `EngineResult` also gains `usage` and `llmMs` (the side-channel `RunMetricsCollector` is gone).
  - `TurnUsage` now extends `TokenUsage`. Top-level `inputTokens` / `outputTokens` / `cacheReadTokens` are still present (via the extension) but `costUsd` is **removed** — cost is computed at the API boundary from `(model, usage)`. Wire-format clients (`done` SSE event, `POST /v1/chat`) still see `usage.costUsd`.
  - `ChatResult` no longer has top-level `inputTokens` / `outputTokens`. Read them from `result.usage.inputTokens` / `result.usage.outputTokens`. The wire format (HTTP response) keeps the top-level fields for backward compatibility.
  - `ChatFnResult` (exported from the automations bundle) drops top-level `inputTokens` / `outputTokens`; `usage` now contains them. External `ChatFn` implementers must update.
- **`Conversation.totalInputTokens` / `totalOutputTokens` / `totalCostUsd` removed.** Totals are derived from events at read time and surfaced on `ConversationSummary`. Stores no longer maintain rolling totals on the line-1 metadata; pre-deploy values written there are ignored.
- **`StoredMessage.metadata.usage` replaces flat `inputTokens` / `outputTokens` / `cacheReadTokens` / `costUsd`.** Pre-deploy assistant messages with the flat shape produce no derived usage (deliberately — see "Old usage data shows zero" below).
- **JSONL `llm.response` event shape changed.** Per-call usage is now nested under `usage` instead of flat `inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheCreationTokens` / `reasoningTokens` siblings. Field rename: `cacheCreationTokens` → `cacheWriteTokens` to match AI SDK V3 vocabulary.
- **SSE `llm.done` event shape changed.** `POST /v1/chat/stream` consumers that subscribe to per-iteration token counts must read `data.usage.inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens` / `reasoningTokens` instead of the previously-flat `data.inputTokens` / `data.outputTokens` / `data.cacheReadTokens` / `data.cacheCreationTokens` / `data.reasoningTokens`. The `done` event's wire shape (`usage.costUsd` etc.) is unchanged.
- **`UsageReport` `cost.cacheCreation` / `tokens.cacheCreation` renamed to `cacheWrite`.** External consumers of `aggregateUsage` output read `undefined` until updated.
- **`UsageReport.tokens.input` is now non-cached input only.** Pre-fix it was the AI SDK V3 grand total (which already includes cache reads and cache writes). Post-fix it's the non-cached portion, so `tokens.input + tokens.cacheRead + tokens.cacheWrite` equals the grand total — same shape as the cost breakdown and the Anthropic dashboard. External consumers will see lower `tokens.input` numbers; switch to summing the three buckets for grand-total input.
- **Old usage data shows zero after deploy.** Conversations created before this release have token totals on disk in the old (flat-field) shape. The new readers do not migrate that data — they skip it and contribute zero. Existing-conversation rows in the conversation list and the usage dashboard show $0 / 0 tokens for activity that predates the deploy. New turns from the deploy onward show correct numbers (now including cache writes, which were under-billed before).
- **Conversations moved to top-level user-owned storage** (Stage 1), superseded by workspace-owned conversations (upgrade floor: v0.10.0).
- **Removed `manage_conversation` actions:** `shareConversation`, `unshareConversation`, `addParticipant`, `removeParticipant` are gone. Single-owner semantics; sharing returns in a future stage with policy gates. External callers that previously invoked these actions get an `unknown action` error.
- **`Conversation.visibility` and `Conversation.participants` removed from the schema.** Reads of files that still carry these fields skip them at parse time; writes never produce them. `ownerId` is now required on every conversation file — files without one fail to load.
- **`/v1/conversations/:id/events` takes no workspace.** It is located by conversation id and authorized by ownership: foreign-owner conversation → 403 `conversation_access_denied`; non-existent → 404 `not_found`.
- **Personal workspaces enforce sole-owner-admin membership and freeze `isPersonal` / `ownerUserId` post-create.** `WorkspaceStore.update` / `addMember` / `removeMember` / `updateMemberRole` throw `PersonalWorkspaceInvariantError` on any member or identity-field mutation against a personal workspace; `WorkspaceStore.create` rejects a personal workspace whose initial members aren't exactly `[{ userId: ownerUserId, role: "admin" }]`. Upgrade floor: every personal workspace has its owner as sole admin — `connectors`, `name`, `about`, `customInstructions` remain freely mutable.
- **User credentials live in the user's personal workspace**, not `users/<id>/credentials/`. Upgrade floor: v0.10.0.
- **`BundleRef.oauthScope: "user"` deleted** (T008). Loader throws `LegacyOAuthScopeError`; catalog field renamed `defaultScope` → `defaultBinding: "workspace" | "personal"`.
- **`installConnector(entry, wsId)`** — `wsId` now required (T010); pre-Stage-2 callers no longer compile.
- **`ToolSource.execute`** — `principalId` argument removed (T008); identity flows via `RequestContext` AsyncLocalStorage. Third-party implementers must drop the parameter.
- **`ChatRequest.workspaceId` / `ChatResult.workspaceId` removed** (T006). Chat is identity-bound; per-turn workspace is determined by the namespaced tool name.
- **`SessionMeta.workspaceId` hard-cut** (T007, Q4). `RedisSessionRegistry` ignores legacy `workspaceId` on read.

### Fixed

- **A cancelled turn on an agent runtime records the usage it spent.** The model calls the agent finished before the cancel are priced in the usage ledger; they recorded nothing ([#1877](https://github.com/NimbleBrainInc/nimblebrain/pull/1877)).
- **Title and compaction calls are recorded under their run in the usage ledger**, matching what the model gateway charges the run, so per-run cost includes them ([#1867](https://github.com/NimbleBrainInc/nimblebrain/pull/1867)).
- **Connectors whose OAuth server sends the RFC 9207 `iss` parameter, Notion's among them, connect.** The callback now hands `iss` to the token exchange, which refused the code without it ([#1888](https://github.com/NimbleBrainInc/nimblebrain/pull/1888)).
- **Cancelling a run on an external agent worker stops the worker's turn.** `session/cancel` and the connection close now reach the worker over HTTP, and a worker that does not answer the cancel within 5 seconds is disconnected ([#1870](https://github.com/NimbleBrainInc/nimblebrain/pull/1870)).
- **GreenPT runs no longer fail with `Unexpected role 'user' after role 'tool'`.** Bound skill guidance and the final-step reminder ride the last tool result, and replay closes a run that ended on tool results, so no user message follows a tool message ([#1872](https://github.com/NimbleBrainInc/nimblebrain/pull/1872), [#1878](https://github.com/NimbleBrainInc/nimblebrain/pull/1878)).
- **On an app route with slashes (`crm/companies`), the top bar's title and settings gear find that view** instead of the one routed at the first segment ([#1889](https://github.com/NimbleBrainInc/nimblebrain/pull/1889)).
- **A run whose model budget is spent stops at once instead of failing as "Too Many Requests".** When the model gateway refuses a call for the run's spent budget, the run ends with `spend_limit` (account `model-gateway:run`) and no retries, on the native engine and on a nimblebrain-agent worker; a provider's own 429 is still retried ([#1864](https://github.com/NimbleBrainInc/nimblebrain/issues/1864), [docs](https://docs.nimblebrain.ai/concepts/)).
- **A chat turn backs off and retries a transient provider error** (rate limit, overload, 5xx, network failure) instead of failing on the first one. Retries follow the provider's own `isRetryable` verdict, then the HTTP status; a 401 always fails at once ([docs](https://docs.nimblebrain.ai/deploy/observability/#model-provider-retries)).
- **A provider error sent mid-stream fails the model call instead of ending the turn silently.** An in-stream `overloaded_error` or `rate_limit_error` now throws `ModelStreamError` with the provider's `statusCode` and `isRetryable`, so it is retried before output and reported after it, rather than ending as `no_finish_part`. A chunk that fails to parse still lets the turn finish ([#1836](https://github.com/NimbleBrainInc/nimblebrain/issues/1836), [docs](https://docs.nimblebrain.ai/deploy/observability/#model-provider-retries)).
- **A connector whose credential was rejected stays reported, and stops retrying, across a restart.** New gauge `nb_connector_reauth_required{source}` counts connections that need Reconnect; `nb_connector_unhealthy` now means a transport outage only. The runtime no longer restarts such a connection in the background, and a brokered connection keeps `reauth_required` after a restart ([docs](https://docs.nimblebrain.ai/deploy/observability/#connector-metrics)).
- **A static-auth catalog entry without a complete `oauthClientSetup` is dropped at catalog load**, with the reason [the docs](https://docs.nimblebrain.ai/config/connectors-catalog/#validation) already gave, and the deployments catalog check reports it. It used to reach Browse and fail at install.
- **A promotion no longer delivers unlimited skill guidance.** Every delivery of tool-bound skills is held to the same limit as a held call (about three skill bodies), so promoting one tool of a multi-skill bundle no longer adds every body at once.
- **Nebius gpt-oss tool calls no longer fail the run.** Nebius sometimes streams a tool call's closing fragment on the next index with no id; `@ai-sdk/openai-compatible` 3.0.62 attaches it to its call instead of failing with `Expected 'id' to be a string.`
- **Signing out under WorkOS ends the WorkOS session.** Sign-out revokes it, so the next sign-in asks for credentials instead of resuming the old one, and a person can switch accounts.
- **The model sees a remote tool's structured data.** A remote server that returns its data only in `structuredContent`, with a summary as the text, now has that JSON passed to the model as text (within the usual result bound) instead of the summary alone.
- **The chat panel's resize handle and the usage chart's daily bars work from the keyboard.** Arrow keys resize the panel; a focused bar shows its day's breakdown and is labelled for screen readers.
- **A remote connector redeployed at the same URL reaches agent runs within a minute.** A run's tool list re-fetches a remote server's tools once they are older than 60 s, so a new tool or parameter is callable without a runtime restart ([#1683](https://github.com/NimbleBrainInc/nimblebrain/issues/1683)).
- **Every task run leaves a result.** A run that failed before the engine returned, never started, or was lost with its process now writes `<runId>.result.json` too, with an empty output and its `error`, so `tasks__run_result` and the Tasks panel read one shape. See Breaking for the upgrade floor.
- **`/mcp/<wsId>` refuses an untrusted browser `Origin` with `403`** (DNS-rebinding protection). A hostname you reach the web UI by, other than the public origin, goes in `ALLOWED_ORIGINS`. [Docs](https://docs.nimblebrain.ai/deploy/security/#browser-origins-at-mcpwsid).
- **`manage_users` refuses what it reports as refused.** Demoting or deactivating the last admin answers `isError: true`, and changing your own role or deactivating yourself is refused on the server, not only hidden in the page.
- **OIDC claims or provisions a user only from a verified email.** A token whose `email_verified` claim is not `true`, or is missing, is refused, so an unverified email can no longer take over a pre-created user or pass `allowedDomains`. **Breaking** for a provider that omits the claim — see Breaking ([#1662](https://github.com/NimbleBrainInc/nimblebrain/issues/1662)).
- **An OIDC user created ahead of time is bound to its sign-in subject.** The first sign-in that claims the record by email records the issuer and subject on it, and later sign-ins find it by that subject, so an email changed at the provider keeps the same account. A record bound to one subject is never matched by email again: a different subject with that email is refused and logged, and no second record is created. Records with no subject bound yet bind on their next sign-in, so no migration is needed.
- **Listing conversations no longer re-parses a long conversation on every refresh.** While a turn runs, the conversations list re-reads only the events appended since its last read, so a refresh on a multi-megabyte conversation costs under a millisecond instead of blocking every chat on the tenant for up to 0.7s ([#1637](https://github.com/NimbleBrainInc/nimblebrain/pull/1637)).
- **A connector whose sign-in ended is left to rest.** After a sign-in is declined or times out, the health monitor no longer restarts the connector every sweep with no way to succeed; Reconnect starts a new sign-in ([#337](https://github.com/NimbleBrainInc/nimblebrain/issues/337)).
- **A brokered Google connection shows its account.** When the broker records no display name, the account comes from the id_token the vendor issued, so a Google toolkit whose auth config grants `openid` and `userinfo.email` reads "Connected as …" like Gmail does ([#1612](https://github.com/NimbleBrainInc/nimblebrain/issues/1612)).
- **Connect on a personal connector resumes a sign-in that was left open.** Closing the provider's tab and clicking Connect again returns the same sign-in instead of "already in progress" until the 15-minute timeout, and a connector whose sign-in is still open no longer reads "Connected" ([#1611](https://github.com/NimbleBrainInc/nimblebrain/issues/1611)).
- **A declined or refused sign-in frees the connector at once.** The OAuth callback ends the pending flow when the server answers with an error, and so does disconnecting or removing the connector mid-sign-in, so Connect no longer answers "already in progress" until the flow's 15-minute timeout ([#1433](https://github.com/NimbleBrainInc/nimblebrain/issues/1433)).
- **Config startup lines log at their real level.** An applied override is `info`, and an ignored override key, an unknown key or a deprecated key is `warn`, so a clean boot no longer logs `error` lines that alerting counts ([#1545](https://github.com/NimbleBrainInc/nimblebrain/issues/1545)).
- **Deleting from the Files app works again.** The app confirmed with `window.confirm`, which the app sandbox blocks without showing anything, so every delete was silently cancelled. It now confirms in the app.
- **A browser's force-dark mode leaves the web client alone.** It has its own dark theme, so it now opts out of Dark Reader (and Firefox for iOS's website dark mode, built on it) and Chrome's auto-dark, which repainted a light-mode page into a half-dark one with an unreadable logo and white apps.
- **A catalog entry's grants bind to its own server.** Host UI, `hooks` delivery and the outbox apply only to a connector at the entry's URL, while `admin_tools` applies to any connector under its name; a catalog install always uses that URL, and two entries whose ids share a server name are both refused at load ([docs](https://docs.nimblebrain.ai/config/connectors-catalog/#validation)).
- **A connector's lifecycle handlers can be called only by the host.** The tools a server marks under `ai.nimblebrain/lifecycle` leave every tool listing and are refused on every door with `host_only_tool`, admins included, so no one can forge an install or removal event ([docs](https://docs.nimblebrain.ai/extensions/lifecycle/), [#1458](https://github.com/NimbleBrainInc/nimblebrain/issues/1458)).
- **A connector skill loads for the tools it names, not every tool of its connector.** A server-published skill or curated overlay that declares `metadata.nimblebrain.tool-affinity` (bare tool names or globs) is bound to those tools under the connector's namespace; one that declares none still loads for any of the connector's tools. A declared pattern that matches none of the connector's tools is logged as a warning, and an overlay's `metadata.nimblebrain` block may carry `tool-affinity` alone ([#1467](https://github.com/NimbleBrainInc/nimblebrain/issues/1467)).
- **An agent call naming an argument its tool's schema does not declare is refused before dispatch, with the valid arguments listed.** ([#1466](https://github.com/NimbleBrainInc/nimblebrain/issues/1466)).
- **A remote OAuth connector whose server refuses `openid` signs in again.** A server that advertises OpenID Connect but answers `invalid_scope` to this client is asked once more with the connector's own scopes, and the row reads **Connected** with no account ([#1431](https://github.com/NimbleBrainInc/nimblebrain/issues/1431)).
- **`tools/call` answers an unknown tool with a standard error body.** The `404 tool_not_found` now carries `message`, with `server` and `tool` under `details`, like every other REST error.
- **A disconnected connector reads as at rest, not broken.** Disconnect leaves a workspace connector installed as a neutral **Not connected** with **Connect**, and off the overview's attention list; amber **Reconnect** is kept for a connection whose credential the vendor rejected, and now survives a restart. Disconnect asks first and says what stays and that Uninstall removes it.
- **A remote OAuth connector signed into mid-session lists its tools.** A tool read during pending auth reached a client that had not finished its handshake; the MCP SDK answered with an empty list, and the source kept it, so the connector ran with 0 tools until the next restart. A source now reports "not started" until its client has connected.
- **A WorkOS token must be for the configured org, whichever issuer minted it.** With `organizationId` set, an AuthKit token whose `org_id` is another org, or absent, now gets a 401, as a User Management token already did. While WorkOS is failing, a cached identity is served for at most 30 minutes past its last successful check; after that the request gets a 503 (#912).
- **An answer cut off at the output ceiling ends the run instead of failing it.** A no-tool-call turn with `finishReason: "length"` ends with `stopReason: "length"`, and the user sends another message to continue. The engine no longer re-calls the model on the partial answer: that request ended on an assistant message, which current Anthropic models reject as prefill with a 400. The `context.length_continuation` event is removed.
- **A connector whose sign-in was never finished now says so in words.** When an interactive OAuth flow expired, the registry's own message reached the person who tried to connect — `[oauth-flow-registry] flow abc12345… timed out after 900000ms`, rendered verbatim on the connector card and handed to the agent, because `lastError` is a user-visible surface. The expiry is now a typed `OAuthFlowExpiredError` carrying the sentence that reader needs; the operator's message is unchanged in the logs.
- **A connector OAuth callback now records what happened to it.** Every terminal outcome of `/v1/mcp-auth/callback` — resolved, unknown or expired flow, cookie mismatch, missing or invalid state envelope, missing parameters, and the vendor's own `error` — is logged once as a structured line at the handler. Previously only the invalid-envelope case left any trace, so "the user never came back from the vendor" and "a callback arrived and a check refused it" were the same observation: silence. Authorization codes, states and cookies are never logged; a flow is identified by the same truncated state prefix the flow registry's expiry message carries, so the two correlate.
- **The file picker answers when the user cancels.** Dismissing the dialog without choosing settles the call with `{ files: [] }`, so an app's upload control returns to rest. Cancel was detected by a `window` focus heuristic that a dialog opened from inside an app iframe does not reliably trigger, leaving the request unanswered and the caller's picker pending with no error — the files app's button stayed on "Uploading…" until a reload.
- **An app built on the spec's own client can reach the host.** A JSON-RPC request id is a string *or a number*, and the MCP SDK numbers them from zero — but the bridge's schemas took a string, so the validator dropped every `ui/message`, `ui/open-link` and `synapse/action` frame such an app sent: the link never opened and the message never arrived. `ui/message` and `ui/open-link` are also requests in the spec, and the host served both and answered neither, which a client with a deadline reports as a timeout. Both are answered now — `ui/open-link` with an empty result, which means delivered rather than displayed, since opening with `noopener` leaves the browser no handle to report on — and a handler that throws answers rather than leaving the app waiting. The notification form older SDKs send is unchanged ([docs](https://docs.nimblebrain.ai/apps/bridge/)).
- **The chat composer stays editable while a reply is running,** and each conversation keeps its own unsent draft; only sending waits for the turn.
- **An app iframe's `tasks/get`, `tasks/result` and `tasks/cancel` are scoped to the server that ran the task,** as `tools/call` and resource reads already are. `/mcp` stamps each task with its source, the bridge names the app's server on every task request, and a task any other source ran answers as not found. Two sources that mint the same task id no longer share one entry. MCP clients that name no source keep today's behavior.
- **An app iframe's `resources/read` is scoped to its own server,** as `tools/call` and the resource listings already were. The bridge names the app's server on the read, and `/mcp` resolves it in that one source; a URI served by any other source reads as not found. MCP clients that name no source keep the workspace-wide read.
- **A deleted workspace stays deleted.** Its scheduled automations are disarmed before the subtree is archived — the scheduler kept them in memory and reloaded that map only from the automations tool surface, so one could still fire and, writing back, re-create the directory that had just been archived. Such a tree was invisible (a workspace directory with no readable record is skipped everywhere) and so was never deleted again. Workspace-scoped writes now go through one helper that requires the workspace to exist, and name the workspace when it does not. Existing stray directories are left alone — they are data of unknown provenance and removing them is an operator's call.
- **Deleting a workspace now tears down the connectors it held.** The ten-step teardown behind `manage_connectors uninstall` — the `on_removing` call, the OAuth revoke, the brokered connection's revoke at the vendor, the hook and cursor retirement, the tool-surface watches, the tool permissions — was reachable from nowhere else, so a workspace delete archived the subtree and left all of it standing: grants live at the vendor, sources registered, hooks holding their key ids. It is now one function that both paths call, run over every connector the workspace holds before the subtree is archived. Best-effort per connector — one unreachable vendor cannot strand the delete — and the per-connector outcome comes back on the result, so a revoke that failed is nameable after the record that named it is gone.
- **App iframes are always scoped to their own server;** the name-based cross-source exception is removed. `tools/call`, `resources/read`, the resource listings and `tasks/*` ignore a server named in `_meta["ai.nimblebrain/server"]` or `params.server`, and a tool name qualified with another server is refused, for every app.
- **Uninstalling a connector no longer leaves its credentials on disk.** A connector owns the workspace keys it names — through `secretHeaders` or, for the built-in `credential` provider, `providerAuth.config.key` — and removing it removes them; the confirmation names each key and when it was written. A key another installed connector still names is kept and reported, so one uninstall cannot break a sibling that shares it. Keys come from the connector's own persisted config, read server-side — `manage_connectors uninstall` never takes one. The orphan mattered more than it looks: the rotation surface renders only for an *installed* connector, so a leftover key was unreachable from the UI entirely — a live outbound credential nothing referenced and nothing admitted existed. ([docs](https://docs.nimblebrain.ai/config/secrets/#uninstalling))
- **A connector that needs a workspace secret asks for it at install.** An entry declaring `secretHeaders` now shows "Needs a credential" on its Browse card, and **Install** collects each value before adding anything — so the source starts with its credential already resolved instead of failing on the credential it was never given. Cancelling installs nothing. Replacing a value later is the same dialog on the connector's **Configure** page, which also reports which keys are set and when. The only path before this was typing the value into a conversation, where it landed in the transcript, the model's context, and whatever the conversation persisted to. A connector installed and broken this way heals on setting the key — nothing to reinstall. A `secretHeaders` reference may now carry optional `label` / `help` for the dialog to use. ([docs](https://docs.nimblebrain.ai/config/secrets/))
- **`data.changed` no longer crosses a workspace or an identity boundary.** The live-refresh broadcast now carries the workspace the change happened in, and is fanned out only to clients who are members of it — `/v1/events` is identity-scoped, so previously every signed-in tab received every workspace's `{server, tool}` activity. The browser drops what remains from a workspace the user belongs to but is not currently viewing, which is what stopped an app installed in two workspaces from re-fetching on the other one's writes. Identity-owned changes (`conversations`, `files`, `automations`) belong to no workspace and are still delivered to everyone.
- **The config schema no longer advertises a `move_scope` skills tool.** `skillManagement`'s description listed it among the tools it gates; no such tool exists. The description now names the write surface that does.
- **`usage__report` breakdowns are capped at the 250 costliest rows.** `conversation` and `turn` are keyed on ids minted per thread and per turn, so their row count grows with everything a tenant has ever done — at current volume that is tens of thousands of rows over the retention window, and the whole report is serialized twice before anything trims it. The engine bounds what reaches the *model*, but the full string is built in the process first and the unbounded copy is what gets written to the conversation record. Capped dimensions are named in a new `truncatedBreakdowns` field with the full row count; its absence means the breakdown is complete. `totals` counts every record either way, so no spend is missing — only rows from one view of it. `day` is exempt from the row cap because it is zero-filled to a contiguous series for the usage chart; what bounds it is a separate ceiling on that fill, which now stops once the window is wider than a year. It previously stopped only for `period: all`, and `from` overrides the period — so a `from` predating the ledger produced one row per calendar day since, tens of thousands of rows from a single stored record.
- **`usage__report --group-by conversation` no longer lists automation runs as conversations.** The grouping read the undiscriminated `sessionId`, which for a task run held its run id, so `run_…` keys appeared in a dimension the schema calls "conversation". A task has no conversation and now groups under `none` there; its spend is unchanged and still counted by `runs`.
- **`resources/list` is no longer called on servers that don't advertise resources.** Every tools-only server — including the platform's own in-process sources — answered `Method not found`, which the caller could only read as a transport failure. Costs a round trip per source per discovery, and made the new degraded signal fire constantly for healthy servers.
- **An automation can no longer author skills.** `skills__create` / `__update` / `__delete` / `__activate` / `__deactivate` / `__restore` are refused inside an unattended run and are no longer surfaced to it. A skill is durable guidance the runtime composes into later prompts on its own — `loading_strategy: always` reaches every subsequent turn — while a task run ingests untrusted content (email, web pages, tickets) with no human present to confirm anything, so a write from inside one put attacker-authored text into future interactive sessions. This is the wall `instructions__write_instructions` and the automation-authoring tools already had, applied to the surface that outranks both. The read tools (`list`, `read`, `history`, `loading_log`) stay open, so an automation can still audit the catalog and report what it found; a human makes the change from an interactive session.
- **An automation run no longer looks like a conversation to the code asking.** The run's correlation id was carried in `RequestContext.conversationId`, so one field answered two questions and five of its nine readers took the wrong one: `skills__active_for` reported `Conversation not found: run_…`, the compose tools resolved a conversation that does not exist, `use_skill`'s already-delivered check read one instead of skipping as its comment claimed, the briefing appended usage to a missing path, and a skill an automation created was persisted as `origin: "chat"` with a run id attached. A run now carries `runId`; the two readers that want a correlation id (the usage ledger's `sessionId`, telemetry) read that. **Operator-visible**: task spans carry `run_id` instead of a run id mislabelled as `conversation_id`, and automation-created skills stamp `origin: "admin"`. The usage ledger's `sessionId` is read-only legacy: records written before the id split still carry it and still aggregate correctly, but it is no longer written.
- **`conversations__list` reports message counts, not event counts.** The header read counted every line after line 1, which on an event-sourced conversation counts events — so a one-exchange chat listed as 5 messages, and every tool call added two more. The count now mirrors `conversations__get`'s rule (each user message, plus each run that produced a response), so the two surfaces agree about the same conversation. Affects `list`, `search`, `stats`, `export`, and `update`'s response; counts drop for every conversation created since the event format.
- **Entering an app no longer drops the workflow guidance its server publishes.** A bundle that publishes more than one skill lost all but the first on every turn sent from that app's panel — including `always` skills, which are the ones meant to be in context unconditionally. The bundle-skill pool excluded the entered *server*, on the premise that its guidance was recovered through the `<app-guide>` briefing; that briefing carries only the server's primary skill, so the premise held only while a server published exactly one. Deduplication is now per **skill**: the one body `<app-guide>` actually carries is withheld, and every other skill the same server publishes routes by its declared strategy, entered or not. Chats sent from outside the app panel were never affected, which is what made this present as an intermittent flap.
- **Renaming a conversation now takes effect.** `conversations__update` wrote the title to line 1 of the conversation file, but every reader projects it from the last `metadata.title` event — which the auto-titler appends on the first turn of every conversation. From that point on a rename was shadowed: the write landed, every surface kept showing the old title, and the tool reported the requested one back as success. An event-sourced conversation now gets an appended `metadata.title` event; a legacy one keeps the line-1 rewrite, because appending an event to it would switch the reader onto the event parser and hide its messages. The response is read back rather than assembled locally, so it also stops quoting line 1's stale token totals where `conversations__get` reports derived ones.
- **`conversations__update` / `__get` / `__fork` / `__export` can address the conversation you are in.** `id` is now optional on all four and resolves from the request when omitted — the same ambient fallback `skills__active_for` already used. An agent inside a chat is never told its own conversation id, so requiring one made the current conversation the only one these tools could not name; what an agent did instead was invent a placeholder and get `Conversation not found` back. The literal `"current"` is accepted as a spelling of the same thing (it cannot collide with a real id). Outside a chat the tools error and say to pass an id from `conversations__list`, rather than guessing.
- **A fruitless search no longer disables discovery three questions in.** The loop supervisor collapsed every non-advancing result for a tool to one fingerprint, so three `nb__search` calls with three *different* queries tripped it and dropped `nb__search` from the toolset — and since it is the only door to every proxied tool, the rest of the run answered "can this platform do X" with "no". Non-advancing results are now fingerprinted on their normalized input, so re-asking one question (case and whitespace folded) still trips at 3 while genuinely different questions do not. A separate per-tool non-advancing budget — 6 in a run, cleared by any advancing result — keeps the flail bounded.
- **`files__create` no longer saves a corrupted file when the body isn't base64.** `Buffer.from(body, "base64")` discards every character outside the base64 alphabet, so a caller that passed raw text got a successful create and a garbage file, with nothing to indicate it until someone opened it. The body is now validated before decoding and the call fails with what to do instead. `files__create` also takes `encoding: "base64" | "text"` (default `base64`): `"text"` writes the body verbatim as UTF-8, which is what an agent composing markdown, code, or CSV needs — hand-encoding a document to base64 is not something a language model does reliably, so "base64 or nothing" was never a contract it could hold to. **The default path narrows**: only the standard alphabet with `=` padding is accepted, and only line breaks may be interleaved. Unpadded base64, base64url (`-`/`_`), and space-interleaved base64 all decoded before and now error — padding and alphabet are what separate real base64 from prose, which without them passes every check. Re-encode such a body in the padded standard form; do not switch it to `encoding: "text"`, which would store the encoded string rather than the bytes.
- **Composio connectors that ask for a setup value now connect.** Toolkits whose auth config requires a connection-initiation field — WhatsApp's WhatsApp Business Account ID, Zoho's region, Jira's subdomain — failed the Connect click with `Composio rejected the connection initiate.` The redirect flow now runs on Composio's hosted Connect Link, which collects those fields from the connecting user. This also retires our use of a call Composio sunset for its managed OAuth configs on 2026-07-03, which would have broken *every* Composio OAuth connector on enforcement. Operator-visible: the consent dance now passes through a Composio-hosted page, so brand it (Composio dashboard → Project Settings → Auth Screen) and allow `dashboard.composio.dev` alongside `connect.composio.dev` if you filter egress. See the [Composio operator guide](https://docs.nimblebrain.ai/deploy/composio/).
- **The conversation list, search, and stats no longer cross the workspace wall.** Opening or refreshing a workspace could show conversations from another one — the Conversations app sent its workspace as a tool argument, and on the render before the host handshake resolved it had none to send, which the server read as "every workspace". `conversations__list` / `__search` / `__stats` now resolve the workspace from the request itself (the workspace it is bound to) the way `files__*` does, and take no workspace argument at all. A request with no workspace is refused rather than widened, and every REST and `/mcp` request names one in its URL. Search was never scoped and returned message snippets from other workspaces on every query. **Breaking for `/mcp` clients**: `conversations__list` and `__search` run in the workspace the `/mcp/<wsId>` URL names, and the `workspaceId` / `includeUnstamped` arguments are gone — a conversation is in the workspace its directory says, which is also what the locator has always read, so there is no longer an "unstamped" case for a caller to ask about. The workspace's briefing and `home__activity` counted other workspaces' chats and are scoped too, and the system prompt no longer tells the agent these tools search across all workspaces.
- **A chat's agent profiles and model-slot overrides now come from the workspace the chat runs in.** They were read from the caller's personal workspace, so a shared workspace's `agents` / `models` config never applied to chats held in it, and a user's personal overrides followed them everywhere. **Behaviour change for anyone who set either field**: personal-workspace overrides no longer apply to chats in a shared workspace, and a shared workspace's overrides now apply to every member's turns there. No migration — the fields are operator-set in `workspace.json` and have no UI writer.
- **The Context Ledger names skills instead of calling every connector's guidance "SKILL".** A skill published by a connector is a SEP-2640 resource whose id ends in `/SKILL.md`, so surfaces that derived a name from the id labelled all of them identically. `skills.loaded` now records each skill's own `name` and, for connector-published guidance, the `connector` that published it, and the rows read those. Rows also name the publishing connector where they used to print the wire tier `bundle` — connector skills are conventionally named for their job (`usage`, `workflows`), so the publisher is what distinguishes them. Runs recorded before the field existed still render (both read paths resolve a name).
- **"In context" no longer overstates the context window.** The `skills` row measures how much of the *system prompt* the composed skill bodies account for, not a fourth region beside it, so summing all four rows counted them twice (a turn showing 20.7k was really 14.5k). Both surfaces now total the three disjoint regions, state it once, and render skills indented under the system prompt. The `context.assembled` event's recorded `totalTokens` is unchanged; it is telemetry, and `compose__assembled_context` exposes the honest figure as a new `windowTokens` field and documents the overlap in its description. (`compose__effective_context`'s `totalTokens` does change — see below.)
- **`compose__effective_context` reports the prompt size in both modes.** With `run_id`, `totalTokens` returned the run's recorded four-row sum — the whole context window, plus the composed skill bodies a second time — while the same call without `run_id` returned the composed prompt. One field, two questions, so comparing a conversation across the two made the prompt look like it had grown by the size of tool descriptions and history. Historical mode now reads the recorded `system_prompt` row, the analogue of live mode's composed-layer sum. **Agent-visible value change on an existing tool**: a `run_id` call returns a smaller number than on 0.15.x. The tool description and the text summary now name the quantity; `windowTokens` on `compose__assembled_context` answers the other one.
- **History's message count is no longer labelled "turns".** `context.assembled` recorded the windowed *message* count in a field named `turns`, so a 47-message conversation read "47 turns". The field is `messages`; historical events are normalized on read.
- **Reconnecting a provider-minted connector no longer reports a spurious failure.** `startAuth` on a source that connects without an interactive flow (a provider-minted / already-authenticated connector — e.g. hitting "Reconnect" on a platform fleet connector) transitioned the source to `running` but then rejected the auth-URL promise, so the route logged `startAuth failed … already authenticated` and returned 500 even though the connector had reconnected. That headless success now resolves with no URL: the route returns `{ authorizationUrl: null }` and the UI refreshes state in place instead of redirecting to a nonexistent auth page.
- **App tools no longer surface a raw "MCP error … not started" when a remote source idle-closes.** A remote (fleet) MCP source whose long-lived connection idle-closes has its client torn down until the next `HealthMonitor` sweep; during that window a foreground app's tool call or `ui://` read surfaced the raw error (JSON-RPC `-32603`). `execute()` was also asymmetric with `readResource()` — it short-circuited on the `dead` flag, denying a still-live client the `recover()` self-heal that reads already got. `execute()` now dispatches a dead-but-live client (routing any throw through the same connection-down-only recovery), and both `execute()` and the app-surface `ui://` read re-establish a torn-down client on demand — `isStopped()`-gated so a deliberately-stopped source is never revived, and cooldown-floored so a persistently-down upstream isn't restart-hammered.
- **`nb__delegate` no longer hard-fails on an unknown agent profile.** Naming a profile that isn't configured returned an instant error and the delegation never ran — so skill protocols that named a profile the tenant hadn't defined (the model guessing `general`/`worker` → "Available profiles: none") failed outright. An unknown name now falls back to the default sub-agent and notes the fallback in the result; and the `agent` parameter is advertised only when the workspace has profiles configured, so the model isn't prompted to pick one that can't exist.
- **A resumed conversation stays sealed to its own workspace.** Tool, skill, app, and `## Workspace` context resolution followed the client's currently-focused workspace, so resuming a chat born in workspace A while focused on B answered with B's tools and reported B as "the workspace you're in" — a cross-workspace context leak. Resolution now keys off the conversation's authoritative workspace (`convWsId`, from its path) for the whole turn, matching how files already resolve; the focused workspace only chooses where a *new* chat is born.
- **Expected session-expiry no longer reported to Sentry as an error.** `captureLogout` was emitting an `involuntary logout: refresh_rejected` event on every definitive refresh-401 — i.e. every returning user whose refresh token had lapsed and every routine deploy of an environment left open, none of which is an error. `refresh_rejected` is now breadcrumb-only (it still decorates the trail of a real error that fires later); only `retry_401` — a freshly minted token rejected on the very next request, a genuine anomaly — is captured.
- **Multi-org WorkOS users no longer get involuntarily logged out (~hourly `retry_401`).** `WorkosIdentityProvider.refreshToken` minted the new access token without pinning `organizationId`, so WorkOS could refresh against the user's *default* org rather than the one the session was established under. The drifted token then failed `verifyRequest`'s `org_id` gate on the very next request — a refresh that "succeeds" yet yields a token the session rejects, surfacing as `involuntary logout: retry_401`. The refresh now pins the configured org. Additionally, `verifyRequest`'s previously-silent rejection paths now log a structured `reason` (`org_mismatch`, `token_expired`, `malformed_jwt`, …) so an involuntary logout names its cause instead of surfacing as a bare 401.
- **Removed a credential-leaking OAuth debug logger; added redacted connector-health logging in its place.** `NB_DEBUG_OAUTH_EXCHANGE` monkey-patched global `fetch` to log raw token-endpoint requests and responses at `warn` — access tokens, refresh tokens, client secrets, and auth codes in cleartext, bypassing field-level redaction. It was env-gated "temporary" diagnostic code left in `main`, re-armable by one env var. Deleted. In its place, `WorkspaceOAuthProvider` now emits **redacted** signals (booleans + lifetimes only, never token values): a `warn` when a token exchange returns **no refresh_token** (the connection can't refresh and will need a manual reconnect at access-token expiry — usually a missing offline-access param like `token_access_type=offline`), and a `warn` on the `reauth_required` flip. Same diagnostic power for the bug class it was added to chase, with zero secret exposure.
- **Remote OAuth connectors no longer drop to "Reconnect" on a transient blip.** A network error or 5xx from a connector's token endpoint during a mid-session access-token refresh was swallowed by the MCP SDK's `auth()` and converted into a fresh-authorization attempt, which the headless provider signals by throwing `UnauthorizedError` — read by `McpSource` as a dead credential, flipping the connection to `reauth_required` and dropping the connector from the run. A refresh-aware `fetch` on the OAuth transport now retries only the idempotent `grant_type=refresh_token` POST on transient failures (network/5xx/429/`temporarily_unavailable`) with bounded backoff, while a genuine `invalid_grant`/`invalid_client` still passes through to the real reconnect path. The connector-layer analog of the session-refresh fixes in [#434](https://github.com/NimbleBrainInc/nimblebrain/pull/434) / [#459](https://github.com/NimbleBrainInc/nimblebrain/pull/459).
- **Connector OAuth callbacks are public again.** A `requireAuth` `.use("*")` on the logout sub-app leaked onto `/v1/mcp-auth/callback` and `/v1/composio-auth/callback` (Hono flattens a sub-app wildcard onto sub-apps mounted after it), 401-gating their unauthenticated-by-design return leg — so a callback that landed on an origin without the user's session (e.g. the bouncer returning a custom-domain tenant to its branded host) never exchanged its `code` and wedged the connector at "Connecting…". `requireAuth` now attaches per-route to `/v1/auth/logout`.
- **Cache-write TTL is tiered by breakpoint stability.** The engine cached everything at a 1-hour TTL (2× base input), but after the rolling step-anchor the bulk of writes happen within a run — re-read seconds later, where the 1-hour premium (60% over 5-minute) buys nothing. The stable system+tools prefix now uses 1-hour (one write per run, worth keeping warm across a between-runs pause); the rolling history breakpoints use 5-minute. A meaningful cut in write cost on long conversations.
- **Cache-write cost is now priced per-TTL.** The cost model priced writes at the catalog's 1.25× 5-minute rate while the engine billed at the 2× 1-hour rate, under-reporting real cache-write spend by ~1.6× (verified against the live API's `cache_creation.ephemeral_1h_input_tokens`). The per-call 1h/5m split is captured on `TokenUsage` and each portion priced at its true rate; absent splits (legacy events) are treated as all-1h so historical figures stay accurate. Dashboards finally match the bill. **Operator note:** past cache-write spend re-prices ~1.6× higher the moment this deploys (it was genuinely under-reported), so month-over-month dashboards show a one-time step.
- **Final-iteration wrap-up hint no longer busts the system cache.** On a run's last allowed iteration the engine appended a "this is your final step" instruction to the system prompt — mutating the cached system block, which invalidated it and the entire message prefix after it on the final call of every run (a full-prefix re-write each run). The hint now rides a tail message in the volatile cache region instead, leaving the stable system prefix byte-identical so the final call reads it from cache.
- **Prompt cache no longer thrashes on long agentic runs.** The single cache breakpoint sat on the last *user* message, which is fixed for a run — so every iteration's freshly-appended assistant/tool content lived past it, uncached, and the growing prefix was re-sent at full input rate (measured: cache-write/non-cached dominated long-run cost, effective hit rate 14–40%). Cache-control placement moves out of the engine into a provider-scoped seam (`src/model/cache-policy.ts`, mirroring `applyReasoningReplayPolicy`); the Anthropic strategy places a rolling step-anchor breakpoint on each prior step's tail so the whole prior prefix is read back instead of re-written. Validated against the live API: expensive (non-cached + write) tokens drop ~70–100% on the within-run pattern. Other providers pass through (OpenAI caches prefixes automatically; the append-only prefix is all it needs).
- **Reasoning blocks are retained on replay instead of stripped per turn.** The replay policy stripped older Anthropic thinking blocks keyed on "is this the latest assistant message" — so the instant a turn stopped being latest its bytes changed, invalidating the cached prefix from that point. With the rolling step-anchor that bust landed just behind the anchor on every iteration, forcing a full re-write of the growing prefix. Under prompt caching, retained reasoning is written to cache once and read back cheaply, so the strip's token savings were dwarfed by the re-writes it forced. Stripping is only cache-safe beyond a stable frozen boundary (compaction), and compaction summarizes old turns into a reasoning-free block anyway, so the per-turn strip helper is removed rather than kept dormant. OpenAI/Gemini already retained reasoning (correctness); now all providers do.
- User- and workspace-scope `type: skill` skills now load instead of silently doing nothing: the trigger/keyword matcher runs per-request over the merged org+workspace+user pool (it was boot-only and never scanned those tiers), strategy-less skills created via `skills__create` auto-resolve to `loading-strategy: always` written to disk, and `skills__list` flags any skill that would never load. ([#391](https://github.com/NimbleBrainInc/nimblebrain/issues/391))
- Automation field edits no longer fail silently: a rejected update (e.g. an out-of-range `maxIterations`) now surfaces the error in the detail view instead of swallowing it and snapping the field back to its old value.
- Automation `maxIterations` cap raised from 15 to 50 (the engine's absolute ceiling), and the create-form default from 5 to 25. The old 5-step default amputated analytical automations mid-run — they hit the cap, produced no final output, and reported "No output captured." Iterations are a runaway backstop, not a cost budget; `maxInputTokens` and `maxRunDurationMs` bound per-run cost directly.
- **A stalled model stream no longer burns an entire run.** A model call that hung — connecting, prefilling, or mid-stream — used to block the agent loop until the run-level `maxRunDurationMs` fired, so one upstream stall consumed a whole chat/automation budget (and left automations with no deliverable). A per-call watchdog now bounds it in two regimes: a generous first-content deadline (covers connect + prefill, which scales with prompt size) and a tight inter-part idle once tokens are flowing. A stall before any output is retried via `withRetry` (transient, nothing streamed yet); a stall mid-stream fails cleanly rather than re-streaming deltas the client already saw.
- Gmail connector allowlist now exposes `GMAIL_DELETE_DRAFT` so agents can prune stale drafts instead of leaving every revision in the thread. (`GMAIL_DELETE_MESSAGE` deliberately omitted — irreversible permanent delete of arbitrary mail; use the already-exposed `GMAIL_MOVE_TO_TRASH` for reversible removal.) Note: destructive Gmail tools are also gated at the Composio project level — enable them on the gmail `auth_config` in the Composio dashboard and re-install the connector per workspace (the tool filter is baked at session-create time).
- `manage_users delete` is now a **soft delete**: it stamps a `deletedAt` tombstone on the local profile and revokes access (the auth layer denies any request from a deactivated user, both web and `/mcp`) instead of hard-deleting the WorkOS identity. Previously the WorkOS user was destroyed while the local profile and workspace memberships were left behind — so the deleted user still showed in the UI, and re-adding them minted a fresh WorkOS ID that orphaned every prior workspace membership. Deactivated users now stay listed and flagged "Deactivated" in **both** the org Users tab and workspace member lists, and `manage_users restore` re-enables them. The identity ID is preserved across deactivate/restore. The last-owner (org) and last-admin (workspace) guards count only *active* principals, so a deactivated owner/admin can't be mistaken for a live one and let you strand the org/workspace with zero functional admins.
- `runtime.chat()` now honors an `AbortSignal` end-to-end (`ChatRequest.signal` → `EngineConfig.signal` → iteration loop + every tool call). Callers racing the chat against a deadline (notably the automations executor's per-run budget) now cancel in-flight LLM/tool work instead of orphaning it; HTTP `/v1/chat` and `/v1/chat/stream` also forward `request.signal`, so client disconnect cancels engine work ([#251](https://github.com/NimbleBrainInc/nimblebrain/pull/251)).
- Task-augmented MCP tools no longer report a false `MCP error -32603: Task <id> failed` when the server stored a usable `tasks/result` payload; the engine refetches and surfaces the real content ([#245](https://github.com/NimbleBrainInc/nimblebrain/pull/245)).
- `automations__run` no longer hits `MCP error -32001: Request timed out` on multi-minute automations; `handleRun` caps its sync wait below the SDK's 60 s request timeout and returns a `{ status: "dispatched" }` envelope when the run outlasts the window, with the scheduler tracking it in the background ([#245](https://github.com/NimbleBrainInc/nimblebrain/pull/245)).
- Synthesized automation failure records now carry the real `startedAt` from dispatch time instead of the catch-clause instant, so operators can tell a long hang from a setup crash ([#245](https://github.com/NimbleBrainInc/nimblebrain/pull/245)).
- Timed-out (and cancelled) automation runs now report the real `inputTokens`/`outputTokens`/`toolCalls`/`iterations` and `conversationId` for the work done before the wall clock killed them, instead of `0/0/0/0`. `executeTask` honors its documented contract — returning a result on abort instead of throwing it away — so cost monitoring and token-budget auto-disable see actual usage, and the run links to its conversation for post-mortem.
- Agent runs no longer burn the iteration budget on a stuck MCP tool. Three additions cooperate: outbound args drop no-op optional values (empty strings, nil UUIDs, empty arrays) that some upstream APIs reject as malformed; tool results whose text looks like an upstream error (e.g. `Ran into an error: AxiosError…`) but ship `isError: false` are promoted to `isError: true` so downstream layers see truth; a per-run supervisor watches each tool for identical-fingerprint repeats and, on the 3rd, replaces the result with a stop-directive plus a one-shot system-prompt nudge so the model surfaces the error and ends the run instead of looping to `max_iterations`.
- Vision content is no longer dropped after the first turn of an agentic loop. Uploaded images survived turn 1 (the in-memory message still carried the bytes) but disappeared on turn 2+ — the user-message reconstructor filtered everything except text. Multi-turn flows like "extract from screenshot, then call a CRM tool" now work end-to-end.
- `estimateCost` no longer double-bills reasoning tokens for models with a separate `cost.reasoning` rate. Reasoning is a subset of `outputTokens` per the V3 usage spec; the corrected formula splits rather than adds.
- `estimateCost` no longer double-bills cache tokens. AI SDK V3's `inputTokens.total` is the grand total of all input-side tokens (including cache reads and cache writes); the prior formula charged the grand total at the full input rate AND charged the cache subsets again at their respective rates — a ~3x overcharge on cache-heavy turns. Fix subtracts cache subsets from the input total before applying the input rate. ([#140](https://github.com/NimbleBrainInc/nimblebrain/issues/140), [#151](https://github.com/NimbleBrainInc/nimblebrain/pull/151)).
- Live per-turn `usage.costUsd` (returned via the `done` SSE event and `POST /v1/chat`) now correctly includes cache write and reasoning costs. Previously the runtime layer dropped these fields before computing cost, under-billing cache writes and mis-billing reasoning. Cost is now computed at the API boundary from the canonical `TokenUsage` struct, so the live wire-format value matches what the catalog math produces.
- `EventSourcedConversationStore.fork()` now wraps each forked `llm.response` in synthetic `run.start` / `run.done` bookends. Previously, `history()` on a forked event-format conversation returned only user messages — the reconstructor only emits assistant turns inside an active run scope, and the unbookended events fell through to the "skip events outside a run context" branch. Independent of the cost work; surfaced during PR #151's QA.
- `stopReason` now reflects the model's real finish reason (length, content_filter, error, other) instead of always reporting `complete`; per-call `finishReason` is persisted on `llm.response` events and forwarded over SSE ([#105](https://github.com/NimbleBrainInc/nimblebrain/pull/105)).
- Settings pages now share a unified layout system (`SettingsFormPage` / `ListPage` / `DashboardPage` / `AppPanelPage`) that owns header, width, save bar, and section spacing — replacing per-tab variations of all four. Bundle settings panels render inside settings chrome (back-link, title, "provided by" footer) instead of a chromeless iframe. Org-admin "manage workspace" back button now goes to the right route.
- `maxOutputTokens` is now derived per-call from the synced model catalog (`limits.output`) instead of a static 16,384 default — Opus 4.7 jumps from 16k → 128k, Sonnet 4.6 → 64k. Operator-pinned values still win, clamped to the model's catalog max ([#104](https://github.com/NimbleBrainInc/nimblebrain/pull/104)).
- Default thinking config now works on Claude Opus 4.7. The engine translates `thinking.type=enabled` (which Anthropic rejects for that model) into `adaptive` + `output_config.effort`, gated on a per-model `supportsEnabledThinking()` predicate ([#118](https://github.com/NimbleBrainInc/nimblebrain/pull/118)).
- Skills tool surface honest about its input contract: `skills__list` text emits a row per skill with id+metadata (was counts only); `scopeOfPath` no longer falsely classifies bare ids as `bundle`; path-validation error names the actual roots (`org/workspace/user/bundle`); mutation tools' descriptions point callers at `skills__list` for ids; `bundleNotMutable()` returns the structured `suggested_action` shape from the design doc.
- `skills__active_for` now defaults `conversation_id` to the current conversation when called from inside a chat (the agent doesn't know its own conv id; requiring it forced agents to skip the tool). Explicit ids still win; calls outside a chat scope error with a clear message instead of silently falling back.
- Files-app uploads no longer fail with `Payload too large`. Picker bytes now flow through `POST /v1/resources` (multipart, workspace-scoped) instead of being base64-encoded into a `tools/call` argument; `isAllowedMime` strips Content-Type parameters, fixing a latent miss in the chat ingest path too ([#93](https://github.com/NimbleBrainInc/nimblebrain/pull/93)).
- `nb__read_resource` and `POST /v1/resources/read` resolve `ui://` resources published by platform built-ins (settings, home, automations, conversations, files, usage, nb). Previously the structural type guard couldn't distinguish two divergent `readResource` shapes and silently skipped any platform source ([#90](https://github.com/NimbleBrainInc/nimblebrain/issues/90)).
- `/v1/resources/read` request-context propagation. The route handler wasn't wrapping its source-call in `runWithRequestContext`, so callback-form resource bodies that called `runtime.requireWorkspaceId()` (e.g. `instructions://workspace`) threw and surfaced as 404 "not found." Wrapping matches the existing `handleCallTool` pattern.
- `GET /mcp` returns 405 instead of opening an idle SSE listener. The standalone server→client stream was held open with nothing to write, so Bun's `idleTimeout` (and any L7 proxy: Vite dev, ALB, nginx) silently killed it — surfacing as `[vite] http proxy error: /mcp` + `socket hang up` and exhausting the SDK client's reconnect budget. The MCP SDK treats 405 as "no GET-style listening" and runs POST-only ([#138](https://github.com/NimbleBrainInc/nimblebrain/pull/138)).
- Settings model-picker no longer 404s non-Anthropic models. The dropdown now encodes `provider:` into option values (was `m.id` alone, dropping the provider grouped one level up at `optgroup`); the runtime resolves bare ids in the catalog so existing tenant configs continue to route correctly without re-saving. `getModelSlots()` returns fully-qualified ids so cost aggregation, capability checks, max-output and thinking resolvers, and log lines all see consistent shape — a tenant whose disk had bare `gemini-3.1-pro-preview` was previously routing to the Anthropic API ([#143](https://github.com/NimbleBrainInc/nimblebrain/pull/143)).
- PDF resource_link previews render again in Chromium / Arc. Chat iframe no longer sets `sandbox="allow-scripts"`, which made the frame an opaque origin and caused Chromium to refuse navigation to parent-owned `blob:` URLs (Arc surfaced this as "This page has been blocked by Arc"). PDFium's separate sandboxed renderer process is the actual security boundary for `application/pdf`; iframe `sandbox` is for untrusted HTML ([#144](https://github.com/NimbleBrainInc/nimblebrain/pull/144)).
- **A tool's `annotations` and `outputSchema` survive `tools/list`.** The host read a listing's `_meta` and discarded the spec's own two fields, so a bundle declaring `destructiveHint: true` was indistinguishable from a read-only tool everywhere downstream, and `/mcp` clients were told about neither. Both now travel with the tool and go back out on `/mcp` under their own names, alongside `_meta`. **Breaking for embedders:** the `_meta` bag moved off `annotations` onto a new `meta` field on the exported `InProcessTool`, `Tool` and `ToolSchema` types, and `annotations` now holds the spec's `ToolAnnotations`. An in-process tool that set `annotations: { "ai.nimblebrain/internal": true }` renames that field to `meta`; the compiler names every site.
- **The host no longer advertises a `tasks.list` client capability it never used.** Nothing called `listTasks`, and SEP-2663 removes `tasks/list` from the spec, so the claim only invited a server to plan around a client that would never arrive. Task-augmented `tools/call` and `tasks/cancel` are still advertised — those are exercised.

### Removed

- **`providers.anthropic.promptCaching`.** No code ever read it: prompt caching is always on for Anthropic. A config that still sets it boots with an `Unknown key … (ignored)` warning.
- **`workspaceId` in the chat request body and `home.timezone` in runtime config.** Neither had an effect: a chat turn runs in the workspace in its URL, and the config loader never passes `home` through, so the tenant timezone default is `preferences.timezone`. A body that still sends `workspaceId` is accepted and the field ignored.
- **`filePath` on `conversations__list` rows.** It named the server's storage path, which no reader needs; address a conversation by `id`.
- **Readers for historic conversation and usage-ledger shapes:** `skills.loaded` entries without a `name`, `context.assembled` rows counting `turns`, `llm.response` events without `usage` or `finishReason`, `tool.done` events without `output`, conversation headers without a bound `model` (now refused as `conversation_corrupted`), usage-ledger lines carrying `sessionId` / `parentRunId` / `delegated`, and the web parser for `ws_<id>-` tool names. No converter ships: a deployment that holds any of these shapes must convert them before upgrading, or they are not read.
- **Four legacy config aliases.** `COMPOSIO_MONITOR_INTERVAL_SECONDS` is no longer read; set `NB_CONNECTION_REVALIDATE_INTERVAL_SECONDS` ([#727](https://github.com/NimbleBrainInc/nimblebrain/issues/727)). The minted credential and the artifacts reader no longer derive the authorizer token endpoint from `NB_FLEET_AUTHORIZER_ISSUER` or a connection's `issuer`; set `NB_FLEET_AUTHORIZER_TOKEN_URL`. The WorkOS `redirectUri` in `instance.json` is ignored; the sign-in callback is always `<public origin>/v1/auth/callback`. `home.userName` no longer falls back for `preferences.displayName`.
- **Readers for four legacy stored shapes:** a Composio connector ref's `composio` block and its `x-api-key: ${COMPOSIO_API_KEY}` transport auth, plaintext OAuth record files under `credentials/mcp-oauth/`, and message-per-line conversation files (with `JsonlConversationStore`). No release converts them, and no converter ships: a deployment that holds any of these shapes must convert it before upgrading, or the data is not read (a message-format conversation reads as empty). Upgrading a deployment that still holds them across this release is not supported.
- **The boot move of task storage from `automations/` to `tasks/`.** Upgrade through v0.35.x first; a later release does not read `automations/`.
- **Readers and boot guards for stored shapes no hosted tenant holds.** **Upgrading from a release before v0.28.0:** run v0.34.x once first and resolve any boot error it reports, since its guards and reconcile are what bring older data to the current shape. v0.34.x does not rewrite two connector shapes, so they are skipped at boot (with a warning naming the row) and need a re-install: a connector installed before v0.5.0, which has no `serverName`, and one that still carries a `smithery` block. A connector ref must carry `serverName` (no URL-derived fallback; `manage_workspaces` `connectors` rows require it); boot no longer refuses an `oauthScope: "user"` ref, a `workspace.json` without `connectors[]`, or a non-generated workspace id (`list` still skips one); the boot reconcile of `isPersonal` / `ownerUserId`, the flat conversation layout reader, the `smithery` ref block, and `migrate:workspace-connectors` are gone.
- **The manifest `briefing` block and the fast-slot briefing generator.** `_meta["ai.nimblebrain/host"].briefing` is no longer read and is gone from the published host schema; declare counts with the [facets extension](https://docs.nimblebrain.ai/apps/facets/) instead. The `home.cacheTtlMinutes` setting is gone with it.
- **The `ai.nimblebrain/internal` tool `_meta` key is no longer read.** A server that set it to hide a tool from the agent declares `_meta.ui.visibility: ["app"]` instead; until it does, the tool is listed to the agent.
- **`orgName` in `instance.json`.** It was validated and never read; the product name a person sees is `brand.name` in `nimblebrain.json`.
- **App bridge: `synapse/download-file` and the `ui/message` `prompt` action are gone.** Apps hand the user a file with the spec's `ui/download-file`, which `@nimblebrain/synapse` sends from 0.19.0. **Breaking for apps built on an earlier Synapse:** they send `synapse/download-file` as a notification, which carries no id, so the host ignores it and the download does nothing, with no error. A request for the old method is answered method-not-found. Nothing sent the `prompt` action.
- **The Home dashboard app view and two unused core resources.** `ui://home/dashboard` and its sidebar placement are gone; the workspace overview renders the briefing natively. `ui://nb/conversations` and `ui://nb/settings` are gone; they called tools that do not exist.
- **The `home` platform app and its `home__activity` tool.** Nothing called it; the workspace overview reads app counts through facets. **Breaking for `/mcp` clients** that call `home__activity`. The "Daily Briefing" automation template is gone too: an automation is a full agent run whose result only its owner sees.
- **The `nb` source serves no resources.** `ui://nb/model-selector`, the last one, had no placement and no caller; the model settings page sets the model.
- **The stdio client transport mode.** A source is reached over a URL or it is an in-process app — nothing spawns a server (ADR-0020, ADR-0022). No caller had constructed a stdio source since the connector ref became URL-only; the mode arm, its spawn config, and the connector-stderr channel it fed (the `[connector:<name>]` lines and the `stderrTail` field on `source.crashed`) go with it. A catalog entry advertising a stdio `packages[]` transport is unaffected — that is the upstream `ServerDetail` shape, and such an entry was already reported as not installable. `McpSource` is exported from the package entry, so its constructor's mode union narrows to `remote | inProcess`; the union type itself was never exported, so the removed arm was unnameable through that entry.
- **Nested skill discovery under an org / workspace / user `skills/` directory.** A skill tier is one flat directory of `*.md` files; subdirectories are not descended into, so a `_versions/` snapshot never loads as a live skill. The two-level walk served a `skills/bundles/<connector>/` convention nothing wrote and no documented layout named. Connector-published guidance is unaffected — it lives in the workspace's `connector-skills/` store and is read by its own path.
- **`bundleName` on an automation, and the `bundle` automation source.** Both recorded a schedule contributed by an installed server bundle — a path the runtime no longer has, so nothing wrote either and nothing read `bundleName` at all. A record on disk still carrying them keeps loading and running; the fields are ignored.
- **The MCPB acquisition path**, in full: `src/bundles/mpak.ts` (the registry client), `env-filter.ts` (the subprocess env allowlist), `manifest.ts` + the MCPB and host-manifest JSON Schemas, `resolve.ts`, `src/registries/mpak-source.ts`, `src/tools/app-tools.ts`, `src/config/workspace-credentials.ts` (the per-bundle `user_config` store), and `src/host-resources/manifest-gate.ts`. On the lifecycle: `installNamed` / `installLocal` / `installRemote`, `upgradeApp`, the cached-version respawn, `fetchTrustScore`, and the bundle-declared automation sync. Every reader of the private `_meta["ai.nimblebrain/upjack"]` namespace goes with them, including the `entity` briefing facet — a facet now names a `tool` or a `resource` on the server that declared it and is answered over MCP, rather than the host reading that server's files off a shared disk. `host_capabilities` is no longer read: it gated an install that no longer happens.
- **`RegistryType` is an open string** keyed into a source-factory map, rather than a closed enum. `static` is the type this build ships a source for; a config naming any other type loads, warns, and surfaces no entries. **An existing `registries.json` keeps its persisted `mpak` row** — it is inert, but it warns once per Browse or agent registry search. Delete the row (or disable it in Organization → Registries) to quiet it. A `packages[]`-only catalog entry is not installable by this runtime and is dropped from Browse with a logged reason — publish the server behind a URL and give the entry a `remotes[]` endpoint.
- **`nb__delegate`, agent profiles, and the `delegation` feature flag.** The tool constructed an `AgentEngine` of its own — the only engine construction outside the runtime's composition root — and started a run from inside a turn, so the membership and budget checks the two real run-start paths perform did not apply to it. A sub-agent is a capability, and a capability enters through MCP: a remote server that runs an agent is an ordinary tool call. Removed with it: the `agents` map in `workspace.json` (and the stripped top-level `agents` in `nimblebrain.json`), the `features.delegation` flag and its schema entry, the `delegated` label on `nb_llm_calls_total` / `nb_llm_tokens_total`, and the `delegated` / `parentRunId` fields on new usage-ledger records. **Breaking for `/mcp` clients** that call `nb__delegate`, and for any workspace configuring `agents` (the key is now ignored). Existing ledger records still parse and still group correctly under `usage__report --group-by turn`, which keeps reading a retained record's `parentRunId`. Dashboards that group by `delegated` should drop the label.
- **`skills__active_for`.** It reported which Layer 3 skills were loaded for a conversation, but the model — its only possible caller — received one line (`"3 skills loaded · 4200 tokens"`) about skills whose full text was already in its context; the per-skill detail went to `structuredContent`, which never reaches the in-process agent loop. Nothing else called it: the Context Ledger reads `skillsLoaded` off the chat stream, and there is no REST route. `skills__loading_log` answers the same question over more ground — every channel a skill can reach the model through, not just the system prompt, across conversations rather than one, with a channel breakdown and a timestamp per load. Its rows carry no `reason` or `layer`. **Breaking for `/mcp` clients** that call `skills__active_for`; use `skills__loading_log` with `conversation_id`.
- **Removed the one-time workspace-as-wall migration scripts** (`migrate:{conversations,files,automations}-to-workspace`). The migration completed across all environments in v0.10.0; the scripts remain in the v0.10.0 release artifact (tag + image) for any tenant not yet migrated.
- Standalone stdio automations MCP server (`src/platform/automations/server.ts` `main()` + the HTTP executor `executeHttp`). Superseded by the in-process `automations` platform source; importing the module had been starting a phantom second scheduler and grabbing stdio. The file is now tool handlers only.
- `InlineSource`, `ResourceReader`, `isResourceReader`. External callers should switch to `defineInProcessApp` (returns an `McpSource`); `InlineToolDef` becomes `InProcessTool` with the same shape.
- `bridgeUseMcp` feature flag and its scaffolding (`web/src/features.ts`, `getBridgeUseMcp` / `setBridgeUseMcp`, the schema entry, the resolver field). The MCP transport is the only path; legacy REST branches in the bridge are deleted.
- The bridge's `synapse/persist-state` / `synapse/state-loaded` extension. No SDK sends `persist-state`, so the host never stored or replayed state; a `persist-state` request now gets `-32601`.
- Skill create/edit form no longer surfaces "Loading strategy" and "Tool affinity (comma-separated globs)" inputs. These fields exist on the on-disk `SkillManifest` but were never in the LLM-facing tool schema and were silently dropped by `skills__create` / `skills__update`. Operators who need them today should edit the skill markdown file directly under `~/.nimblebrain/{skills,workspaces/<wsId>/skills,users/<userId>/skills}/`; a future operator-only API may surface them.
- Registry URL editing on the Org → Registries settings page (and the `manage_registries set_url` action). URL overrides (e.g. self-hosted mpak) are deployment config — set via `NB_REGISTRIES` or `registries.json`. The UI now shows the configured URL or "(using default)" as read-only.
- `WorkspaceSelector` (header switcher) — replaced by the sidebar navigator (Q1).
- `UserConnectorStore` class + `src/users/` directory (T008). Credentials live in the workspace.
- `UserPoolSource` (T008). Cross-workspace dispatch routes through the orchestrator.
- Boot-time `user.json` migration in `runtime.ts` (T008). Upgrade floor: v0.10.0.
- **The interactive terminal client (Ink/React TUI), the operator CLI subcommands, and the `nb` binary** (`nb bundle` / `skill` / `config` / `credential` / `status` / `reload` / `telemetry` / `automation` / `user`). The TUI and subcommands are gone; their capabilities are managed from the web UI and the agent's tools. The runtime is launched via bun -- `bun run start` (server) and `bun run dev` (local watch/HMR) -- and the `nb` bin entry is dropped. Drops the `commander`, `ink`, `react`, and `react-dom` dependencies. The server entry path `bun run src/cli/index.ts serve` is unchanged, so images and charts need no edit.

### Operator notes

- All pre-IA settings URLs redirect to their new locations (`/settings/profile` → `/profile`, `/settings/users` → `/settings/org/users`, etc.). No action required for end users.
- Bundle authors who want to support custom instructions: publish `app://instructions` from your MCP server. See [the bundle-author guide](https://docs.nimblebrain.ai/apps/custom-instructions/) and the `synapse-todo-board` reference implementation.
- The connection-revalidator sweep interval is now `NB_CONNECTION_REVALIDATE_INTERVAL_SECONDS` (the cadence is provider-agnostic). The legacy `COMPOSIO_MONITOR_INTERVAL_SECONDS` is still honored (slated for removal, #727), so no action is required.

### Changed

- **Host-owned `_meta` keys.** `ai.nimblebrain/non-advancing` is stripped from connector results, like the skill markers; the platform's own tools still set it. Outbound `tools/call` no longer carries `ai.nimblebrain/unattended`: a server is not told that a call was fired by configuration with no one watching. The reason stays in the audit line.

## [0.4.0] - 2026-04-24

### Highlights

- **MCP OAuth for external clients** — Claude Code, Claude Desktop, Cursor, and any RFC 9728/8414-compliant client can connect to `/mcp` via WorkOS AuthKit. Works behind TLS-terminating proxies ([docs](https://docs.nimblebrain.ai/guide/mcp-connect/)).
- **Workspace-scoped credentials** — per-bundle files (`0o600`), 3-tier resolver (workspace store → `mcp_config.env` alias → manifest default).
- **OAuth client for remote MCP sources** — NimbleBrain can now consume third-party MCP servers that require user identity.
- **Tool-call UX** — parallel calls collapse into a single accordion, engine errors render inline, `resource_link` blocks render PDFs and binaries, 20s SSE heartbeat keeps long streams alive.

### Breaking

- `files__write` → `files__create`. Hand-coded external callers must update.
- `GET /v1/apps/:name/resources/:path` now returns a JSON envelope matching `POST /v1/resources/read`. Binary payloads come back as base64 in `blob`.
- `nb config set|get|clear` require `--workspace`/`-w <wsId>`.
- Engine-level MCP task exports removed (`ActiveTaskTracker`, `pollTask`, `isCreateTaskResult`, `McpTask`, et al). Drive tasks via `ToolRouter.execute(call, signal)` + `tool.progress` events.

### Added

- `POST /v1/resources/read`.
- Docker images on GHCR (`ghcr.io/nimblebraininc/nimblebrain{,-web}`) alongside ECR.
- Claude Opus 4.7 in the model catalog.

### Fixed

- **Chat uploads are now visible to `files__*` tools.** Operator action: run `bun run scripts/migrate-tenant-files.ts [workDir]` to migrate pre-existing uploads.
- Context-doc uploads >1 MB no longer 413.
- MCP OAuth resource URL honors `X-Forwarded-Proto` behind ALB/nginx/Caddy ([docs](https://docs.nimblebrain.ai/deploy/security/#mcp-oauth-behind-a-reverse-proxy)).
- Concurrent chat runs on the same conversation return `409` instead of racing.
- Workspace bundles start concurrently at boot (faster startup on busy instances).
- `InlineSource.execute()` validates input against `inputSchema` — malformed `/mcp` calls no longer leak Node internals.
- Many smaller UI and streaming-reliability fixes.

### Removed

- `NB_CONFIG_*` env-naming convention — replaced by SDK-declared env aliases.
- Legacy standalone files MCP server (superseded by the inline source).

## [0.3.0] - 2026-04-16

### Security

- **Scope bundle instances and placements by workspace.** When two workspaces had the same bundle installed, `Runtime.getBundleInstancesForWorkspace` returned instances from both (filtering only by `serverName`), causing briefing facets and the apps list to read entity data from other workspaces. `PlacementRegistry.unregister` was also global-per-serverName, so re-seeding in a second workspace silently wiped the first workspace's nav entries. Both paths are now workspace-scoped.

### Breaking (internal API)

Downstream forks or consumers that extend `BundleLifecycleManager` will need to update callsites:

- `installNamed(name, registry, env?)` → `installNamed(name, registry, wsId, env?)`
- `installLocal(bundlePath, registry, env?)` → `installLocal(bundlePath, registry, wsId, env?)`
- `installRemote(url, serverName, registry, transportConfig?, ui?, trustScore?)` → `installRemote(url, serverName, registry, wsId, transportConfig?, ui?, trustScore?)`
- `uninstall(nameOrPath, registry)` → `uninstall(nameOrPath, registry, wsId)`
- `startBundle(serverName, registry)` → `startBundle(serverName, wsId, registry)`
- `stopBundle(serverName, registry)` → `stopBundle(serverName, wsId, registry)`
- `recordCrash(serverName)` / `recordRecovery(serverName)` / `recordDead(serverName)` each gain a required `wsId` second argument.
- `BundleInstance.wsId` is now required (was optional). Every instance belongs to exactly one workspace; global/platform sources are represented as `InlineSource`, not `BundleInstance`.
- `PlacementRegistry.unregister(serverName, wsId?)` now scopes to `(serverName, wsId)` only. Passing no `wsId` removes only global entries; passing a specific `wsId` leaves other workspaces untouched.

### Migration

`installNamed` previously wrote bundle subprocess data to `{workDir}/data/{bundle}`. It now writes to `{workDir}/workspaces/{wsId}/data/{bundle}`, matching `seedInstance`. Self-hosted deployments that installed bundles via the runtime install path (rather than the startup seed path) should move existing data directories to the workspace-scoped layout or accept that old data is orphaned.

### Tooling

- `bun run verify` is now true CI parity. Added `format:check` (previously missed by CI) and split into `verify:static` + `verify:test-unit`; `ci.yml` invokes those subscripts so `package.json` is the single source of truth for "what CI runs."

### Other

- Auto-build bundle UIs during Docker image build and local `dev`.
- Update GitHub Actions workflows to latest major versions.

## [0.2.0] - 2026-04-15

- Add `dev:docs-demo` script for running the docs-demo environment with a preset dev identity.

## [0.1.0] - 2026-04-07

Initial public release.