Synapse SDK
Synapse is the recommended way to build NimbleBrain app UIs. It wraps the standard MCP Apps protocol and adds typed tool calls, LLM-visible state, data sync, and keyboard forwarding.
The primary API is connect() — a single async call that handles the entire MCP Apps handshake and returns a ready-to-use App object. For React apps, <AppProvider> wraps connect() and exposes everything through hooks.
Apps built with vanilla ext-apps still work without Synapse. Synapse apps degrade gracefully in non-NimbleBrain hosts (Claude, ChatGPT, VS Code) — NB-specific features become silent no-ops while the ext-apps baseline continues working.
Why use Synapse?
Section titled “Why use Synapse?”Raw ext-apps gives you an iframe and postMessage. That works for static UIs — but as soon as the agent and UI share mutable state, you hit real friction:
The UI goes stale when the agent acts. A user says “change the headline.” The agent calls set_content. Without Synapse, your UI has no idea — you poll, or the user refreshes. With Synapse, your server announces the write and useDataSync() fires a callback in every open view.
The agent can’t see what the user is doing. The user filters to “West Coast, Q2.” They ask “how does this compare to last quarter?” The agent doesn’t know what “this” means. updateModelContext() pushes the current filter into the agent’s context so it can answer without asking.
Tool calls are boilerplate. Every raw tool call needs JSON-RPC framing, request ID tracking, content array parsing, and timeout handling — about 25 lines of plumbing per app. useCallTool() gives you { call, data, isPending, error } in one hook.
Local dev is painful. Testing an ext-apps UI means spawning a server, writing a bridge page, wiring postMessage proxying, and handling the handshake. synapseVite() does all of that — npm run dev and open /__preview.
Install
Section titled “Install”npm install @nimblebrain/synapseSynapse has @modelcontextprotocol/ext-apps and react as peer dependencies, but you don’t need to install them separately — Synapse handles the ext-apps handshake internally, and React is only needed if you use the React hooks.
Quick start
Section titled “Quick start”import { connect } from '@nimblebrain/synapse';
const app = await connect({ name: 'my-app', version: '1.0.0' });
// Receive tool results from the hostapp.on('tool-result', (data) => { console.log('Tool output:', data.content);});
// Call tools on your app's MCP serverconst result = await app.callTool('create_task', { title: 'Review Q2' });console.log(result.data);// { id: "tsk_01BXX...", title: "Review Q2", status: "active" }
// Tell the agent what the user seesapp.updateModelContext( { filter: 'overdue', selectedCount: 3 }, 'User is viewing 3 overdue tasks');
// Send a message into the conversationapp.sendMessage('Show me the Q2 report');import { AppProvider, useToolResult, useCallTool, useTheme,} from '@nimblebrain/synapse/react';
function App() { return ( <AppProvider name="my-app" version="1.0.0"> <TaskBoard /> </AppProvider> );}
function TaskBoard() { const result = useToolResult(); const { call, isPending, data } = useCallTool('create_task'); const theme = useTheme();
if (result) { console.log('Last tool result:', result.content); }
return ( <button onClick={() => call({ title: 'New task' })} disabled={isPending} > Create Task </button> );}<script src="node_modules/@nimblebrain/synapse/dist/connect.iife.global.js"></script><div id="root">Loading...</div><script> Synapse.connect({ name: 'my-widget', version: '1.0.0', autoResize: true, }).then(app => { app.on('tool-result', (data) => { document.getElementById('root').innerHTML = render(data.content); }); });</script>Handling events
Section titled “Handling events”The App object returned by connect() uses an event-driven model. Subscribe with on(), which returns an unsubscribe function:
const app = await connect({ name: 'my-app', version: '1.0.0' });
// Tool result — parsed data from the host after a tool call completesconst unsub1 = app.on('tool-result', (data) => { // data.content — parsed text content (JSON-parsed if valid, raw string otherwise) // data.structuredContent — structuredContent if host sent it, null otherwise // data.raw — original params for advanced use console.log(data.content);});
// Tool input — the arguments the agent is sending to the toolconst unsub2 = app.on('tool-input', (args) => { console.log('Tool called with:', args);});
// Theme changed — host switched light/dark modeapp.on('theme-changed', (theme) => { document.documentElement.setAttribute('data-theme', theme.mode);});
// Teardown — host is removing the iframeapp.on('teardown', () => { // Clean up resources});
// Full method names pass through as-is — here, your server announced a writeapp.on('notifications/resources/list_changed', (params) => { console.log('Server announced a write');});
// Unsubscribe when doneunsub1();unsub2();| Event | Fires when | Handler receives |
|---|---|---|
tool-result |
Host delivers a tool result | ToolResultData — parsed content |
tool-input |
Host sends tool input args | Record<string, unknown> |
tool-input-partial |
Streaming partial input | Record<string, unknown> |
tool-cancelled |
Tool call was cancelled | unknown |
theme-changed |
Host theme changed | Theme |
teardown |
Host is removing the iframe | (none) |
| Any method name | Notifications by full method (e.g., notifications/resources/list_changed) |
unknown |
Tool calls
Section titled “Tool calls”app.callTool() sends a tools/call JSON-RPC request to the host bridge, which proxies it to your app’s MCP server. The result is automatically parsed from both raw JSON and MCP CallToolResult formats.
const app = await connect({ name: 'my-app', version: '1.0.0' });
const result = await app.callTool('create_task', { title: 'Review Q2' });
if (result.isError) { console.error('Tool failed:', result.data);} else { console.log('Created:', result.data);}React hook:
function TaskBoard() { const { call, isPending, data, error } = useCallTool('create_task');
return ( <button onClick={() => call({ title: 'New task' })} disabled={isPending}> Create Task </button> );}Typed tool calls with codegen
Section titled “Typed tool calls with codegen”Generate TypeScript interfaces from your MCP server’s tool schemas:
npx synapse codegen --from-manifest ./manifest.json --out src/generated/types.tsThis reads inputSchema (and optional outputSchema) from your tool definitions and produces typed interfaces:
// Auto-generatedexport interface CreateTaskInput { title: string; description?: string; tags?: string[];}
export interface CreateTaskOutput { id: string; title: string; status: 'active' | 'archived' | 'deleted';}
export interface TasksToolMap { create_task: { input: CreateTaskInput; output: CreateTaskOutput }; // ...}Use them with callTool:
import type { TasksToolMap } from './generated/types';
const result = await app.callTool< TasksToolMap['create_task']['input'], TasksToolMap['create_task']['output']>('create_task', { title: 'Review Q2' });
// result.data is typed as CreateTaskOutputCodegen sources:
| Flag | Source |
|---|---|
--from-manifest <path> |
Read from a local manifest.json |
--from-server <url> |
Introspect a running MCP server via tools/list |
--from-schema <dir> |
Generate CRUD types from Upjack entity schemas |
Long-running tools
Section titled “Long-running tools”For tools whose work exceeds the stock MCP request timeout (~60s) — research runs, batch imports, multi-stage analyses — use callToolAsTask instead of callTool. It returns a handle as soon as the server answers, and the handle picks up the result when the work finishes.
callToolAsTask speaks the MCP tasks extension (io.modelcontextprotocol/tasks, protocol 2026-07-28), the only task vocabulary NimbleBrain serves to apps. It needs @nimblebrain/synapse 0.27.0 or later. It declares the extension in the tools/call request’s _meta, and the server decides per call: it runs the tool outright and answers with the result, or it answers with a task that the handle polls with tasks/get. Both come back as a handle, so your code has one path. The 2025-11-25 tasks utility is not served: a task runs only when your server speaks 2026-07-28 and advertises the extension, and on a 2025-era connection the host calls a tool inline and refuses one whose execution.taskSupport is "required" (see the bridge reference).
When to reach for it
Section titled “When to reach for it”| Tool shape | Use |
|---|---|
Returns in <30s, simple result |
callTool / useCallTool |
Returns in <60s but might pause for input/IO |
callTool (acceptable) |
| Long-running, multi-phase, or progress-emitting | callToolAsTask / useCallToolAsTask |
| Returns in milliseconds (CRUD, lookups) | callTool |
The agent itself uses the tasks extension automatically whenever the server advertises it. This section is about the iframe-side API for UIs that fire long-running tools from a button click and reflect the lifecycle in the UI.
React hook (recommended)
Section titled “React hook (recommended)”import { useCallToolAsTask } from '@nimblebrain/synapse/react';
function ResearchPanel() { const { fire, // Start (or restart) the call task, // Latest Task state, or null before fire() result, // ToolCallResult once completed, otherwise null error, // Error on failure, or a TaskError when the task ends without a result isWorking, // the hook is still following the task isTerminal, // the task has ended cancel, // Send tasks/cancel for the active task } = useCallToolAsTask<{ query: string }, { report: string }>('start_research');
if (!task) { return <button onClick={() => fire({ query: 'Q2 metrics' })}>Run research</button>; } if (isWorking) { return <Spinner status={task.status} statusMessage={task.statusMessage} onCancel={cancel} />; } if (error) { return <ErrorBox error={error} />; } return <Report data={result?.data} />;}Wrap the tree in <AppProvider>. A task the user cancels ends with status cancelled and no error.
Imperative API
Section titled “Imperative API”import { callToolAsTask } from '@nimblebrain/synapse';
const handle = await callToolAsTask<{ report: string }>( app, 'start_research', { query: 'Q2 metrics' },);
// `handle.task`: taskId, status, ttl, pollInterval, statusMessage.// A call the server answered outright gives a task that is already `completed`.const unsub = handle.onStatus((task) => { console.log('status:', task.status, task.statusMessage);});
const result = await handle.result(); // polls tasks/get until the task endsunsub();
// Or read the status once, or cancelconst current = await handle.refresh();await handle.cancel();result() resolves with the tool’s result, parsed the way callTool parses one. It rejects with a TaskError when the task fails (carrying the server’s error), is cancelled, or asks for input (input_required); NimbleBrain never answers input requests, so Synapse cancels such a task before rejecting.
Authoring task-aware tools
Section titled “Authoring task-aware tools”Nothing is required: a server without tasks support answers every call outright, and callToolAsTask still works. To have a slow tool run as a task, the server implements the tasks extension and answers that call with a task. With FastMCP 4 (Python), register the extension (mcp.add_extension(TasksExtension(...)) from fastmcp_tasks) and see its documentation for marking a tool to run as a task.
Dual-channel pattern
Section titled “Dual-channel pattern”Tasks that create durable entities (a research run, an import job) should deliver the entity ID through the entity channel (the server’s notifications/resources/list_changed, heard by useDataSync), not the task result. The two channels carry different things:
- Task channel: lifecycle (
working→completed/failed/cancelled), status messages, cancellation control. - Entity channel: the durable record — survives the LLM losing interest mid-run, the client disconnecting, or the agent process bouncing.
UIs that need to navigate to the new entity should re-read on useDataSync rather than awaiting result(). The server announces each write to the run, so the list refreshes as soon as the run exists:
function ResearchPanel() { const { fire, task, isWorking, cancel } = useCallToolAsTask('start_research'); const listRuns = useCallTool('list_runs'); useDataSync(() => { listRuns.call({}); }); const runs: ResearchRun[] = listRuns.data?.runs ?? [];
// Snapshot existing IDs at fire-time, navigate when a new one appears. const knownIds = useRef(new Set<string>()); useEffect(() => { if (task && isWorking) { runs.forEach((r) => knownIds.current.add(r.id)); } }, [task, isWorking]);
useEffect(() => { const fresh = runs.find((r) => !knownIds.current.has(r.id)); if (fresh) navigate(`/runs/${fresh.id}`); }, [runs]);
return isWorking ? <Spinner onCancel={cancel} /> : <button onClick={() => fire({ query })}>Retry</button>;}This pattern means the UI navigates within ~1s of the click — as soon as the server creates the entity — instead of blocking for minutes on the task result.
Capability detection and graceful fallback
Section titled “Capability detection and graceful fallback”A host that relays the extension declares hostCapabilities.experimental["io.modelcontextprotocol/tasks"] (an empty object; presence is the signal). On a host that does not, callToolAsTask rejects with HostCapabilityError without sending. Read app.supportsTasks to branch before firing:
const result = app.supportsTasks ? await (await callToolAsTask(app, 'start_research', { query })).result() : await app.callTool('start_research', { query }); // blocking; no cancel or statusPolling and cancellation behavior
Section titled “Polling and cancellation behavior”- Polling. The handle polls
tasks/getat the task’spollInterval(2s when the server names none, never faster than every 250ms). A terminal answer carries the outcome inline: the result whencompleted, the error whenfailed. There are no status notifications;onStatusand the hook’staskare fed by these polls. - Failed polls. A failed poll is retried.
result()rejects after three failures in a row, or at once when the host no longer knows the task (-32602). - Cancel.
cancelsendstasks/cancel. Cancellation is cooperative: the task may reportworkinga little longer before it reachescancelled. - Unmount. Unmounting a component, or firing again, stops polling but does not cancel the server-side task. It keeps running until it ends or its
ttllapses; the user can re-fire to recover state. Passresult({ signal })anAbortSignalto stop waiting on a handle directly.
Resize
Section titled “Resize”Control the iframe size reported to the host. Two modes:
Manual resize (default)
Section titled “Manual resize (default)”const app = await connect({ name: 'my-app', version: '1.0.0' });
// Auto-measure document.body and send size to hostapp.resize();
// Or send explicit dimensionsapp.resize(800, 600);Auto resize
Section titled “Auto resize”Pass autoResize: true to attach a ResizeObserver on document.body. Synapse sends size-changed on every observed resize, debounced to one animation frame (16ms).
const app = await connect({ name: 'my-widget', version: '1.0.0', autoResize: true,});
// Explicit resize() still works for overridesapp.resize(400, 300);React hook:
function MyComponent() { const resize = useResize();
useEffect(() => { // Resize after content renders resize(); }, [data]);
return <div>{/* content */}</div>;}Data sync
Section titled “Data sync”When your server’s data changes, have the server send notifications/resources/list_changed from the write. The host relays it to that server’s views, and useDataSync runs your callback:
useDataSync(() => { queryClient.invalidateQueries(['tasks']);});The callback receives the notification’s params, which name no tool: the change may come from the agent, another view, a webhook or a schedule. A write the server does not announce refreshes no view. Outside React, subscribe with app.on('notifications/resources/list_changed', cb).
See the SDK guide, Keep the UI in sync with the agent, for the server side in Python and TypeScript, and the bridge reference for what the host relays.
LLM-aware UI state
Section titled “LLM-aware UI state”Push UI state into the agent’s context so it knows what the user is looking at:
app.updateModelContext( { view: 'board', filter: 'overdue', checkedTasks: ['tsk_01', 'tsk_02'] }, 'User is viewing the board with overdue filter, 2 tasks checked');When the user sends a chat message while viewing your app, this state is included in the system prompt. The agent can reference it to provide contextual responses.
The summary parameter is optional but recommended — it’s used as a fallback when the full state exceeds the token budget. app.updateModelContext sends immediately; in React, useModelContext() debounces rapid changes for you.
React hook:
import { useModelContext } from '@nimblebrain/synapse/react';
const push = useModelContext();push({ filter: 'overdue', count: 3 }, 'Viewing 3 overdue tasks');Shell actions
Section titled “Shell actions”Send a message into the conversation, open a link, or ask the NimbleBrain shell to open an app or a conversation:
import { action } from '@nimblebrain/synapse';
app.sendMessage('Show me the Q2 report');app.openLink('https://example.com');
action(app, 'openConversation', { id: 'conv_abc123' });action(app, 'openApp', { name: '@nimblebraininc/contacts' });action(app, 'openConnectorSettings'); // this connector's settings pageTo tell the agent what the user is acting on, push it with app.updateModelContext() (see LLM-aware UI state) before sendMessage; the next turn carries it.
Save a file to the user’s machine, or open the host’s native file picker:
import { downloadFile, pickFile } from '@nimblebrain/synapse';
await downloadFile(app, 'report.csv', csvContent, 'text/csv');
// The host persists each picked file to the workspace store and returns a// stable file ID — bytes never cross the iframe boundary, so uploads aren't// bounded by the tool-call JSON limit.const file = await pickFile(app, { accept: '.csv,.json' });// → { id: 'fl_…', filename: 'data.csv', mimeType: 'text/csv', size: 12345 } | null
// Pass the ID to a tool. Tools read the bytes server-side via the// host's file APIs.if (file) { await app.callTool('ingest_csv', { file_id: file.id });}pickFiles(app, options) picks several. In React, useFileUpload() returns the pickers with a pending flag.
Theming
Section titled “Theming”The App object provides the host theme immediately after connect() resolves, and fires events on changes:
const app = await connect({ name: 'my-app', version: '1.0.0' });
// Available immediatelyconsole.log(app.theme);// { mode: 'dark', tokens: { '--nb-primary': '...', ... } }
// React to changesapp.on('theme-changed', (theme) => { document.documentElement.setAttribute('data-theme', theme.mode);});React hook:
// Returns Theme (mode + tokens); re-renders when the theme changes.const theme = useTheme();Keyboard forwarding
Section titled “Keyboard forwarding”When an iframe has focus, keyboard events fire on the iframe’s document, not the host’s. Pass forwardKeys to connect() so the host’s global shortcuts (like Ctrl+J to open the chat panel) still work while focus is inside the app:
const app = await connect({ name: 'my-app', version: '1.0.0', forwardKeys: true, // Escape and every Ctrl/Cmd combo except the clipboard keys});
// Or forward exactly the listed combosconst app = await connect({ name: 'my-app', version: '1.0.0', forwardKeys: [ { key: 'j', ctrl: true }, // Ctrl+J { key: 'Escape' }, ],});Without forwardKeys, nothing is forwarded. Forwarding is on only where the host declares ai.nimblebrain/keydown.
Dev mode
Section titled “Dev mode”Standalone preview (recommended)
Section titled “Standalone preview (recommended)”The Synapse Vite plugin gives you a full dev experience with one command:
cd uinpm run devThe synapseVite() plugin in your vite.config.ts automatically:
- Reads
../manifest.jsonto get your app name and server command - Spawns the MCP server as a child process (stdio mode)
- Serves a preview host page at
/__previewthat iframes your app - Proxies tool calls from the iframe to the MCP server
- Provides a dark/light theme toggle
import { defineConfig } from "vite";import react from "@vitejs/plugin-react";import { viteSingleFile } from "vite-plugin-singlefile";import { synapseVite } from "@nimblebrain/synapse/vite";
export default defineConfig({ plugins: [react(), viteSingleFile(), synapseVite()], build: { outDir: "dist", assetsInlineLimit: Infinity },});Zero config — synapseVite() reads the manifest. Override if needed:
synapseVite({ appName: "my-app", // override manifest name serverCmd: "uv run python -m server", // override derived command manifest: "../custom-manifest.json", // custom manifest path preview: false, // disable preview (build-only)})Python projects with pyproject.toml are auto-detected — the plugin prepends uv run to the server command.
NimbleBrain platform
Section titled “NimbleBrain platform”For testing data sync, agent interactions, and multi-app navigation:
bun run dev --app ./uiYour app must be registered as a connector in the dev workspace’s workspace.json, pointing at the URL your server listens on.
See the Local Development guide for the full workflow comparison.
Project structure
Section titled “Project structure”The recommended structure for an MCP app with a UI:
Directorymy-app/
- manifest.json
- pyproject.toml or package.json for TS
Directorysrc/mcp_my_app/
- server.py MCP server: tools + ui:// resource
- ui.py Loads ui/dist/index.html
Directoryui/ Vite + React project
- package.json
- vite.config.ts synapseVite() — zero config
- index.html
Directorysrc/
- main.tsx
- App.tsx Your components + Synapse hooks
Directorydist/
- index.html Built single-file HTML
The server reads the built ui/dist/index.html and serves it as a ui:// resource. vite-plugin-singlefile bundles everything (React, CSS, JS) into that single file.
See the Hello World example for a complete walkthrough.
Graceful degradation
Section titled “Graceful degradation”Synapse detects its host during the ext-apps handshake. In NimbleBrain, hostInfo.name is "nimblebrain" — Synapse enables all features. In the standalone preview, the preview host also identifies as "nimblebrain". In other hosts (Claude, ChatGPT, VS Code), NB-specific features degrade:
| Feature | NimbleBrain / Preview | Other hosts |
|---|---|---|
| Tool calls | Full | Works (if host supports tools/call) |
| Data sync | Full (NB only) | No-op |
| Visible state | Full (NB only) | No-op |
| Actions / chat | Full | No-op |
| Theme | Full | From ext-apps handshake |
| Keyboard forwarding | Full | No-op |
| Open link | Via bridge | window.open() fallback |
| Download file | Via bridge | Via ui/download-file where the host supports it |
| Pick file | Via bridge | Throws |
Package exports
Section titled “Package exports”| Export | Contents |
|---|---|
@nimblebrain/synapse |
connect, the extension helpers (action, pickFile, pickFiles, uploadFiles, notify, setLocation, onNavigate, hostSupports, downloadFile, callToolAsTask), NIMBLEBRAIN_EXTENSIONS, connectUI, detectHostKind, HostCapabilityError, TaskError, all types |
@nimblebrain/synapse/react |
AppProvider, useApp, useToolResult, useToolInput, useResize, useTheme, useHostContext, useCallTool, useCallToolAsTask, useDataSync, useModelContext, useSendMessage, useAction, useNotify, useFileUpload, useTrail |
@nimblebrain/synapse/iife |
connect.iife.global.js — window.Synapse global for <script> tags |
@nimblebrain/synapse/vite |
synapseVite Vite plugin |
@nimblebrain/synapse/codegen |
readFromManifest, readFromServer, generateTypes |
React hooks reference
Section titled “React hooks reference”| Hook | Returns | Subscribes to |
|---|---|---|
useApp() |
App object |
— |
useToolResult() |
ToolResultData | null |
tool-result event |
useToolInput() |
Record<string, unknown> | null |
tool-input event |
useTheme() |
Theme |
theme-changed event |
useHostContext() |
McpUiHostContext |
host-context-changed event |
useResize() |
(w?, h?) => void |
— |
useCallTool(name) |
{ call, data, isPending, error } |
— |
useCallToolAsTask(name) |
{ fire, task, result, error, isWorking, isTerminal, cancel } |
Task status, by polling |
useDataSync(cb) |
— | notifications/resources/list_changed from the app’s server |
useModelContext() |
(state, summary?) => void, debounced |
— |
useSendMessage() |
(text) => void |
— |
useAction() |
(action, params?) => void |
— |
useNotify() |
(notice) => Promise<boolean> |
— |
useFileUpload() |
{ pickFile, pickFiles, uploadFiles, isPending } |
— |
useTrail(trail, navigate) |
boolean — whether the host shows the title and breadcrumb |
ai.nimblebrain/navigate |
Every hook requires an <AppProvider> ancestor.
Synapse vs. raw bridge
Section titled “Synapse vs. raw bridge”Synapse wraps the MCP App Bridge protocol. You don’t need to use both — pick one:
| Synapse | Raw bridge | |
|---|---|---|
| Use when | Building a new app with a build step (Vite, webpack) | Simple static HTML, no build step, or vanilla ext-apps |
| Tool calls | Typed, parsed, error-handled | Raw JSON-RPC postMessage |
| Agent awareness | updateModelContext / useModelContext |
Not available |
| Data sync | useDataSync or app.on("notifications/resources/list_changed") |
Listen for notifications/resources/list_changed manually |
| Dev experience | npm run dev with preview + HMR |
Manual server restart |
| Framework support | Vanilla JS + React hooks | Any (raw postMessage) |
The bridge protocol docs remain the reference for the underlying wire format. Synapse is the SDK that makes it ergonomic.