Developer Guide
UI surfaces
The app owns the shell. Your extension fills in the parts it needs: a settings form from a schema, or React components for the top bar, footer, panel, and overlay.
Writing components
There is no build step, so there is no JSX. The loader puts the app's React on globalThis.React before importing your module. Read it at module top level, alias createElement, and write plain function components. Hooks work as normal.
const React = globalThis.React;
const h = React.createElement;
function DevicePanel(props) {
const [level, setLevel] = React.useState(0);
React.useEffect(() => {
let cancelled = false;
props.runtime?.variables
.get('level', { scope: 'persona', defaultValue: 0 })
.then((r) => { if (!cancelled) setLevel(Number(r.value) || 0); })
.catch((err) => console.warn('[device-dashboard] read failed', err));
return () => { cancelled = true; };
}, [props.runtime, props.chat?.currentPersonaId]);
const update = async (next) => {
setLevel(next);
try {
await props.runtime?.variables.set('level', next, { scope: 'persona' });
} catch (err) {
console.warn('[device-dashboard] write failed', err);
}
};
return h('div', { className: 'space-y-2 text-text-primary' },
h('p', { className: 'text-sm text-text-secondary' }, `Level ${level}`),
h('input', {
type: 'range', min: 0, max: 10, value: level,
onChange: (e) => update(Number(e.target.value)),
}),
h('button', {
className: 'text-xs text-orchid-300 hover:underline',
onClick: () => props.chat?.sendMessage('Take the level up one step.'),
}, 'Ask for more'),
);
}Do
Keep hot state in module variables and subscribe components to it with React.useSyncExternalStore, so re-rendering follows the live module instance.
Persist through props.runtime.variables, never localStorage.
Wrap runtime calls in try/catch and warn instead of throwing.
Use the app's Tailwind theme tokens so light themes work.
Do not
Import React or any package. There is nothing to import from.
Subscribe to module state inside a useEffect with empty deps. If the host re-evaluates your module, that effect never re-runs and useExtension() stops reporting changes.
Fetch inside render. Load in an effect and cancel on unmount.
Assume props.runtime exists. It is optional in isolated mounts.
Settings schema
Declare manifest.settings and the app renders the form, persists values, and handles per-persona overrides. Read values in code from resolvedSettings: on ctx.extensionState in lifecycle hooks, or props.extension in components. Tool calls do not receive it, so cache the values from a hook first.
export const manifest = {
apiVersion: 7,
version: '1.0.0',
key: 'device-dashboard',
name: 'Device Dashboard',
category: 'toy',
icon: 'Heart',
description: 'Live device controls in chat.',
settings: {
fields: {
connection: {
id: 'connection',
type: 'device_connection',
scope: 'global',
title: 'Device',
idleLabel: 'Connect',
},
apiKey: {
id: 'apiKey',
type: 'password',
scope: 'global',
title: 'API key',
secret: true,
allowPersonaOverride: false,
},
maxLevel: {
id: 'maxLevel',
type: 'slider',
scope: 'global',
title: 'Maximum level',
description: 'Personas can lower this, never raise it.',
min: 1,
max: 10,
step: 1,
default: 6,
},
mode: {
id: 'mode',
type: 'select',
scope: 'global',
title: 'Mode',
options: [
{ label: 'Gentle', value: 'gentle' },
{ label: 'Firm', value: 'firm' },
],
default: 'gentle',
},
},
mainLayout: ['connection', 'apiKey', 'maxLevel', 'mode'],
quickLayout: ['connection', 'maxLevel'],
},
chatPanel: { order: 100 },
PanelComponent: DevicePanel,
};fieldsRecord<string, Field>Field id to definition. The key and the field's id must match.mainLayoutstring[]Order on the full settings page.quickLayoutstring[]Order in the top-bar dropdown, and the fallback body for the footer and panel when you supply no component. Keep it to a few fields.Common field properties
idstringrequiredSame as the map key.typestringrequiredOne of the types below.scope'global' | 'persona'requiredFor value fields this is a legacy hint; the global-first model below decides where values are stored. For button and device_connection it is honoured as written.titlestringrequiredLabel.descriptionstringHelp text under the field.defaultanyValue before the user saves anything.placeholderstringInput placeholder.secretbooleanStored in the browser's IndexedDB on this device only, never synced, and readable from resolvedSettings. A blank value is treated as unset. Use for tokens and keys.allowPersonaOverridebooleanDefault true. Set false to keep a value global-only.Field types
togglebooleanSwitch.selectoptions: [{ label, value }]Single choice.slidermin, max, step?Numeric slider.numbermin?, max?, step?Numeric input.text / passwordstringSingle line. Pair password with secret: true.textarearows?Multi-line text.buttonactionLabel?Host-rendered button that calls your useExtension().invokeControl(fieldId). Does nothing when invokeControl is missing.device_connectionidleLabel?Same as button. The label is actionLabel, then idleLabel, then title. Reflect connection state through statusIndicator and instances.Global and persona inheritance
Value fields resolve global-first: the field default, then the saved global value, then a sparse per-persona override if the user set one. Reset to Global on the settings page clears a persona's overrides. Button and device_connection fields are actions and do not inherit.
Custom settings body
Set manifest.SettingsComponent when the form needs setup steps, explanations, or a hybrid layout. The app still owns the page, the enable toggle, and persistence.
props.extensionExtensionState & { manifest }Your merged state.props.renderDefaultSettings(scope?)(scope?: 'global' | 'persona' | 'all') => ReactNodeEmbeds the host form so you do not rebuild it.props.resetPersonaOverrides()() => Promise<void>Clears the selected persona's overrides.props.settingsMode / selectedPersonaId / selectedPersonaNamevaluesWhich scope the page is editing and for whom.props.settings / props.onUpdate(settings)valuesCurrent values and a write path for fully custom forms.props.runtime{ variables, emergencyStop }?Bound to your key and the selected persona.Top-bar dropdown
Every enabled extension with showInTopBar unset or true gets a status icon. Its dropdown shows the status label, the quick layout fields, and a manage button. Provide StatusComponent for a richer body.
props.extensionExtensionStateYour merged state.props.onConnect() => void?Host callback for connection-focused bodies.Extensions with instances get one icon per connected instance unless aggregateInstancesInTopBar is true.
Chat footer drawer
A collapsible drawer above the composer. Register with chatFooter: { order } and supply FooterComponent. Without one the host renders StatusComponent, then the quick layout and instance summary.
props.extensionExtensionState & { manifest }Your merged state.props.chat{ appendToMessage, sendMessage, currentPersonaId }Append text to the composer or send a message now.props.isDrawerExpandedbooleanAdapt to the collapsed state.props.onManage() => voidOpens your settings page.props.runtime{ variables, emergencyStop }?Bound runtime.Return null to hide the section for the current state.
Chat side panel
The right-hand extension panel is for persistent dashboards and readouts. Register with chatPanel: { order } and supply PanelComponent. Sections only appear while the extension is enabled for the current persona.
props.extensionExtensionState & { manifest }Your merged state.props.chatChatBarActionContext?Same composer helpers as the footer.props.onManage() => voidOpens your settings page.props.runtime{ variables, emergencyStop }?Bound runtime.Fullscreen overlay
v6. A layer over the whole app, mounted at the root through a portal. Use it for hypnosis visuals, sensor HUDs, or camera previews. Register with overlay: { order } and supply OverlayComponent.
Wrapperposition: fixed; inset: 0; pointer-events: noneThe app underneath stays clickable. Set pointer-events: auto on the parts of your UI that should take clicks.Stackingz-index: 50 + positionOverlays from several extensions composite together, sorted by order ascending.props.extensionExtensionState & { manifest }Your merged state.props.runtime{ variables, emergencyStop, chat, ui }?The overlay can post messages, run turns, and show toasts.The component is mounted whenever the extension is enabled. Return null when there is nothing to draw.
Chat bar actions
Small icon buttons next to the composer, declared in the manifest. Use them for one-tap actions. Anything with text or state belongs in the footer drawer.
chatBarActions: [
{
id: 'dice',
icon: 'Dices',
label: 'Roll the dice',
onClick: (chat) => chat.sendMessage('Roll the dice for me.'),
isActive: () => false,
},
],Prompt state
Return promptState from useExtension() and the app renders it into the Current Toys and Player Information sections of the prompt on every turn, in a format the persona already understands. The player section heading includes the user's name.
promptState: {
toys: [
{
kind: 'toy',
id: 'wand',
name: 'Wand',
visibility: 'visible',
statusLabel: 'On',
parts: [
{ label: 'Speed', value: 60, unit: '%' },
{ label: 'Pattern', value: 'Pulse' },
{ value: 'Looping' },
],
},
],
playerInfo: [
{
kind: 'player_info',
id: 'heart-rate',
label: 'Current Heart Rate',
visibility: 'visible',
value: 89,
unit: 'bpm',
details: ['-5 from 1 minute ago'],
},
],
}toys[]{ kind: 'toy', id, name, visibility, statusLabel?, parts[], sortOrder? }statusLabel is On for active stimulation, Idle when connected but still, Disconnected when configured but offline. parts is ordered: speed, intensity, sensation or pattern, remaining time or Looping or Continuous, then channel only when it changes meaning.playerInfo[]{ kind: 'player_info', id, label, visibility, value, unit?, details?, sortOrder? }Live metrics. details are appended in parentheses.visibility'visible' | 'hidden'Hidden items are skipped. Visible items sort by sortOrder, then by name for toys and label for player info.Instances and controls
Report connected devices or other named resources through instances so the app can list them, show one top-bar icon each, and use them for emergency-stop readiness. Implement invokeControl(fieldId) to back button and device_connection settings fields.
Host layouts or custom React
Stay with the schema
Write a component
Theming
The app has dark and light themes driven by CSS variables on <html>. Your components inherit them only if you use theme-aware colours. Hardcoded hex values and stock Tailwind palette names stay dark on a light theme.
Use
bg-bg-deep, bg-bg-raised, text-text-primary, text-text-secondary, orchid-*, eucalyptus-*, rose-*, border-border. Inline: rgb(var(--color-surface)), rgb(var(--color-text)), rgb(var(--color-accent) / 0.2).Avoid
#0B0817), stock palettes (emerald, slate, gray), and opacity whites (bg-white/5).