Theming
The NimbleBrain platform injects CSS custom properties into every app iframe so your UI can match the host shell — including dark mode — without shipping your own color palette. The tokens follow the MCP ext-apps specification, with a small set of NimbleBrain-specific extensions prefixed --nb-. Read the spec key wherever one exists: it is the one every MCP Apps host can send, so the same CSS themes your app in Claude and ChatGPT too.
How it works
Section titled “How it works”When your app’s HTML loads in an iframe, the platform:
- Injects a
<style>block into your<head>with the tokens for the current theme (ext-apps--color-*/--font-*/--border-radius-*/--shadow-*, plus the NimbleBrain tokens that look the same in light and dark) and the theme’scolor-scheme. This happens before your HTML renders — no flash of wrong colors. - Answers your app’s
ui/initializerequest with the spec tokens underhostContext.styles.variables, and the NimbleBrain tokens that change between light and dark (--color-text-accent,--nb-color-processing,--nb-color-processing-light) underhostContext["ai.nimblebrain/styles"].variables. - Sends
ui/notifications/host-context-changedwhen the user toggles dark mode, with the newthemeand the new mode’s values in both fields, once your app has sentui/notifications/initialized. Every view gets it, in a sidebar or page placement and inline in the chat as a tool result.
The <style> block is written once, when the iframe loads, and the iframe stays loaded across a toggle. So every token that differs between light and dark also arrives over the protocol, and a token that is only delivered over the protocol is one whose value depends on the mode: the block would hold the load-time mode’s value forever.
Your CSS references these variables with var(--token). Inside the platform, with Synapse, the token always has a value — the <style> block lands before your HTML renders, and Synapse applies the rest at the handshake, backed by its own defaults until then — so no fallback is needed. Without Synapse, apply both fields yourself (see Handling dark mode). If your app also has to render where nothing injects tokens, see Running outside the platform below.
Using tokens in CSS
Section titled “Using tokens in CSS”body { font-family: var(--font-sans); background: var(--color-background-primary); color: var(--color-text-primary);}
button { background: var(--color-text-accent); color: var(--color-text-inverse); border-radius: var(--border-radius-md);}
input { border: 1px solid var(--color-border-primary); background: var(--color-background-secondary); color: var(--color-text-primary);}
.muted-text { color: var(--color-text-secondary);}Running outside the platform
Section titled “Running outside the platform”If your app also has to render where nothing injects these tokens — Claude Desktop, another MCP host, or your own dev server — give var() a fallback that is your app’s own default, not a copy of the platform’s current palette:
/* Your neutral, chosen by you. */body { background: var(--color-background-primary, white); color: var(--color-text-primary, black);}Pick the values you would ship if NimbleBrain did not exist. A fallback copied out of the token reference is wrong the moment the platform’s palette moves, and nothing tells you it happened.
An app that only ever runs inside the platform should use the bare form shown under Using tokens in CSS, so that a missing token fails visibly rather than resolving to a plausible wrong colour.
Handling dark mode
Section titled “Handling dark mode”The injected <style> block gives you the mode-independent tokens, and a load-time value for the spec tokens. The handshake response carries the rest. When the user toggles dark mode mid-session, the host sends the updated values in ui/notifications/host-context-changed, but only to an app that has completed the initialization handshake: the host holds every notification until the app sends ui/notifications/initialized. Synapse performs the handshake and applies the tokens for you. Without it, do both yourself:
function applyTokens(tokens) { if (!tokens || typeof tokens !== 'object') return; for (const [key, value] of Object.entries(tokens)) { document.documentElement.style.setProperty(key, value); }}
// Scrollbars and form controls are drawn in the document's color-schemefunction applyColorScheme(theme) { if (theme === 'light' || theme === 'dark') { document.documentElement.style.colorScheme = theme; }}
window.addEventListener('message', (event) => { const msg = event.data; if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return;
// The handshake response: apply its tokens, then tell the host you are ready if (msg.id === 'init-1' && msg.result) { applyColorScheme(msg.result.hostContext?.theme); applyTokens(msg.result.hostContext?.styles?.variables); applyTokens(msg.result.hostContext?.['ai.nimblebrain/styles']?.variables); window.parent.postMessage( { jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} }, '*'); }
// A later change (dark-mode toggle and the like) if (msg.method === 'ui/notifications/host-context-changed') { applyColorScheme(msg.params?.theme); applyTokens(msg.params?.styles?.variables); applyTokens(msg.params?.['ai.nimblebrain/styles']?.variables); }});
window.parent.postMessage({ jsonrpc: '2.0', id: 'init-1', method: 'ui/initialize', params: { protocolVersion: '2026-01-26', appInfo: { name: 'my-app', version: '1.0.0' }, appCapabilities: {} }}, '*');applyTokens sets CSS variables on document.documentElement, which overrides the injected <style> values. Because your CSS uses var() references, the UI updates instantly.
applyColorScheme does the same for color-scheme, which the browser draws scrollbars, form controls and the canvas behind a transparent body with. The injected block sets it for the load-time mode only: the iframe is sandboxed, so the host cannot change it afterwards. Synapse 0.32 and later sets it for you.
Token reference
Section titled “Token reference”The platform delivers the following tokens to every iframe, and every one that differs between light and dark refreshes on a theme change. Those in the ext-apps spec’s variable enum arrive in styles.variables; the others in ai.nimblebrain/styles or the injected <style> block, as above.
Colors (ext-apps spec)
Section titled “Colors (ext-apps spec)”| Token | Use for |
|---|---|
--color-background-primary |
Page background |
--color-background-secondary |
Card / panel backgrounds |
--color-background-tertiary |
Subtle / nested backgrounds |
--color-text-primary |
Body text |
--color-text-secondary |
Captions, metadata, placeholders |
--color-text-tertiary |
De-emphasized text |
--color-text-accent |
Links, primary buttons, AI accents (NimbleBrain’s; in ai.nimblebrain/styles, not styles.variables) |
--color-text-inverse |
Text on a filled accent or danger background |
--color-text-danger |
Errors, destructive actions |
--color-text-success |
Success indicators |
--color-text-warning |
Warning indicators |
--color-background-info |
Info background |
--color-border-primary |
Borders, dividers |
--color-border-secondary |
Secondary borders |
--color-ring-primary |
Focus rings |
Typography (ext-apps spec)
Section titled “Typography (ext-apps spec)”| Token | Use for |
|---|---|
--font-sans |
Body font |
--font-mono |
Code / monospace font |
--font-weight-normal |
Normal weight |
--font-weight-medium |
Medium weight |
--font-weight-semibold |
Semibold weight |
--font-weight-bold |
Bold weight |
--font-text-3xs-size / -line-height |
Dense metadata, badge text |
--font-text-2xs-size / -line-height |
Captions, timestamps |
--font-text-xs-size / -line-height |
Extra-small body text |
--font-text-sm-size / -line-height |
Small body text |
--font-text-base-size / -line-height |
Base body text |
--font-text-lg-size / -line-height |
Large body text |
--font-heading-sm-size / -line-height |
Small heading |
--font-heading-md-size / -line-height |
Medium heading |
--font-heading-lg-size / -line-height |
Large heading |
Layout (ext-apps spec)
Section titled “Layout (ext-apps spec)”| Token | Use for |
|---|---|
--border-radius-xs |
Tight corners |
--border-radius-sm |
Small corners |
--border-radius-md |
Default corners |
--border-radius-lg |
Large corners |
--border-radius-xl |
Extra-large corners |
--border-width-regular |
Standard border width |
Effects (ext-apps spec)
Section titled “Effects (ext-apps spec)”| Token | Use for |
|---|---|
--shadow-hairline |
Hairline outline |
--shadow-sm |
Small elevation |
--shadow-md |
Medium elevation |
--shadow-lg |
Large elevation |
NimbleBrain extensions (--nb-*)
Section titled “NimbleBrain extensions (--nb-*)”They never go in styles.variables, and no other host sends them. The two colours change with the mode, so they arrive in ai.nimblebrain/styles and refresh on a toggle; --nb-font-heading does not, and arrives in the injected <style> block.
Each carries a value the ext-apps spec has no key for. @nimblebrain/synapse reads each, with a fallback for a host that does not send it:
| Token | Use for |
|---|---|
--nb-color-processing |
In-progress / processing state |
--nb-color-processing-light |
Processing background |
--nb-font-heading |
Heading font; where a host does not send it, use --font-sans |
Opt-in, not opt-out
Section titled “Opt-in, not opt-out”Theme injection is non-breaking. The platform injects tokens into every iframe, but they have zero effect unless your CSS references them:
| Your CSS | What happens |
|---|---|
background: white |
Stays white. Injected tokens are ignored. |
background: var(--my-bg) |
Uses your --my-bg variable. No collision. |
background: var(--color-background-primary, white) |
Picks up the platform theme. Falls back to white outside the platform. |
Existing apps require zero changes — theming is entirely opt-in.
Injected style cascade
Section titled “Injected style cascade”The platform injects a <style> block at the top of <head>, before your app’s own <style> tags (only the platform’s Content-Security-Policy <meta> precedes it). This means:
- Platform tokens are declared on
:rootand are available to your CSS. - The injected block includes a minimal body reset (
margin: 0,font-family: var(--font-sans),background: var(--color-background-primary),color: var(--color-text-primary)). - Your app’s
<style>comes after and wins the CSS cascade for any conflicting declarations.
If you define your own body { background: ... }, it overrides the injected reset. This is by design — your app’s styles always win.
Debugging
Section titled “Debugging”If your tokens aren’t applying, open DevTools and inspect the iframe:
- In Chrome DevTools, open the Elements panel.
- Expand the iframe’s document (click the
#documentnode inside the<iframe>). - Select
<html>and check the Computed tab for--color-background-primary.
Common issues:
| Symptom | Cause | Fix |
|---|---|---|
| Background is not what I expected | Your CSS uses background: #fff instead of var(--color-background-primary) |
Replace hardcoded colors with var() references |
| Tokens exist but aren’t applied | CSS specificity — a more specific selector overrides the token | Check that your selector isn’t more specific than the :root declaration |
| Dark mode doesn’t toggle | Missing ui/notifications/host-context-changed handler |
Add the applyTokens message listener (see code above) |
--color-text-accent or an --nb-* color doesn’t flip on toggle |
Your handler applies styles.variables but not ai.nimblebrain/styles |
Apply both fields, as in the applyTokens listener above, or use Synapse 0.31 or later |
| Scrollbars or form controls keep the old mode after a toggle | Nothing updates the document’s color-scheme |
Set it from theme, as applyColorScheme above does, or use Synapse 0.32 or later |
| Tokens are stale after restart | deps/ directory contains an old bundled copy |
Delete deps/<your-package> during local development (see Local Development) |
Full example
Section titled “Full example”See the Hello World walkthrough for a complete app with theme integration, including both Python and TypeScript implementations.