Skip to content

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.

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.

Terminal window
npm install @nimblebrain/synapse

Synapse 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.

import { connect } from '@nimblebrain/synapse';
const app = await connect({ name: 'my-app', version: '1.0.0' });
// Receive tool results from the host
app.on('tool-result', (data) => {
console.log('Tool output:', data.content);
});
// Call tools on your app's MCP server
const 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 sees
app.updateModelContext(
{ filter: 'overdue', selectedCount: 3 },
'User is viewing 3 overdue tasks'
);
// Send a message into the conversation
app.sendMessage('Show me the Q2 report');

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 completes
const 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 tool
const unsub2 = app.on('tool-input', (args) => {
console.log('Tool called with:', args);
});
// Theme changed — host switched light/dark mode
app.on('theme-changed', (theme) => {
document.documentElement.setAttribute('data-theme', theme.mode);
});
// Teardown — host is removing the iframe
app.on('teardown', () => {
// Clean up resources
});
// Custom/extension events pass through as-is
app.on('synapse/data-changed', (params) => {
console.log('Agent modified data');
});
// Unsubscribe when done
unsub1();
unsub2();
EventFires whenHandler receives
tool-resultHost delivers a tool resultToolResultData — parsed content
tool-inputHost sends tool input argsRecord<string, unknown>
tool-input-partialStreaming partial inputRecord<string, unknown>
tool-cancelledTool call was cancelledunknown
theme-changedHost theme changedTheme
teardownHost is removing the iframe(none)
Any custom stringExtension events (e.g., synapse/data-changed)unknown

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>
);
}

Generate TypeScript interfaces from your MCP server’s tool schemas:

Terminal window
npx synapse codegen --from-manifest ./manifest.json --out src/generated/types.ts

This reads inputSchema (and optional outputSchema) from your tool definitions and produces typed interfaces:

// Auto-generated
export 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 CreateTaskOutput

Codegen sources:

FlagSource
--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

Control the iframe size reported to the host. Two modes:

const app = await connect({ name: 'my-app', version: '1.0.0' });
// Auto-measure document.body and send size to host
app.resize();
// Or send explicit dimensions
app.resize(800, 600);

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 overrides
app.resize(400, 300);

React hook:

function MyComponent() {
const resize = useResize();
useEffect(() => {
// Resize after content renders
resize();
}, [data]);
return <div>{/* content */}</div>;
}

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 listening

React hook (legacy API):

useDataSync((event) => {
queryClient.invalidateQueries(['tasks']);
});

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 hook
const setVisible = useVisibleState();
setVisible({ filter: 'overdue', count: 3 }, 'Viewing 3 overdue tasks');

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' }
OptionDefaultDescription
persistfalsePersist state across iframe reloads via host storage
visibleToAgentfalseAuto-push state to the LLM context after every dispatch
summarizeGenerate a human-readable summary for token budget fallback
versionSchema version for state migration
migrationsOrdered 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>
);
}

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.

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' });

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 immediately
console.log(app.theme);
// { mode: 'dark', tokens: { '--nb-primary': '...', ... } }
// React to changes
app.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.

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: []

The Synapse Vite plugin gives you a full dev experience with one command:

27246/__preview
cd ui
npm run dev

The synapseVite() plugin in your vite.config.ts automatically:

  • Reads ../manifest.json to get your app name and server command
  • Spawns the MCP server as a child process (stdio mode)
  • Serves a preview host page at /__preview that iframes your app
  • Proxies tool calls from the iframe to the MCP server
  • Provides a dark/light theme toggle
ui/vite.config.ts
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.

For testing data sync, agent interactions, and multi-app navigation:

Terminal window
nb dev --app ./ui

Your app must be configured as a local bundle in .nimblebrain/nimblebrain.json.

See the Local Development guide for the full workflow comparison.

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.

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:

FeatureNimbleBrain / PreviewOther hosts
Tool callsFullWorks (if host supports tools/call)
Data syncFull (NB only)No-op
Visible stateFull, trust-gated (NB only)No-op
Widget storePersist + agent (NB only)In-memory only
Actions / chatFullNo-op
ThemeFullFrom ext-apps handshake
Keyboard forwardingFullNo-op
Open linkVia bridgewindow.open() fallback
Save fileVia bridgeNo-op
Pick fileVia bridgeThrows
ExportContents
@nimblebrain/synapseconnect, createSynapse, createStore, all types
@nimblebrain/synapse/reactAppProvider, useApp, useToolResult, useToolInput, useResize, useConnectTheme, SynapseProvider, useSynapse, useCallTool, useDataSync, useTheme, useAction, useAgentAction, useChat, useVisibleState, useStore, useFileUpload
@nimblebrain/synapse/iifeconnect.iife.jswindow.Synapse global for <script> tags
@nimblebrain/synapse/vitesynapseVite Vite plugin
@nimblebrain/synapse/codegenreadFromManifest, readFromServer, generateTypes
HookSourceReturnsSubscribes to
useApp()Connect APIApp object
useToolResult()Connect APIToolResultData | nulltool-result event
useToolInput()Connect APIRecord<string, unknown> | nulltool-input event
useConnectTheme()Connect APIThemetheme-changed event
useResize()Connect API(w?, h?) => void
useCallTool(name)Legacy API{ call, data, isPending, error }
useTheme()Legacy APISynapseThemeTheme changes
useDataSync(cb)Legacy APIdata-changed events
useChat()Legacy API(msg, ctx?) => void
useAction()Legacy API(action, params?) => void
useAgentAction(cb)Legacy APIAgent action events
useVisibleState()Legacy API(state, summary?) => void
useStore(store)Legacy API{ state, dispatch }Store changes
useFileUpload()Legacy API{ pickFile, pickFiles }
useSynapse()Legacy APISynapse 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 wraps the MCP App Bridge protocol. You don’t need to use both — pick one:

SynapseRaw bridge
Use whenBuilding a new app with a build step (Vite, webpack)Simple static HTML, no build step, or vanilla ext-apps
Tool callsTyped, parsed, error-handledRaw JSON-RPC postMessage
State managementcreateStore with persistenceRoll your own
Agent awarenessupdateModelContext / setVisibleStateNot available
Data syncon("synapse/data-changed") or onDataChangedListen for synapse/data-changed manually
Dev experiencenpm run dev with preview + HMRManual server restart
Framework supportVanilla JS + React hooksAny (raw postMessage)

The bridge protocol docs remain the reference for the underlying wire format. Synapse is the SDK that makes it ergonomic.

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()
Handshakeawait connect() resolves when readyawait synapse.ready
Eventsapp.on("tool-result", cb)No tool-result event
Resizeapp.resize() / autoResize optionNot available
Model contextapp.updateModelContext()synapse.setVisibleState()
Messagesapp.sendMessage()synapse.chat()
File saveNot on App object (use legacy)synapse.saveFile()
File pickNot on App object (use legacy)synapse.pickFile() / synapse.pickFiles()
Shell actionsNot on App object (use legacy)synapse.action()
React provider<AppProvider><SynapseProvider>