From 34f4c9006a04b0a8be5165d209df455017a2f1fc Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:49:12 -1000 Subject: [PATCH 1/2] Drop sendMessage chat context and document the two host actions sendMessage sends the spec's text block alone. What the user is acting on reaches the agent through updateModelContext, which the NimbleBrain host attaches to the next turn. The NimbleBrain host serves openApp and openConversation; startChat is sendMessage and navigate is openApp or openLink. --- CHANGELOG.md | 10 +++++ CLAUDE.md | 3 +- README.md | 8 ++-- conformance/pages/app-connect.ts | 2 +- src/__tests__/connect-capabilities.test.ts | 40 +++---------------- src/__tests__/connect-integration.test.ts | 24 +---------- src/__tests__/connect.test.ts | 23 +---------- src/__tests__/react/hooks.test.tsx | 9 +++-- src/__tests__/spec-compliance.test.ts | 23 ++--------- src/connect.ts | 13 +----- src/event-map.ts | 2 +- src/extensions.ts | 6 ++- src/react/hooks.ts | 11 +---- src/types.ts | 8 ++-- web/src/content/docs/docs/api/connect.mdx | 6 +-- web/src/content/docs/docs/api/hooks.mdx | 4 +- .../docs/docs/concepts/degradation.mdx | 7 ++-- 17 files changed, 55 insertions(+), 144 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ef097..983c699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Breaking + +- **`sendMessage` takes the text alone, and sends no `_meta["ai.nimblebrain/context"]`.** `sendMessage(text, context)` and `useSendMessage()`'s second argument are gone. What the user is acting on reaches the agent through the spec's `updateModelContext`, which the NimbleBrain host attaches to the next turn; the host no longer reads the chat context, so it had stopped reaching the agent. + + **Migration:** replace `sendMessage(text, { action, entity })` with `updateModelContext({ action, entity })` followed by `sendMessage(text)`, or put what the agent needs in the text. + +- **The NimbleBrain host serves two actions: `openApp` (`{ name }`) and `openConversation` (`{ id }`).** It ignores `navigate` and `startChat`, and `action` still sends any name. + + **Migration:** `action(app, "startChat", { prompt })` becomes `sendMessage(prompt)`. `action(app, "navigate", { route })` becomes `action(app, "openApp", { name })` for an app, or `openLink(url)` for an external page. + ## [0.21.1] - 2026-09-18 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 0d9bb54..ee192aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,7 +263,8 @@ the one slot a spec client's handshake parse keeps): - `ai.nimblebrain/request-file` - `ai.nimblebrain/keydown` -The chat context on `sendMessage` rides `_meta["ai.nimblebrain/context"]`. +`sendMessage` sends the spec's text block and nothing else; what the user is +acting on reaches the agent through `updateModelContext`. An `ai.nimblebrain/` method constant without an entry fails `event-map.test.ts`. diff --git a/README.md b/README.md index 9146eb1..bbe3aa9 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ const app = await connect({ name: "my-app", version: "1.0.0" }); | `updateModelContext(state, summary?)` | Push LLM-visible state | | `callTool(name, args?)` | Call a tool on this app's own MCP server and get a typed result | | `readServerResource({ uri })` | Read an MCP resource from the originating server | -| `sendMessage(text, context?)` | Send a message into the agent conversation | +| `sendMessage(text)` | Send a message into the agent conversation | | `destroy()` | Clean up all listeners, observers, and timers | ### Helpers over an `App` @@ -346,13 +346,13 @@ never carries the picker. import { connect, action, pickFile, downloadFile, callToolAsTask } from "@nimblebrain/synapse"; const app = await connect({ name: "my-app", version: "1.0.0" }); -action(app, "navigate", { entity: "board", id: "b1" }); +action(app, "openConversation", { id: "conv_abc123" }); ``` | Function | Description | |----------|-------------| | `callToolAsTask(app, name, args?, opts?)` | Call a long-running tool task-augmented; returns a `Promise`. See [Long-running tools](#long-running-tools-tasks). | -| `action(app, name, params?)` | Trigger a NimbleBrain host action. No-op unless the host declares `ai.nimblebrain/action`. | +| `action(app, name, params?)` | Trigger a NimbleBrain host action: `openApp` (`{ name }`) or `openConversation` (`{ id }`). No-op unless the host declares `ai.nimblebrain/action`. | | `pickFile(app, options?)` | Native file picker, single file. Rejects with `HostCapabilityError` unless the host declares `ai.nimblebrain/request-file`. | | `pickFiles(app, options?)` | Native file picker, multiple files. Rejects with `HostCapabilityError` unless the host declares `ai.nimblebrain/request-file`. | | `hostSupports(app, extension)` | Whether the host declared a NimbleBrain extension (`"action"`, `"requestFile"`, `"keydown"`). | @@ -391,7 +391,7 @@ import { AppProvider, useApp, useCallTool, useTheme } from "@nimblebrain/synapse | `useDataSync(cb)` | — | Run `cb` when your server announces its data changed (`notifications/resources/list_changed`); `cb` gets the notification's params | | `useModelContext()` | `(state, summary?) => void` | Push LLM-visible state, debounced 250ms | | `useModelContext(factory, deps)` | — | The same, pushed whenever `deps` change | -| `useSendMessage()` | `(text, context?) => void` | Send a message into the agent conversation | +| `useSendMessage()` | `(text) => void` | Send a message into the agent conversation | | `useAction()` | `(name, params?) => void` | Trigger a NimbleBrain host action | | `useFileUpload()` | `{ pickFile, pickFiles, isPending }` | The host's native file picker (NB-only) | diff --git a/conformance/pages/app-connect.ts b/conformance/pages/app-connect.ts index 0caefa3..0230711 100644 --- a/conformance/pages/app-connect.ts +++ b/conformance/pages/app-connect.ts @@ -67,7 +67,7 @@ if (app) { await step("callTool", () => connected.callTool("echo", { a: 1 })); await step("readServerResource", () => connected.readServerResource({ uri: "x://a" })); - await step("sendMessage", () => connected.sendMessage("hi", { action: "a" })); + await step("sendMessage", () => connected.sendMessage("hi")); await step("openLink", () => connected.openLink("https://example.com")); await step("updateModelContext", () => connected.updateModelContext({ a: 1 })); await step("downloadFile", () => downloadFile(connected, "a.txt", "abc", "text/plain")); diff --git a/src/__tests__/connect-capabilities.test.ts b/src/__tests__/connect-capabilities.test.ts index fe40b36..de8767f 100644 --- a/src/__tests__/connect-capabilities.test.ts +++ b/src/__tests__/connect-capabilities.test.ts @@ -332,60 +332,30 @@ describe("connect() capabilities", () => { describe("action", () => { it("sends ai.nimblebrain/action on a NimbleBrain host", async () => { app = await connectAndHandshake(); - action(app, "navigate", { entity: "board", id: "b1" }); + action(app, "openConversation", { id: "c1" }); const sent = sentNotifications("ai.nimblebrain/action"); expect(sent).toHaveLength(1); - expect(sent[0].params).toEqual({ action: "navigate", entity: "board", id: "b1" }); + expect(sent[0].params).toEqual({ action: "openConversation", id: "c1" }); }); it("is a no-op where the host did not declare it, whatever the host is called", async () => { app = await connectAndHandshake({}, makeInitResult("nimblebrain", { hostCapabilities: {} })); - action(app, "navigate", { id: "b1" }); + action(app, "openConversation", { id: "c1" }); await flush(); expect(sentNotifications("ai.nimblebrain/action")).toHaveLength(0); }); it("sends on any host that declares it", async () => { app = await connectAndHandshake({}, makeInitResult("another-host")); - action(app, "navigate", { id: "b1" }); + action(app, "openConversation", { id: "c1" }); await flush(); expect(sentNotifications("ai.nimblebrain/action")).toHaveLength(1); }); }); describe("sendMessage", () => { - it('attaches the chat context under _meta["ai.nimblebrain/context"] on a NimbleBrain host', async () => { - app = await connectAndHandshake(); - app.sendMessage("hello", { action: "open", entity: "board" }); - await flush(); - - const sent = sentByMethod("ui/message"); - expect(sent[0].params).toEqual({ - role: "user", - content: [ - { - type: "text", - text: "hello", - _meta: { "ai.nimblebrain/context": { action: "open", entity: "board" } }, - }, - ], - }); - }); - - it("omits _meta off a NimbleBrain host — the field is a NimbleBrain convention", async () => { - app = await connectAndHandshake({}, makeInitResult("claude")); - app.sendMessage("hello", { action: "open" }); - await flush(); - - const sent = sentByMethod("ui/message"); - expect(sent[0].params).toEqual({ - role: "user", - content: [{ type: "text", text: "hello" }], - }); - }); - - it("omits _meta when no context is given", async () => { + it("sends the text alone on a NimbleBrain host", async () => { app = await connectAndHandshake(); app.sendMessage("hello"); await flush(); diff --git a/src/__tests__/connect-integration.test.ts b/src/__tests__/connect-integration.test.ts index 3baceef..a625805 100644 --- a/src/__tests__/connect-integration.test.ts +++ b/src/__tests__/connect-integration.test.ts @@ -263,32 +263,10 @@ describe("connect() integration", () => { // 4. sendMessage format // ----------------------------------------------------------------------- describe("sendMessage format", () => { - it("sends ui/message with context as _meta", async () => { - // `context` is a NimbleBrain convention, so it rides only on that host. + it("sends ui/message with the text alone", async () => { app = await connectApp({}, nimblebrainInitResult()); host.clearSpy(); - app.sendMessage("Summarize the board", { action: "summarize", entity: "board" }); - - expect(host.sentMessages[0]).toMatchObject({ - method: "ui/message", - params: { - role: "user", - content: [ - { - type: "text", - text: "Summarize the board", - _meta: { "ai.nimblebrain/context": { action: "summarize", entity: "board" } }, - }, - ], - }, - }); - }); - - it("sends ui/message without _meta when no context", async () => { - app = await connectApp(); - host.clearSpy(); - app.sendMessage("Just chatting"); const msg = host.sentMessages[0]; diff --git a/src/__tests__/connect.test.ts b/src/__tests__/connect.test.ts index 298601e..ea3b89d 100644 --- a/src/__tests__/connect.test.ts +++ b/src/__tests__/connect.test.ts @@ -471,28 +471,7 @@ describe("connect()", () => { ]); }); - // The chat context is a NimbleBrain host field, so it rides only on a - // NimbleBrain host — and this harness's host is `test-host`. Both branches - // are covered in connect-capabilities.test.ts. - it("sendMessage() omits context off a NimbleBrain host", async () => { - app = await connectAndHandshake(); - postMessageSpy.mockClear(); - - app.sendMessage("Hello", { action: "summarize" }); - - expect(postMessageSpy).toHaveBeenCalledWith( - expect.objectContaining({ - method: "ui/message", - params: { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - }), - "*", - ); - }); - - it("sendMessage() sends without _meta when no context", async () => { + it("sendMessage() sends the text as a ui/message", async () => { app = await connectAndHandshake(); postMessageSpy.mockClear(); diff --git a/src/__tests__/react/hooks.test.tsx b/src/__tests__/react/hooks.test.tsx index af972a7..ddb761c 100644 --- a/src/__tests__/react/hooks.test.tsx +++ b/src/__tests__/react/hooks.test.tsx @@ -265,10 +265,13 @@ describe("useAction", () => { await settle(); await act(async () => { - result.current("navigate", { id: "b1" }); + result.current("openConversation", { id: "c1" }); }); - expect(sent("ai.nimblebrain/action")[0].params).toEqual({ action: "navigate", id: "b1" }); + expect(sent("ai.nimblebrain/action")[0].params).toEqual({ + action: "openConversation", + id: "c1", + }); }); it("is a no-op where the host did not declare it", async () => { @@ -276,7 +279,7 @@ describe("useAction", () => { await settle("nimblebrain", {}); await act(async () => { - result.current("navigate", { id: "b1" }); + result.current("openConversation", { id: "c1" }); }); expect(sent("ai.nimblebrain/action")).toHaveLength(0); diff --git a/src/__tests__/spec-compliance.test.ts b/src/__tests__/spec-compliance.test.ts index dbc11be..60ae6ce 100644 --- a/src/__tests__/spec-compliance.test.ts +++ b/src/__tests__/spec-compliance.test.ts @@ -55,7 +55,6 @@ import type { ReadResourceRequest, ReadResourceResult, TaskStatus, - TextContent, } from "@modelcontextprotocol/sdk/types.js"; import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/sdk/types.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -451,28 +450,14 @@ describe("outbound message shapes", () => { expect(params.content[0]).toMatchObject({ type: "text", text: "hello world" }); }); - // `context` is a NimbleBrain convention carried in the spec's open `_meta`, - // so it is encoded on a NimbleBrain host and absent everywhere else. Both - // halves are asserted: the placement is what a host reads, and the absence - // is what keeps a private field off a foreign host's wire. - it("sendMessage with context puts it in _meta on a NimbleBrain host", async () => { + // The text block is the spec's `TextContent` and nothing more, on every host: + // what the user is acting on reaches the agent through `ui/update-model-context`. + it("sendMessage sends no _meta, on a NimbleBrain host too", async () => { app = await connectAndHandshake( {}, makeSpecInitResult({ hostInfo: { name: "nimblebrain", version: "1.0.0" } }), ); - app.sendMessage("test", { action: "search" }); - - const call = postMessageSpy.mock.calls.find( - (c: unknown[]) => (c[0] as Record).method === MESSAGE_METHOD, - ); - const params = (call![0] as Record).params as McpUiMessageRequest["params"]; - const block = params.content[0] as TextContent; - expect(block._meta).toEqual({ "ai.nimblebrain/context": { action: "search" } }); - }); - - it("sendMessage omits _meta entirely on a non-NimbleBrain host", async () => { - app = await connectAndHandshake(); - app.sendMessage("test", { action: "search" }); + app.sendMessage("test"); const call = postMessageSpy.mock.calls.find( (c: unknown[]) => (c[0] as Record).method === MESSAGE_METHOD, diff --git a/src/connect.ts b/src/connect.ts index d71381c..62625a5 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -53,9 +53,6 @@ import type { */ const NIMBLEBRAIN_HOST = "nimblebrain"; -/** The `_meta` key a `ui/message` text block carries the NimbleBrain chat context under. */ -const CHAT_CONTEXT_META_KEY = "ai.nimblebrain/context"; - /** * Requests this SDK sends carry no deadline. * @@ -473,15 +470,9 @@ export async function connect(options: ConnectOptions): Promise { return await client.readServerResource(params, NO_DEADLINE); }, - sendMessage(text: string, context?: { action?: string; entity?: string }): void { + sendMessage(text: string): void { if (destroyed || !hostCapabilities.message) return; - const textBlock: TextContent = { - type: "text", - text, - // The chat context is a NimbleBrain host field; other hosts ignore it, - // but there is no reason to spend the bytes off one. - ...(isNimbleBrainHost && context && { _meta: { [CHAT_CONTEXT_META_KEY]: context } }), - }; + const textBlock: TextContent = { type: "text", text }; const params: McpUiMessageRequest["params"] = { role: "user", content: [textBlock], diff --git a/src/event-map.ts b/src/event-map.ts index 77fe9e3..b9401c8 100644 --- a/src/event-map.ts +++ b/src/event-map.ts @@ -62,7 +62,7 @@ export const KEYDOWN_METHOD = "ai.nimblebrain/keydown"; * extension says so; a host that does not is never sent it. */ export const NIMBLEBRAIN_EXTENSIONS = { - /** App → host notification: run a host action (navigate, open a panel). */ + /** App → host notification: run a host action (open an app or a conversation). */ action: { method: ACTION_METHOD, capability: ACTION_METHOD }, /** App → host request: the host's file picker, answered `{ files }`. */ requestFile: { method: REQUEST_FILE_METHOD, capability: REQUEST_FILE_METHOD }, diff --git a/src/extensions.ts b/src/extensions.ts index c335e2d..d58f57a 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -35,8 +35,10 @@ const DEFAULT_MAX_FILE_SIZE = 26_214_400; /** * Trigger a host-side action. * - * Sends a command *to* the host (navigate, open a panel). A no-op when the host - * did not declare `ai.nimblebrain/action`. + * Sends a command *to* the host. The NimbleBrain host serves `openApp` + * (`{ name }`) and `openConversation` (`{ id }`) and ignores any other name. A + * message into the conversation is `sendMessage`, and an external page is + * `openLink`. A no-op when the host did not declare `ai.nimblebrain/action`. */ export function action(app: App, name: string, params?: Record): void { if (!hostSupports(app, "action")) return; diff --git a/src/react/hooks.ts b/src/react/hooks.ts index 09f5bf5..86ca325 100644 --- a/src/react/hooks.ts +++ b/src/react/hooks.ts @@ -263,16 +263,9 @@ export function useModelContext( * not declare `message`; check `app.hostCapabilities.message` to decide whether * to offer the control at all. */ -export function useSendMessage(): ( - text: string, - context?: { action?: string; entity?: string }, -) => void { +export function useSendMessage(): (text: string) => void { const app = useAppContext(); - return useCallback( - (text: string, context?: { action?: string; entity?: string }) => - app.sendMessage(text, context), - [app], - ); + return useCallback((text: string) => app.sendMessage(text), [app]); } // ----------------------------------------------------------------------------- diff --git a/src/types.ts b/src/types.ts index d1f3ee8..343a797 100644 --- a/src/types.ts +++ b/src/types.ts @@ -335,8 +335,7 @@ export interface App { readonly hostCapabilities: McpUiHostCapabilities; /** * True when the host identified itself as NimbleBrain in the handshake. - * Identity, not capability: nothing is gated on it except the NimbleBrain - * chat context on `sendMessage` (`_meta["ai.nimblebrain/context"]`), which other hosts ignore. + * Identity, not capability: nothing is gated on it. */ readonly isNimbleBrainHost: boolean; /** True after `destroy()` has been called. */ @@ -404,8 +403,9 @@ export interface App { readServerResource(params: ReadResourceRequest["params"]): Promise; /** * Send a user message into the agent conversation (ext-apps `ui/message`). - * A no-op when the host did not declare `message`. + * A no-op when the host did not declare `message`. To tell the agent what + * the user is acting on, call `updateModelContext` first. */ - sendMessage(text: string, context?: { action?: string; entity?: string }): void; + sendMessage(text: string): void; destroy(): void; } diff --git a/web/src/content/docs/docs/api/connect.mdx b/web/src/content/docs/docs/api/connect.mdx index 08bc06e..71b525f 100644 --- a/web/src/content/docs/docs/api/connect.mdx +++ b/web/src/content/docs/docs/api/connect.mdx @@ -41,7 +41,7 @@ Available synchronously once the promise resolves. | `containerDimensions` | `Dimensions \| null` | Container size constraints from host | | `hostContext` | `McpUiHostContext` | The full host context — spec fields plus whatever the host publishes alongside | | `hostCapabilities` | `McpUiHostCapabilities` | What the host declared in `ui/initialize`. Every method and helper checks it before sending; see the [portable-app contract](/docs/concepts/degradation/). | -| `isNimbleBrainHost` | `boolean` | Whether the host identified itself as NimbleBrain. Nothing is gated on it except `sendMessage`'s chat context, sent under `_meta["ai.nimblebrain/context"]`. | +| `isNimbleBrainHost` | `boolean` | Whether the host identified itself as NimbleBrain. Identity only: nothing is gated on it. | | `destroyed` | `boolean` | True after `destroy()` | | `supportsTasks` | `boolean` | Whether the host negotiated the MCP tasks utility for `tools/call`. `callToolAsTask` throws when this is false. | @@ -55,7 +55,7 @@ Available synchronously once the promise resolves. | `updateModelContext(state, summary?)` | Push LLM-visible state. No-op without `updateModelContext`. | | `callTool(name, args?)` | Call a tool on this app's own MCP server and get a typed result. An app reaches its own server and nothing else — cross-source work belongs to the agent. Rejects with `HostCapabilityError` without `serverTools`. | | `readServerResource({ uri })` | Read an MCP resource from the originating server. Rejects with `HostCapabilityError` without `serverResources`. | -| `sendMessage(text, context?)` | Send a message into the agent conversation. No-op without `message`. | +| `sendMessage(text)` | Send a message into the agent conversation. No-op without `message`. To tell the agent what the user is acting on, call `updateModelContext` first. | | `destroy()` | Clean up all listeners, observers, and timers | That is the whole object. It is deliberately this small: NimbleBrain's own @@ -67,7 +67,7 @@ picker. | --- | --- | | `callToolAsTask(app, name, args?, options?)` | Task-augmented tool call. See [long-running tools](/docs/guides/long-running-tools/). Rejects with `HostCapabilityError` when `supportsTasks` is false. | | `hostSupports(app, extension)` | Whether the host declared a NimbleBrain extension: `"action"`, `"requestFile"` or `"keydown"`. | -| `action(app, name, params?)` | Trigger a host-side action. No-op unless the host declares `ai.nimblebrain/action`. | +| `action(app, name, params?)` | Trigger a host-side action: on NimbleBrain, `openApp` (`{ name }`) or `openConversation` (`{ id }`). No-op unless the host declares `ai.nimblebrain/action`. | | `pickFile(app, options?)` / `pickFiles(app, options?)` | The host's native file picker. Rejects with `HostCapabilityError` unless the host declares `ai.nimblebrain/request-file`. | | `downloadFile(app, filename, content, mimeType?)` | Hand the user a file to save, over the spec's `ui/download-file`. A string travels as the resource's `text`, a `Blob` as base64 `blob`. Resolves with the host's result — `{ isError: true }` when the host declined or the user cancelled — and rejects with `HostCapabilityError`, without sending, when the host did not declare `downloadFile`. | diff --git a/web/src/content/docs/docs/api/hooks.mdx b/web/src/content/docs/docs/api/hooks.mdx index c39cd41..b13fde1 100644 --- a/web/src/content/docs/docs/api/hooks.mdx +++ b/web/src/content/docs/docs/api/hooks.mdx @@ -52,7 +52,7 @@ reasoning. | --- | --- | --- | --- | | `useModelContext()` | `(state, summary?) => void` | Push what the user is looking at to the agent, debounced 250 ms | `updateModelContext`: no-op | | `useModelContext(factory, deps)` | none | The same, pushed whenever `deps` change | `updateModelContext`: no-op | -| `useSendMessage()` | `(text, context?) => void` | Send a message into the agent conversation | `message`: no-op | +| `useSendMessage()` | `(text) => void` | Send a message into the agent conversation | `message`: no-op | The debounce lives in the hook rather than on `app.updateModelContext`, which sends immediately: a selection the user drags through should cost one frame, not @@ -66,7 +66,7 @@ it did. | Hook | Returns | Description | Without it | | --- | --- | --- | --- | -| `useAction()` | `(name, params?) => void` | Trigger a host-side action | `ai.nimblebrain/action`: no-op | +| `useAction()` | `(name, params?) => void` | Trigger a host-side action: on NimbleBrain, `openApp` or `openConversation` | `ai.nimblebrain/action`: no-op | | `useFileUpload()` | `{ pickFile, pickFiles, isPending }` | The host's native file picker | `ai.nimblebrain/request-file`: both reject with `HostCapabilityError` | ## See also diff --git a/web/src/content/docs/docs/concepts/degradation.mdx b/web/src/content/docs/docs/concepts/degradation.mdx index 2b03794..f04136a 100644 --- a/web/src/content/docs/docs/concepts/degradation.mdx +++ b/web/src/content/docs/docs/concepts/degradation.mdx @@ -76,10 +76,9 @@ as its method and as the identifier a host declares in That is the complete list. `NIMBLEBRAIN_EXTENSIONS`, exported from the package root, holds the same table in code. -Two NimbleBrain fields also ride inside spec messages, and other hosts ignore them: -the workspace in the host context, and the chat context on a `sendMessage` text -block, under `_meta["ai.nimblebrain/context"]`. Synapse sends the chat context only -when the host identifies itself as NimbleBrain (`app.isNimbleBrainHost`). +One NimbleBrain field also rides inside a spec message, and other hosts ignore it: +the workspace in the host context. What the user is acting on reaches the agent +through the spec's `updateModelContext`, on every host that declares it. ## Deciding what to offer From aac098791d0d23f31de34a7e67b648ec1ec0362e Mon Sep 17 00:00:00 2001 From: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:32:26 -1000 Subject: [PATCH 2/2] CHANGELOG: describe the host change without claiming it has shipped --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983c699..9599569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Breaking -- **`sendMessage` takes the text alone, and sends no `_meta["ai.nimblebrain/context"]`.** `sendMessage(text, context)` and `useSendMessage()`'s second argument are gone. What the user is acting on reaches the agent through the spec's `updateModelContext`, which the NimbleBrain host attaches to the next turn; the host no longer reads the chat context, so it had stopped reaching the agent. +- **`sendMessage` takes the text alone, and sends no `_meta["ai.nimblebrain/context"]`.** `sendMessage(text, context)` and `useSendMessage()`'s second argument are gone. What the user is acting on reaches the agent through the spec's `updateModelContext`, which the NimbleBrain host attaches to the next turn. **Migration:** replace `sendMessage(text, { action, entity })` with `updateModelContext({ action, entity })` followed by `sendMessage(text)`, or put what the agent needs in the text. -- **The NimbleBrain host serves two actions: `openApp` (`{ name }`) and `openConversation` (`{ id }`).** It ignores `navigate` and `startChat`, and `action` still sends any name. +- **The NimbleBrain host serves two actions: `openApp` (`{ name }`) and `openConversation` (`{ id }`).** `navigate` and `startChat` are retired from the host, and `action` still sends any name. **Migration:** `action(app, "startChat", { prompt })` becomes `sendMessage(prompt)`. `action(app, "navigate", { route })` becomes `action(app, "openApp", { name })` for an app, or `openLink(url)` for an external page.