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, widget persistence, 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, useDataSync() fires a callback the moment the agent’s tool call completes.
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, useConnectTheme,} 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 = useConnectTheme();
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.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});
// Custom/extension events pass through as-isapp.on('synapse/data-changed', (params) => { console.log('Agent modified data');});
// 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 custom string | Extension events (e.g., synapse/data-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 |
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 the agent calls a tool on your server during a conversation, the host sends a data.changed event to your iframe. Synapse surfaces this as a callback:
const unsub = app.on('synapse/data-changed', (event) => { console.log('Agent modified data — refresh UI');});
// Later: unsub() to stop listeningReact hook (legacy API):
useDataSync((event) => { queryClient.invalidateQueries(['tasks']);});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 4096 token budget. Calls are debounced (250ms) to avoid flooding.
Legacy API (via createSynapse):
synapse.setVisibleState({ filter: 'overdue', count: 3 }, 'Viewing 3 overdue tasks');
// React hookconst setVisible = useVisibleState();setVisible({ filter: 'overdue', count: 3 }, 'Viewing 3 overdue tasks');Widget state store
Section titled “Widget state store”Persistent, typed state management with optional agent visibility:
import { createStore } from '@nimblebrain/synapse';
const store = createStore(synapse, { initialState: { filter: 'all', sortBy: 'date' }, actions: { setFilter: (state, filter: string) => ({ ...state, filter }), toggleSort: (state) => ({ ...state, sortBy: state.sortBy === 'date' ? 'priority' : 'date', }), }, persist: true, // Survives iframe reloads visibleToAgent: true, // Auto-pushed to LLM context summarize: (s) => `Viewing ${s.filter} tasks sorted by ${s.sortBy}`,});
store.dispatch.setFilter('overdue');store.getState(); // { filter: 'overdue', sortBy: 'date' }| Option | Default | Description |
|---|---|---|
persist | false | Persist state across iframe reloads via host storage |
visibleToAgent | false | Auto-push state to the LLM context after every dispatch |
summarize | — | Generate a human-readable summary for token budget fallback |
version | — | Schema version for state migration |
migrations | — | Ordered migration functions for versioned state |
React hook:
import { useStore } from '@nimblebrain/synapse/react';
function TaskBoard() { const { state, dispatch } = useStore(store); return ( <button onClick={() => dispatch.setFilter('overdue')}> {state.filter} </button> );}State migration
Section titled “State migration”When your store shape changes, add a version and migrations:
const store = createStore(synapse, { version: 3, initialState: { filter: 'all', sortBy: 'date', view: 'list' }, migrations: [ (v1) => ({ ...v1, sortBy: 'date' }), // v1 -> v2 (v2) => ({ ...v2, view: 'list' }), // v2 -> v3 ], persist: true, actions: { /* ... */ },});On hydration, if the persisted version is lower than the current version, migrations run sequentially.
Shell actions
Section titled “Shell actions”Dispatch semantic actions to the NimbleBrain shell:
app.sendMessage('Show me the Q2 report');app.sendMessage('Update this contact', { action: 'update', entity: 'ct_01BXX',});
app.openLink('https://example.com');Legacy API (via createSynapse):
synapse.action('openConversation', { id: 'conv_abc123' });synapse.action('startChat', { prompt: 'Help me with this task' });synapse.action('openApp', { name: '@nimblebraininc/contacts' });synapse.action('navigate', { route: '/app/tasks' });
synapse.chat('Show me the Q2 report');synapse.chat('Update this contact', { action: 'update', entity: 'ct_01BXX',});
synapse.openLink('https://example.com');synapse.saveFile('report.csv', csvContent, 'text/csv');synapse.pickFile({ accept: '.csv,.json' });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 hooks:
// Connect API — returns Theme (mode + tokens)const theme = useConnectTheme();
// Legacy API — returns SynapseTheme (mode + primaryColor + tokens)const theme = useTheme();Both re-render automatically when the theme changes.
Keyboard forwarding
Section titled “Keyboard forwarding”When an iframe has focus, keyboard events fire on the iframe’s document, not the host’s. Synapse automatically forwards Ctrl/Cmd combos and Escape to the host so global shortcuts (like Ctrl+K to open the chat panel) still work.
Customize which keys are forwarded (via the legacy createSynapse API):
const synapse = createSynapse({ name: 'my-app', version: '1.0.0', forwardKeys: [ { key: 'k', ctrl: true }, // Ctrl+K { key: '/', ctrl: true }, // Ctrl+/ { key: 'Escape' }, ],});Pass an empty array to disable forwarding entirely: forwardKeys: []
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:
nb dev --app ./uiYour app must be configured as a local bundle in .nimblebrain/nimblebrain.json.
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 bundle
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, trust-gated (NB only) | No-op |
| Widget store | Persist + agent (NB only) | In-memory only |
| Actions / chat | Full | No-op |
| Theme | Full | From ext-apps handshake |
| Keyboard forwarding | Full | No-op |
| Open link | Via bridge | window.open() fallback |
| Save file | Via bridge | No-op |
| Pick file | Via bridge | Throws |
Package exports
Section titled “Package exports”| Export | Contents |
|---|---|
@nimblebrain/synapse | connect, createSynapse, createStore, all types |
@nimblebrain/synapse/react | AppProvider, useApp, useToolResult, useToolInput, useResize, useConnectTheme, SynapseProvider, useSynapse, useCallTool, useDataSync, useTheme, useAction, useAgentAction, useChat, useVisibleState, useStore, useFileUpload |
@nimblebrain/synapse/iife | connect.iife.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 | Source | Returns | Subscribes to |
|---|---|---|---|
useApp() | Connect API | App object | — |
useToolResult() | Connect API | ToolResultData | null | tool-result event |
useToolInput() | Connect API | Record<string, unknown> | null | tool-input event |
useConnectTheme() | Connect API | Theme | theme-changed event |
useResize() | Connect API | (w?, h?) => void | — |
useCallTool(name) | Legacy API | { call, data, isPending, error } | — |
useTheme() | Legacy API | SynapseTheme | Theme changes |
useDataSync(cb) | Legacy API | — | data-changed events |
useChat() | Legacy API | (msg, ctx?) => void | — |
useAction() | Legacy API | (action, params?) => void | — |
useAgentAction(cb) | Legacy API | — | Agent action events |
useVisibleState() | Legacy API | (state, summary?) => void | — |
useStore(store) | Legacy API | { state, dispatch } | Store changes |
useFileUpload() | Legacy API | { pickFile, pickFiles } | — |
useSynapse() | Legacy API | Synapse object | — |
Hooks labeled “Connect API” require an <AppProvider> ancestor. Hooks labeled “Legacy API” require a <SynapseProvider> ancestor. Both providers can coexist in the same tree.
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 |
| State management | createStore with persistence | Roll your own |
| Agent awareness | updateModelContext / setVisibleState | Not available |
| Data sync | on("synapse/data-changed") or onDataChanged | Listen for synapse/data-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.
Legacy API: createSynapse
Section titled “Legacy API: createSynapse”The original createSynapse() API remains fully functional and is used by existing apps. New apps should prefer connect() and AppProvider, but migration is not required.
import { createSynapse } from '@nimblebrain/synapse';
const synapse = createSynapse({ name: 'my-app', version: '1.0.0' });await synapse.ready;
synapse.callTool('create_task', { title: 'Review Q2' });synapse.setVisibleState({ filter: 'overdue' }, 'Viewing overdue tasks');synapse.onDataChanged((event) => { /* ... */ });synapse.saveFile('report.csv', csvContent, 'text/csv');synapse.chat('Show me the Q2 report');Key differences from connect():
connect() | createSynapse() | |
|---|---|---|
| Handshake | await connect() resolves when ready | await synapse.ready |
| Events | app.on("tool-result", cb) | No tool-result event |
| Resize | app.resize() / autoResize option | Not available |
| Model context | app.updateModelContext() | synapse.setVisibleState() |
| Messages | app.sendMessage() | synapse.chat() |
| File save | Not on App object (use legacy) | synapse.saveFile() |
| File pick | Not on App object (use legacy) | synapse.pickFile() / synapse.pickFiles() |
| Shell actions | Not on App object (use legacy) | synapse.action() |
| React provider | <AppProvider> | <SynapseProvider> |