diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ef097..9599569 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. + + **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 }`).** `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. + ## [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