Skip to content

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.

When your app’s HTML loads in an iframe, the platform:

  1. 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’s color-scheme. This happens before your HTML renders — no flash of wrong colors.
  2. Answers your app’s ui/initialize request with the spec tokens under hostContext.styles.variables, and the NimbleBrain tokens that change between light and dark (--color-text-accent, --nb-color-processing, --nb-color-processing-light) under hostContext["ai.nimblebrain/styles"].variables.
  3. Sends ui/notifications/host-context-changed when the user toggles dark mode, with the new theme and the new mode’s values in both fields, once your app has sent ui/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.

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

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.

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

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.

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
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
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
Token Use for
--shadow-hairline Hairline outline
--shadow-sm Small elevation
--shadow-md Medium elevation
--shadow-lg Large elevation

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

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.

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 :root and 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.

If your tokens aren’t applying, open DevTools and inspect the iframe:

  1. In Chrome DevTools, open the Elements panel.
  2. Expand the iframe’s document (click the #document node inside the <iframe>).
  3. 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)

See the Hello World walkthrough for a complete app with theme integration, including both Python and TypeScript implementations.