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

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
});
// Full method names pass through as-is — here, your server announced a write
app.on('notifications/resources/list_changed', (params) => {
console.log('Server announced a write');
});
// Unsubscribe when done
unsub1();
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

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:

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

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

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.

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.

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 ends
unsub();
// Or read the status once, or cancel
const 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.

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.

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 status
  • Polling. The handle polls tasks/get at the task’s pollInterval (2s when the server names none, never faster than every 250ms). A terminal answer carries the outcome inline: the result when completed, the error when failed. There are no status notifications; onStatus and the hook’s task are 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. cancel sends tasks/cancel. Cancellation is cooperative: the task may report working a little longer before it reaches cancelled.
  • 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 ttl lapses; the user can re-fire to recover state. Pass result({ signal }) an AbortSignal to stop waiting on a handle directly.

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

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

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 page

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

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 hook:

// Returns Theme (mode + tokens); re-renders when the theme changes.
const theme = useTheme();

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 combos
const 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.

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

5173/__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
bun run dev --app ./ui

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

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.

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