Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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<TaskHandle>`. 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"`). |
Expand Down Expand Up @@ -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) |

Expand Down
2 changes: 1 addition & 1 deletion conformance/pages/app-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
40 changes: 5 additions & 35 deletions src/__tests__/connect-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 1 addition & 23 deletions src/__tests__/connect-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
23 changes: 1 addition & 22 deletions src/__tests__/connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
9 changes: 6 additions & 3 deletions src/__tests__/react/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,18 +265,21 @@ 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 () => {
const { result } = renderHook(() => useAction(), { wrapper: createWrapper() });
await settle("nimblebrain", {});

await act(async () => {
result.current("navigate", { id: "b1" });
result.current("openConversation", { id: "c1" });
});

expect(sent("ai.nimblebrain/action")).toHaveLength(0);
Expand Down
23 changes: 4 additions & 19 deletions src/__tests__/spec-compliance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>).method === MESSAGE_METHOD,
);
const params = (call![0] as Record<string, unknown>).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<string, unknown>).method === MESSAGE_METHOD,
Expand Down
13 changes: 2 additions & 11 deletions src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -473,15 +470,9 @@ export async function connect(options: ConnectOptions): Promise<App> {
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],
Expand Down
2 changes: 1 addition & 1 deletion src/event-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
6 changes: 4 additions & 2 deletions src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): void {
if (!hostSupports(app, "action")) return;
Expand Down
11 changes: 2 additions & 9 deletions src/react/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
}

// -----------------------------------------------------------------------------
Expand Down
8 changes: 4 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -404,8 +403,9 @@ export interface App {
readServerResource(params: ReadResourceRequest["params"]): Promise<ReadResourceResult>;
/**
* 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;
}
6 changes: 3 additions & 3 deletions web/src/content/docs/docs/api/connect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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
Expand All @@ -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`. |

Expand Down
Loading
Loading