# Persona
> Themeable, pluggable AI chat widget for websites. Zero framework dependencies.
> Source: https://github.com/runtypelabs/persona
> npm: https://www.npmjs.com/package/@runtypelabs/persona
> Docs & demos: https://persona-chat.dev
> This is the full reference. For the overview only, see https://persona-chat.dev/llms.txt
> Agent skills: https://github.com/runtypelabs/skills
Persona is a drop-in streaming chat UI for AI assistants that works on any website. It's built in TypeScript with vanilla JS: no React, Vue, or framework dependency. It can render inside an opt-in Shadow DOM (`useShadowDom: true`) for style isolation and ships as ESM, CJS, IIFE script-tag bundles, and focused subpath exports for codegen, plugin helpers, animations, theme tools, testing, and smart DOM page-context reading.
The fastest way to deploy an AI chat experience is with [Runtype](https://runtype.com): Persona is pre-integrated, so you get streaming chat with client tokens, WebMCP/page tools, built-in local client tools, voice, theming, analytics, approvals, and artifacts out of the box. Just set a `clientToken` and go. It also works with any SSE-capable backend if you want to bring your own.
## Agent Skills
If you're an AI coding agent, install the Runtype skills for guided help with Persona integration, Runtype product building, and more:
```bash
npx skills add runtypelabs/skills
```
This registers skills that activate contextually in Claude Code, Cursor, Copilot, Codex, Windsurf, and 30+ other agents. Key skills:
- **`runtype-persona`**: Embedding, configuring, theming, and debugging Persona widgets. Prefers MCP-generated embed code over hand-written snippets.
- **`runtype`**: Umbrella skill for all Runtype platform questions. Routes to focused skills and includes detailed reference material.
- **`runtype-build-product`**: Building and deploying Runtype products (agents, flows, tools, surfaces).
- **`runtype-admin`**: Operating and debugging live Runtype accounts (traces, logs, evals).
- **`runtype-templates`**: Packaging products as distributable FPO templates.
- **`runtype-sdk-marathon`**: Code-first workflows with the TypeScript/Python SDK and Marathon task harness.
Browse and discover skills at [skills.sh](https://skills.sh).
## Install
```bash
npm install @runtypelabs/persona
```
## Quick Start: ES Modules
```ts
import '@runtypelabs/persona/widget.css';
import { initAgentWidget, markdownPostprocessor } from '@runtypelabs/persona';
const chat = initAgentWidget({
target: 'body',
config: {
apiUrl: '/api/chat/dispatch',
launcher: { enabled: true, title: 'AI Assistant' },
theme: { semantic: { colors: { accent: '#2563eb' } } },
postprocessMessage: ({ text }) => markdownPostprocessor(text),
},
});
chat.open();
chat.submitMessage('Hello!');
chat.on('assistant:complete', (msg) => console.log(msg.content));
```
## Quick Start: Script Tag (CDN)
```html
```
## Quick Start: Client Token (No Proxy)
```ts
initAgentWidget({
target: 'body',
config: {
clientToken: 'ct_live_flow01k7_...',
launcher: { enabled: true },
},
});
```
## Recommended Modern Defaults
- Prefer `clientToken` for direct browser-to-Runtype installs when the surface is already configured in Runtype; use `apiUrl` + `@runtypelabs/persona-proxy` when you need server-side API-key control or custom flow definitions.
- Use `features.askUserQuestion.expose: true` to let an agent ask blocking clarifying questions through the built-in `ask_user_question` answer sheet. Leave it `false` when the flow already declares the tool server-side.
- Use `features.suggestReplies.expose: true` to let the agent push fire-and-forget quick-reply chips via `suggest_replies`; chips auto-resume the paused execution and clear after the next user message.
- Use `webmcp: { enabled: true }` to snapshot tools registered on `document.modelContext`, send them as `clientTools[]`, execute returned `webmcp:` calls on the page, and resume the agent with the result. Gate writes with native approval bubbles or `webmcp.onConfirm`; auto-approve safe reads with `webmcp.autoApprove`.
- Keep `sanitize` enabled (default) for DOMPurify sanitization of markdown/custom HTML. If you intentionally return custom HTML from `postprocessMessage`, either fit the built-in allowlist or provide a custom sanitizer; only set `sanitize: false` for fully trusted content.
- Script-tag installs automatically use the small `launcher.global.js` fast path for ordinary floating launchers and defer the full widget until first open. Use `onScriptLoad`, `onLauncherShown`, `onChatReady`, and `onError` (or matching `persona:*` DOM events) for lifecycle analytics.
## Initialization
Two entry points:
- `initAgentWidget({ target, config, useShadowDom?, onChatReady?, windowKey? })`: floating launcher or docked panel. Returns a controller.
- `createAgentExperience(element, config)`: inline embed with no launcher. Returns a controller.
### Controller API
| Method | Description |
|---|---|
| `open()` / `close()` / `toggle()` | Panel visibility |
| `setMessage(text)` | Set input text without submitting |
| `submitMessage(text?)` | Send a message |
| `clearChat()` | Clear conversation |
| `update(config)` | Merge new config at runtime |
| `destroy()` | Remove widget and clean up |
| `on(event, callback)` / `off(event, callback)` | Subscribe to events |
| `injectAssistantMessage({ content, llmContent? })` | Inject a message programmatically |
| `injectUserMessage(...)` / `injectSystemMessage(...)` | Inject user/system messages |
| `injectComponentDirective({ component, props })` | Render a registered component |
| `startVoiceRecognition()` / `stopVoiceRecognition()` | Voice control |
| `focusInput()` | Focus the composer |
| `showEventStream()` / `hideEventStream()` | Event inspector panel |
### Controller Events
| Event | Payload |
|---|---|
| `user:message` | `AgentWidgetMessage` (includes `viaVoice`) |
| `assistant:message` | `AgentWidgetMessage` (stream start) |
| `assistant:complete` | `AgentWidgetMessage` (stream end) |
| `voice:state` | `{ active, source, timestamp }` |
| `widget:opened` / `widget:closed` | `{ open, source, timestamp }` |
| `widget:state` | `{ open, launcherEnabled, voiceActive, streaming }` |
| `action:detected` | `{ action, message }` |
| `message:feedback` | `{ type: 'upvote'|'downvote', messageId, message }` |
| `message:copy` | `AgentWidgetMessage` |
## Core Config Sections
The full config is passed as `config` to `initAgentWidget()`. Key sections:
- **`apiUrl`**: Your proxy endpoint (or use `clientToken` for direct browser-to-API)
- **`flowId`**: Runtype flow ID for server-side flow selection
- **`agent`**, Agent loop config (model, systemPrompt, tools, loopConfig), mutually exclusive with `flowId`
- **`theme` / `darkTheme`**: Design tokens (palette, semantic, component-level). See `llms-full.txt` for the full token reference.
- **`colorScheme`**: `'light'`, `'dark'`, or `'auto'`
- **`launcher`**: Button/panel config (enabled, title, subtitle, position, mountMode, dock)
- **`layout`**: Header, messages, and slot configuration
- **`voiceRecognition`**: Speech-to-text (browser Web Speech API or Runtype WebSocket)
- **`textToSpeech`**: TTS for assistant responses
- **`features`**: Feature flags for reasoning/tool-call visibility, event stream, artifacts, scroll behavior, stream animations, composer history, `ask_user_question`, and `suggest_replies`
- **`webmcp`**: Page tool discovery/execution via WebMCP (`document.modelContext`) and `clientTools[]`
- **`contextProviders` / `requestMiddleware`**: Inject page/editor context into each request (for example, current slide selection)
- **`plugins`**: Array of plugin objects with render hooks
- **`parserType`**: `'plain'`, `'json'`, `'regex-json'`, or `'xml'` for structured streaming
- **`attachments`**: File upload config (types, size limits)
- **`messageActions`**: Copy/upvote/downvote buttons
- **`suggestionChips`**: Quick-reply buttons above the composer
- **`persistState`**: Save widget state across page navigations
- **`postprocessMessage` / `markdown` / `sanitize`**: Transform and render message HTML; DOMPurify sanitization is on by default
## Launcher Modes
- **Floating** (default): `launcher.mountMode: 'floating'`: corner-anchored button + popup panel. Script-tag installs defer the heavy panel bundle until first open when possible.
- **Docked**: `launcher.mountMode: 'docked'`: side panel that wraps a content container. `dock.reveal` controls the animation: `'resize'` (default), `'emerge'`, `'overlay'`, `'push'`. `dock.maxHeight` defaults to `100dvh` as a viewport guard when the page has no definite height chain.
## Message Injection
Inject messages from external code (tool callbacks, navigation events, etc.):
```ts
chat.injectAssistantMessage({
content: 'Displayed to user',
llmContent: 'Sent to the LLM instead',
});
```
Content priority: `contentParts > llmContent > rawContent > content`.
## Plugin System
14 render hooks for customizing any part of the UI:
```ts
const plugin = {
id: 'my-plugin',
renderLauncher: (ctx) => { /* return HTMLElement or null */ },
renderHeader: (ctx) => { /* ... */ },
renderMessage: (ctx) => { /* ... */ },
renderToolCall: (ctx) => { /* ... */ },
renderReasoning: (ctx) => { /* ... */ },
renderLoadingIndicator: (ctx) => { /* ... */ },
renderIdleIndicator: (ctx) => { /* ... */ },
renderAskUserQuestion: (ctx) => { /* ... */ },
// ... and more
};
initAgentWidget({ target: 'body', config: { plugins: [plugin] } });
```
Plugins are priority-ordered. Return `null` from a hook to fall through to the next plugin or the default renderer.
## Proxy Server
Optional `@runtypelabs/persona-proxy` package for server-side API key management:
```ts
import { createChatProxyApp } from '@runtypelabs/persona-proxy';
export default createChatProxyApp({
path: '/api/chat/dispatch',
allowedOrigins: ['https://example.com'],
flowId: 'flow_abc123', // or flowConfig: { ... }
});
```
Set `RUNTYPE_API_KEY` in the environment.
## Framework Integration
Works with any framework. In React/Next.js/Remix/etc., initialize in a `useEffect` and call `handle.destroy()` on cleanup. Next.js App Router needs `'use client'`. For SSR frameworks, use dynamic import or `typeof window` guard.
## Key Exports
| Export | Purpose |
|---|---|
| `initAgentWidget` | Mount floating/docked widget, returns controller |
| `createAgentExperience` | Mount inline widget, returns controller |
| `DEFAULT_WIDGET_CONFIG` | Sensible default config to spread over |
| `mergeWithDefaults` | Deep-merge your overrides with defaults |
| `markdownPostprocessor` / `createMarkdownProcessor` | Render markdown in messages |
| `createDefaultSanitizer` / `resolveSanitizer` | DOMPurify-backed sanitization helpers |
| `ASK_USER_QUESTION_CLIENT_TOOL` / `SUGGEST_REPLIES_CLIENT_TOOL` | Built-in local client tool definitions for server-side reuse |
| `parseAskUserQuestionPayload` / `parseSuggestRepliesPayload` | Parse built-in client-tool payloads |
| `WebMcpBridge` / `WEBMCP_TOOL_PREFIX` | WebMCP bridge utilities |
| `generateCodeSnippet` (subpath `@runtypelabs/persona/codegen`) | Server/CLI-safe embed snippet generation |
| `createJsonStreamParser` | JSON stream parser factory |
| `createXmlParser` | XML stream parser factory |
| `createPlainTextParser` | Plain text parser (default) |
| `componentRegistry` | Register custom components for directive rendering |
| `createTheme` | Build a theme from token overrides |
| `brandPlugin` / `accessibilityPlugin` | Built-in theme plugins |
| `createDropdownMenu` | Dropdown menu utility |
| `createIconButton` / `createLabelButton` / `createToggleGroup` | Button utilities |
| `collectEnrichedPageContext` / `formatEnrichedContext` | DOM context collection for tool use |
| `@runtypelabs/persona/smart-dom-reader` | Optional Shadow-DOM/iframe-aware page context provider |
| `@runtypelabs/persona/plugin-kit` | Shadow-DOM-safe plugin utilities (`injectStyles`, `createPopover`, `isEditableEventTarget`) |
| `@runtypelabs/persona/animations/*` | Optional stream animation plugins such as `wipe` and `glyph-cycle` |
## License
MIT
---
# Widget Configuration Reference
The sections below are the complete widget documentation (initialization, programmatic control, UI components, config tables, parsers, message injection, dynamic forms, code generation, proxy setup, framework guides), integration guides, and the theme/token reference.
## Streaming Agent Widget
Installable vanilla JavaScript widget for embedding a streaming AI assistant on any website.
### Installation
```bash
npm install @runtypelabs/persona
```
### Building locally
```bash
pnpm build
```
- `dist/index.js` (ESM), `dist/index.cjs` (CJS), and `dist/index.global.js` (IIFE) provide different module formats.
- `dist/widget.css` is the prefixed Tailwind bundle.
- `dist/install.global.js` is the automatic installer script for easy script tag installation.
- `dist/launcher.global.js` is the tiny critical launcher used by deferred script-tag installs before the full panel bundle loads.
- `dist/webmcp-polyfill.js` is the lazy WebMCP polyfill chunk used by the IIFE bundle only when `config.webmcp.enabled` is true and the page has no `document.modelContext`.
### Using with modules
```ts
import '@runtypelabs/persona/widget.css';
import {
initAgentWidget,
createAgentExperience,
markdownPostprocessor,
DEFAULT_WIDGET_CONFIG
} from '@runtypelabs/persona';
const proxyUrl = '/api/chat/dispatch';
// Inline embed
const inlineHost = document.querySelector('#inline-widget')!;
createAgentExperience(inlineHost, {
...DEFAULT_WIDGET_CONFIG,
apiUrl: proxyUrl,
launcher: { enabled: false },
theme: {
semantic: { colors: { accent: '#2563eb' } }
},
suggestions: { starters: { items: ['What can you do?', 'Show API docs'] } },
postprocessMessage: ({ text }) => markdownPostprocessor(text)
});
// Floating launcher with runtime updates
const controller = initAgentWidget({
target: '#launcher-root',
windowKey: 'chatController', // Optional: stores controller on window.chatController
config: {
...DEFAULT_WIDGET_CONFIG,
apiUrl: proxyUrl,
launcher: {
...DEFAULT_WIDGET_CONFIG.launcher,
title: 'AI Assistant',
subtitle: 'Here to help you get answers fast'
}
}
});
// Runtime theme update
document.querySelector('#dark-mode')?.addEventListener('click', () => {
controller.update({
theme: { semantic: { colors: { surface: '#0f172a', primary: '#f8fafc' } } }
});
});
// Docked panel that wraps a concrete workspace container
const docked = initAgentWidget({
target: '#workspace-main',
config: {
...DEFAULT_WIDGET_CONFIG,
apiUrl: proxyUrl,
launcher: {
...DEFAULT_WIDGET_CONFIG.launcher,
mountMode: 'docked',
dock: {
side: 'right',
width: '420px',
}
}
}
});
```
### Update merge policy
`update()` applies a recursive patch, not a shallow replace. A key merges into the
live config only when both the previous value and the patch value are plain
objects, so a partial patch (for example `launcher.title`) preserves defaulted and
earlier-set sibling values instead of dropping them. Arrays, functions, class
instances, and scalar values replace wholesale, and so do a small set of
replace-leaf fields whose plain-object values must not be spliced together:
`headers`, `agent`, `storageAdapter`, `components`, `targetProviders`,
`voiceRecognition.provider.custom`, and `features.streamAnimation.plugins`.
Omitting a key preserves its current value: to reset a field, pass it explicitly
with `undefined`, which falls back to the field's default, or leaves it unset
when no default exists.
`initAgentWidget` accepts the following options:
| Option | Type | Description |
| --- | --- | --- |
| `target` | `string \| HTMLElement` | CSS selector or element where widget mounts. |
| `config` | `AgentWidgetConfig` | Widget configuration object (see the [Configuration Reference](./docs/CONFIGURATION-REFERENCE.md)). |
| `useShadowDom` | `boolean` | Use Shadow DOM for style isolation (default: `false`). |
| `onChatReady` | `() => void` | Callback fired when the widget is initialized and its API is callable. |
| `windowKey` | `string` | If provided, stores the controller on `window[windowKey]` for global access. Automatically cleaned up on `destroy()`. |
When `config.launcher.mountMode` is `'docked'`, `target` is treated as the page container that Persona should wrap. Use a concrete element such as `#workspace-main`; `body` and `html` are rejected.
**Height contract:** the docked shell sizes itself with `height: 100%`, so give it a definite height: usually `html, body { height: 100% }` or a fixed-height app-shell container around the target. If no ancestor provides one, the panel is clamped to `dock.maxHeight` (default `100dvh`; `resize`/`emerge` are also sticky-pinned : `push`/`overlay` get the cap only) so it stays viewport-sized and scrolls internally, and a console warning explains the fix. Override the cap with a CSS length or disable the guard with `dock.maxHeight: false`.
With **`dock.reveal: 'resize'`** (default), a **closed** dock uses a **`0px`** column. **`'emerge'`** uses the same **column width** animation (content reflows) but the chat panel stays **`dock.width`** wide and is **clipped** by the growing slot: like a normal-width widget emerging from the edge. **`'overlay'`** overlays with `transform`. **`'push'`** uses a sliding track (Shopify-style). The built-in launcher stays hidden in docked mode: open with **`controller.open()`** (or your own chrome).
**Rounded / card layout:** `initAgentWidget` inserts a flex **shell** as the **direct child** of your target’s **parent**, with your `target` in the content column and the dock beside it. Put border-radius, border, and `overflow: hidden` on that **parent** (or an ancestor that wraps only the shell) so the dock column sits inside the same visual card as your content.
**Inner push/overlay:** With `reveal: 'push'` or `'overlay'`, only the wrapped node moves. Use a **narrow `target`** (e.g. a main canvas div). For **`dock.side: 'left'`**, place a persistent rail **in flow** next to the stage (e.g. flex `[nav | stage]`) so the dock doesn’t open **under** the sidebar. For a **right** dock, you can instead use a **full-width** stage with an **absolute** left rail if you want the canvas to translate **behind** that rail. `position: fixed`/`sticky` content inside the target stays **viewport-anchored** (it is not pushed), so offset it while the dock is open if needed, e.g. `[data-persona-dock-open="true"] .my-fixed-bar { right: 420px; }`.
> **Security note:** Persona sanitizes rendered message HTML with DOMPurify by default (`sanitize: true`), including output returned from `postprocessMessage`, `markdownPostprocessor`, and `directivePostprocessor`. If your custom postprocessor intentionally returns tags or attributes outside the built-in allowlist, provide `sanitize: (html) => ...`; only set `sanitize: false` for fully trusted content.
### Documentation
The full reference lives in [`docs/`](./docs/) and the theming guide:
- [Extending Persona](./docs/EXTENDING.md): the map of every extension point: plugins, components, postprocessors, themes, stream parsers, animations, voice, sanitization, actions, context/WebMCP, layout slots, storage, and UI builders, each linked to its deep dive
- [Authoring Plugins](./docs/PLUGINS.md): the `AgentWidgetPlugin` contract, render/data/interaction hooks, global vs per-instance registration, lifecycle, and the `@runtypelabs/persona/plugin-kit` helpers
- [Contributing](../../CONTRIBUTING.md): current guidance for contributing plugins, themes, adapters, examples, and other customizations back to this monorepo
- [Programmatic Control & Events](./docs/PROGRAMMATIC-CONTROL.md): controller API, message hooks and injection, enriched DOM context, WebMCP page tools, DOM and controller events, state loading
- [UI Features & Components](./docs/UI-COMPONENTS.md): message actions and feedback, loading/idle indicators, approvals, built-in `ask_user_question` and `suggest_replies` tools, dropdown menus, button utilities, dynamic forms
- [Script Tag Installation & Framework Integration](./docs/INSTALLATION-FRAMEWORKS.md): automatic installer, deferred launcher lifecycle hooks, manual script tag setup, React, Next.js, Remix, Gatsby, and Astro guides
- [Configuration Reference](./docs/CONFIGURATION-REFERENCE.md): every config option: core, client token mode, agent mode, UI & theme, launcher/docking, layout, voice, WebMCP, tool calls, features, suggestions, state & storage
- [Context Mentions](./docs/CONTEXT-MENTIONS.md): `@`-mention sources, resolve lifecycle, chip vs inline display, menu positioning, slash commands, and render overrides
- [Stream Parser Configuration](./docs/STREAM-PARSERS.md): JSON, XML, and plain-text stream parsers and custom parser factories
- [Message Injection](./docs/MESSAGE-INJECTION.md): full injection and component-directive reference
- [Dynamic Forms](./docs/DYNAMIC-FORMS.md): field schema, form styles, and recipes
- [Code Generator](./docs/CODE-GENERATOR.md): `@runtypelabs/persona/codegen` options for CLI/server-side snippet generation
- [THEME-CONFIG.md](./THEME-CONFIG.md): the complete theme and design-token reference
### Optional Runtype proxy server
The `@runtypelabs/persona-proxy` package handles server-side API-key control and forwards requests to Runtype. You can configure it around a saved agent (recommended for most chat widgets) or a flow.
**Option 1: Reference a Runtype agent ID (recommended)**
```ts
// api/chat.ts
import { createChatProxyApp } from '@runtypelabs/persona-proxy';
export default createChatProxyApp({
path: '/api/chat/dispatch',
allowedOrigins: ['https://www.example.com'],
agentId: 'agent_abc123'
});
```
**Option 2: Use default flow**
```ts
// api/chat.ts
import { createChatProxyApp } from '@runtypelabs/persona-proxy';
export default createChatProxyApp({
path: '/api/chat/dispatch',
allowedOrigins: ['https://www.example.com']
});
```
**Option 3: Reference a Runtype flow ID**
```ts
import { createChatProxyApp } from '@runtypelabs/persona-proxy';
export default createChatProxyApp({
path: '/api/chat/dispatch',
allowedOrigins: ['https://www.example.com'],
flowId: 'flow_abc123' // Flow created in Runtype dashboard or API
});
```
**Option 4: Define a custom flow**
```ts
import { createChatProxyApp } from '@runtypelabs/persona-proxy';
export default createChatProxyApp({
path: '/api/chat/dispatch',
allowedOrigins: ['https://www.example.com'],
flowConfig: {
name: "Custom Chat Flow",
description: "Specialized assistant flow",
steps: [
{
id: "custom_prompt",
name: "Custom Prompt",
type: "prompt",
enabled: true,
config: {
model: "meta/llama3.1-8b-instruct-free",
responseFormat: "markdown",
outputVariable: "prompt_result",
userPrompt: "{{user_message}}",
systemPrompt: "you are a helpful assistant, chatting with a user",
previousMessages: "{{messages}}"
}
}
]
}
});
```
**Hosting on Vercel:**
```ts
import { createVercelHandler } from '@runtypelabs/persona-proxy';
export default createVercelHandler({
allowedOrigins: ['https://www.example.com'],
flowId: 'flow_abc123' // Optional
});
```
**Environment setup:**
Add `RUNTYPE_API_KEY` to your environment. The proxy constructs the Runtype payload (including flow configuration) and streams the response back to the client.
### Development notes
- The widget streams results using SSE and mirrors Persona's flow/agent events (which Runtype implements natively), including `await` local-tool pauses and `/resume` continuations.
- Tailwind classes are prefixed with `tvw-` and scoped to `[data-persona-root]`, so they won't collide with the host page.
- Run `pnpm dev` from the repository root to boot the example Runtype proxy (`examples/runtype-hono-proxy`) and the vanilla demo (`apps/web`).
- The proxy prefers port `43111` but automatically selects the next free port if needed.
- `features.askUserQuestion.expose` and `suggestions.followUps.expose` advertise built-in LOCAL client tools through `clientTools[]`; leave `expose` off if the flow already declares those tools server-side.
- `webmcp: { enabled: true }` snapshots page-registered tools on `document.modelContext`, sends them as `clientTools[]`, executes returned `webmcp:*` calls in the browser, and resumes the paused execution.
---
# Programmatic Control & Events
> Part of the [@runtypelabs/persona](../README.md) documentation.
## Programmatic control
`initAgentWidget` (and `createAgentExperience`) return a controller with methods to programmatically control the widget.
### Basic controls
```ts
const chat = initAgentWidget({
target: '#launcher-root',
config: { /* ... */ }
})
document.getElementById('open-chat')?.addEventListener('click', () => chat.open())
document.getElementById('toggle-chat')?.addEventListener('click', () => chat.toggle())
document.getElementById('close-chat')?.addEventListener('click', () => chat.close())
// Manually retry a dropped durable stream (e.g. a "Reconnect" button). No-op
// unless a durable turn dropped and `reconnectStream` is configured.
document.getElementById('reconnect-chat')?.addEventListener('click', () => chat.reconnect())
```
### Message hooks
You can programmatically set messages, submit messages, and control voice recognition:
```ts
const chat = initAgentWidget({
target: '#launcher-root',
config: { /* ... */ }
})
// Set a message in the input field (doesn't submit)
chat.setMessage("Hello, I need help")
// Submit a message (uses textarea value if no argument provided)
chat.submitMessage()
// Or submit a specific message
chat.submitMessage("What are your hours?")
// Start voice recognition
chat.startVoiceRecognition()
// Stop voice recognition
chat.stopVoiceRecognition()
```
All hook methods return `boolean` indicating success (`true`) or failure (`false`). They will automatically open the widget if it's currently closed (when launcher is enabled).
### Clear chat
```ts
const chat = initAgentWidget({
target: '#launcher-root',
config: { /* ... */ }
})
// Clear all messages programmatically
chat.clearChat()
```
### Follow-up suggestions
Push follow-up suggestions from host code without an agent round trip:
```ts
chat.setFollowUpSuggestions([
"Track my order",
{ label: "Talk to a human", prompt: "I want to talk to a person", behavior: "fill" },
])
chat.clearFollowUpSuggestions()
```
This is an ephemeral UI overlay: the items never enter the transcript, the wire
payload, or persisted state, so they do not survive a refresh. They clear when
the next user message appends, and latest writer wins against the agent's
`suggest_replies` payloads. Rendering goes through `suggestions.followUps`, the
plugin hooks, and the usual DOM events with `source: "host"`, and works even
when `suggestions.followUps.enabled` is `false` (that key disables the tool, not
the surface). See
[UI Features & Components](./UI-COMPONENTS.md#programmatic-follow-ups).
### Message Injection
Inject messages programmatically from external sources like tool call responses, system events, or third-party integrations. This is useful when local tools need to push results back into the conversation.
```ts
const chat = initAgentWidget({
target: '#launcher-root',
config: { /* ... */ }
})
// Simple message injection
chat.injectAssistantMessage({
content: 'Here are your search results...'
});
// User message injection
chat.injectUserMessage({
content: 'Add to cart'
});
// System context injection
chat.injectSystemMessage({
content: '[Context updated]',
llmContent: 'User is viewing product page for iPhone 15 Pro'
});
```
**Dual-Content Messages (llmContent)**
Use `llmContent` to show different content to the user versus what gets sent to the LLM. This is useful for:
- **Token efficiency**: Show rich content to users while sending concise summaries to the LLM
- **Sensitive data redaction**: Display PII to users while hiding it from the LLM
- **Context injection**: Provide detailed LLM context with minimal UI footprint
```ts
// Example: Tool callback that injects search results
async function handleProductSearch(query: string) {
const results = await searchProducts(query);
// User sees full product details with images and prices
// LLM receives a concise summary to save tokens
chat.injectAssistantMessage({
content: `**Found ${results.length} products:**
${results.map(p => `- ${p.name} - $${p.price} (SKU: ${p.sku})`).join('\n')}`,
llmContent: `[Search results: ${results.length} products found, price range $${results.minPrice}-$${results.maxPrice}]`
});
}
// Example: Redacting sensitive information
chat.injectAssistantMessage({
// User sees their order confirmation with details
content: `Your order #12345 has been placed!
- Card ending in 4242
- Shipping to: 123 Main St, Anytown, USA`,
// LLM only knows an order was placed (no PII)
llmContent: '[Order confirmation displayed to user]'
});
```
**Content Priority**
When messages are sent to the API, content is resolved in this priority order:
1. `contentParts` - Multi-modal content (images, files)
2. `llmContent` - Explicit LLM-specific content
3. `content` - Display content as fallback
**Streaming Updates**
For long-running operations, use the same message ID to update content:
```ts
const messageId = 'search-123';
// Show loading state
chat.injectAssistantMessage({
id: messageId,
content: 'Searching...',
streaming: true
});
// Update with results
chat.injectAssistantMessage({
id: messageId,
content: 'Found 5 results...',
llmContent: '[5 search results]',
streaming: false
});
```
**Component Directives (`injectComponentDirective`)**
When you've registered a custom component via `componentRegistry.register(...)`, inject an assistant message that renders that component using the same path Persona uses for streamed JSON directives:
```ts
import { componentRegistry } from '@runtypelabs/persona';
import { DynamicForm } from './components';
componentRegistry.register('DynamicForm', DynamicForm);
chat.injectComponentDirective({
component: 'DynamicForm',
props: {
title: 'Book a demo',
fields: [
{ label: 'Name', type: 'text', required: true },
{ label: 'Email', type: 'email', required: true }
],
submit_text: 'Request meeting'
},
text: 'Share your details to book a demo.',
llmContent: '[Showed booking form]' // optional, redacted version for the LLM
});
```
The helper sets `content` to `text`, `rawContent` to the canonical directive JSON, and forwards `llmContent`. Useful for previews, replays, debug buttons, and local tools that should render a component instead of plain text.
If you already have a serialized directive, you can pass it through `rawContent` directly on any inject method:
```ts
chat.injectAssistantMessage({
content: 'Booking form',
rawContent: JSON.stringify({
text: 'Booking form',
component: 'DynamicForm',
props: { /* ... */ }
}),
llmContent: '[Showed booking form]'
});
```
See [docs/MESSAGE-INJECTION.md](./MESSAGE-INJECTION.md#component-directive-injection) for the full reference.
### Event Stream Control
When the `showEventStreamToggle` feature flag is enabled, you can programmatically control the event stream inspector panel:
```ts
const chat = initAgentWidget({
target: '#launcher-root',
config: {
apiUrl: '/api/chat/dispatch',
features: { showEventStreamToggle: true }
}
})
// Open the event stream panel
chat.showEventStream()
// Close the event stream panel
chat.hideEventStream()
// Check if the event stream panel is currently visible
chat.isEventStreamVisible() // returns boolean
```
These methods are no-ops if `showEventStreamToggle` is not enabled.
### Input focus control
Focus the chat input programmatically:
```ts
const chat = initAgentWidget({
target: '#chat-root',
config: { apiUrl: '/api/chat/dispatch' }
})
// Focus the input (returns true if successful, false if panel is closed or unavailable)
chat.focusInput()
```
In launcher mode, `focusInput()` returns `false` when the panel is closed and does not auto-open it. Use `chat.open()` first if you want to open and focus in one flow.
### Accessing from window
To access the controller globally (e.g., from browser console or external scripts), use the `windowKey` option:
```ts
const chat = initAgentWidget({
target: '#launcher-root',
windowKey: 'chatController', // Stores controller on window.chatController
config: { /* ... */ }
})
// Now accessible globally
window.chatController.setMessage("Hello from console!")
window.chatController.submitMessage("Test message")
window.chatController.startVoiceRecognition()
```
When using the automatic installer script (`install.global.js`), see [Programmatic access with the installer](./INSTALLATION-FRAMEWORKS.md#programmatic-access-with-the-installer) for additional approaches including the `onChatReady` callback and `persona:chat-ready` event.
### Message Types
The widget uses `AgentWidgetMessage` objects to represent messages in the conversation. You can access these through `postprocessMessage` callbacks or by inspecting the session's message array.
```typescript
type AgentWidgetMessage = {
id: string; // Unique message identifier
role: "user" | "assistant" | "system";
content: string; // Message text content
createdAt: string; // ISO timestamp
streaming?: boolean; // Whether message is still streaming
variant?: "assistant" | "reasoning" | "tool";
sequence?: number; // Message ordering
reasoning?: AgentWidgetReasoning;
toolCall?: AgentWidgetToolCall;
tools?: AgentWidgetToolCall[];
viaVoice?: boolean; // Indicates if user message was sent via voice input
};
```
**`viaVoice` field**: Set to `true` when a user message is sent through voice recognition. This allows you to implement voice-specific behaviors, such as automatically reactivating voice recognition after assistant responses. You can check this field in your `postprocessMessage` callback:
```ts
postprocessMessage: ({ message, text, streaming }) => {
if (message.role === 'user' && message.viaVoice) {
console.log('User sent message via voice');
}
return text;
}
```
Alternatively, manually assign the controller:
```ts
const chat = initAgentWidget({ /* ... */ })
window.chatController = chat
```
## Enriched DOM context
Use `collectEnrichedPageContext` and `formatEnrichedContext` to summarize the visible page for tools or metadata (selectors, roles, text, and optional structured card summaries). By default the collector runs in **structured** mode: it gathers candidates, scores them with built-in `ParseRule` definitions in `defaultParseRules` (product/result-style cards), suppresses redundant descendants, then applies `maxElements`. Pass `options: { mode: "simple" }` for the legacy path (traverse with an early cap only, no rules or `formattedSummary`).
```ts
import {
collectEnrichedPageContext,
formatEnrichedContext,
defaultParseRules
} from '@runtypelabs/persona';
const elements = collectEnrichedPageContext({
options: {
mode: 'structured',
maxElements: 80,
excludeSelector: '.persona-host',
maxTextLength: 200,
visibleOnly: true
},
rules: defaultParseRules
});
const pageContext = formatEnrichedContext(elements);
// Structured mode: "Structured summaries:" blocks for matched cards, then grouped interactivity sections.
```
- Omit both `options` and `rules` → structured defaults (`defaultParseRules`, sensible limits).
- `options: { mode: 'structured' }` → explicit structured behavior (same as default).
- `rules: [...]` → custom rules with default options.
- `options: { mode: 'simple' }` → no relation-based scoring or rule-owned formatting. If you also pass `rules`, they are ignored and a console warning is emitted.
Pass `formatEnrichedContext(elements, { mode: 'simple' })` to ignore any `formattedSummary` fields on elements (for example when re-formatting data collected earlier).
**Where things live:** `defaultParseRules` and the rule/config types are part of the public package API: import them from `@runtypelabs/persona` (same entry as `collectEnrichedPageContext`). Exported names you will use most often:
| Export | Role |
| --- | --- |
| `defaultParseRules` | Built-in `ParseRule[]` (commerce-style cards + generic result rows). |
| `ParseRule` | Type for a custom rule: `id`, `scoreElement`, optional `shouldSuppressDescendant`, optional `formatSummary`. |
| `RuleScoringContext` | Argument to rule hooks (`doc`, `maxTextLength`). |
| `ParseOptionsConfig` | `mode`, `maxElements`, `maxCandidates`, `excludeSelector`, `maxTextLength`, `visibleOnly`, `root`. |
| `DomContextOptions` | What you pass to `collectEnrichedPageContext` (`options`, `rules`, plus legacy top-level limits). |
| `FormatEnrichedContextOptions` | Second argument to `formatEnrichedContext` (`mode`). |
| `EnrichedPageElement` | One collected node; optional `formattedSummary` in structured mode. |
Use **Go to definition** (or open `node_modules/@runtypelabs/persona/dist/index.d.ts` after install) for the authoritative field list and JSDoc. Implementation source in this repo: `packages/widget/src/utils/dom-context.ts`.
Custom rule sketch:
```ts
import type { ParseRule } from '@runtypelabs/persona';
const myRules: ParseRule[] = [
{
id: 'kpi-tile',
scoreElement: (el, enriched, ctx) =>
el.classList.contains('kpi-tile') ? 2000 : 0,
formatSummary: (el, enriched, ctx) =>
el.classList.contains('kpi-tile')
? `${enriched.text.trim()}\nselector: ${enriched.selector}`
: null
}
];
```
### Optional: smart-dom-reader provider
The default reader above is a zero-dependency `TreeWalker` and **does not pierce shadow
DOM**. For pages built from web components, an optional provider backed by a
vendored copy of [`@mcp-b/smart-dom-reader`](https://github.com/WebMCP-org/npm-packages/tree/main/packages/smart-dom-reader)
ships as a **separate entry point**, `@runtypelabs/persona/smart-dom-reader`. It is **not**
imported by the main bundle, so consumers who never import this subpath pay nothing: no
extra install, no bundle weight, no IIFE/CDN impact.
It adds, over the default reader: **Shadow-DOM piercing**, form grouping, and page
landmarks/state: while still emitting Persona's `EnrichedPageElement[]` shape so it
formats and flows through the same pipeline.
```ts
import initAgentWidget from '@runtypelabs/persona';
import { createSmartDomReaderContextProvider } from '@runtypelabs/persona/smart-dom-reader';
initAgentWidget({
// ...config
contextProviders: [
createSmartDomReaderContextProvider({
// 'interactive' (default) | 'full': full adds semantic content AND is required
// for shadow-DOM piercing (shadow descendants surface only in full mode).
mode: 'full',
contextKey: 'pageContext', // key under payload.context (default)
// root: document.querySelector('main') // optional: scope to a subtree, skip chrome
})
]
});
```
`contextProviders` are honored on both send paths: agent mode and flow/proxy dispatch mode merge each provider's result into `payload.context` on every request. `requestMiddleware` then receives that payload, so you can transform or template the collected context before it leaves the browser.
You can also use the pieces directly:
```ts
import {
collectSmartDomContext, // → EnrichedPageElement[] (parity with collectEnrichedPageContext)
smartDomResultToEnriched // pure mapper: SmartDOMResult → EnrichedPageElement[]
} from '@runtypelabs/persona/smart-dom-reader';
import { formatEnrichedContext } from '@runtypelabs/persona';
const pageContext = formatEnrichedContext(collectSmartDomContext({ mode: 'full' }));
```
Both `collectSmartDomContext()` and `createSmartDomReaderContextProvider()` accept a
`root` element to scope extraction to a subtree (parity with `collectEnrichedPageContext`'s
`root`): useful to read only your main content region and skip nav/sidebars. Shadow DOM
inside the subtree is still pierced.
> **Actionability caveat.** Persona's click loop (`utils/actions.ts`) drives
> `document.querySelector`, which cannot pierce shadow roots or evaluate XPath. The adapter
> therefore prefers plain-CSS selectors; elements reachable only via shadow-piercing or
> XPath selectors are surfaced to the model as **context only** and are **not clickable**
> through the current `message_and_click` handler.
> **Why vendored, not a dependency.** Every published version of `@mcp-b/smart-dom-reader`
> (2.3.1–3.0.0) is mis-published: its `package.json` points to `dist/index.js` /
> `dist/index.d.ts` while the build only ships `.mjs` / `.d.mts`, so it cannot be imported
> by name in Node or any bundler. The library (MIT, zero-dep) is therefore vendored under
> `packages/widget/src/vendor/smart-dom-reader/`; see that directory's `README.md` for
> provenance and update steps. Once upstream republishes correctly this can revert to a
> normal optional peer dependency.
## WebMCP page tools
When `webmcp: { enabled: true }` is set, the widget consumes tools the page
registers on `document.modelContext` (the [WebMCP](https://github.com/webmachinelearning/webmcp)
producer surface), snapshots them into each request as `clientTools[]`, runs the
agent's calls on the page, and gates each behind a confirm bubble (override with
`autoApprove` / `onConfirm`).
```ts
initAgentWidget({
// ...config
webmcp: {
enabled: true,
autoApprove: (info) => READ_ONLY_TOOLS.has(info.toolName),
},
});
```
**Give your tools user-facing names.** The approval bubble (and any custom
`onConfirm` handler, via `info.title`) shows a human-readable label for the
tool being called. Declare it once at registration with the WebMCP spec's
top-level `title` field:
```ts
document.modelContext.registerTool({
name: "add_to_cart",
title: "Add to Cart", // shown to users in the approval bubble
description: "Add products to the shopping cart. IMPORTANT: …", // agent-facing
inputSchema: { /* … */ },
execute: async (args) => { /* … */ },
});
```
Tools without a `title` get a label derived from their name
(`add_to_cart` → "Add to cart"). The agent-facing `description` is never shown
as the headline: it sits behind the approval bubble's "Show details" toggle.
See [Tool Calls & Approvals](./CONFIGURATION-REFERENCE.md#tool-calls--approvals) for the full summary-line
resolution order and `approval.formatDescription` for parameter-aware copy.
(The legacy `annotations.title` is *not* read: the polyfill's consumer surface
doesn't expose annotations; use top-level `title`.)
**Using WebMCP against a non-Runtype backend (e.g. the Vercel AI SDK)?** The
widget's WebMCP loop expects Runtype's proxy wire protocol (a `step_await` pause
→ `/resume` round-trip). See
[`docs/webmcp-without-runtype.md`](../../../docs/webmcp-without-runtype.md) for the
exact contract and two integration paths, with a runnable Next.js example at
[`examples/ai-sdk-webmcp/`](../../../examples/ai-sdk-webmcp/).
## DOM Events
The widget dispatches custom DOM events that you can listen to for integration with your application:
### `persona:clear-chat`
Dispatched when the user clicks the "Clear chat" button or when `chat.clearChat()` is called programmatically.
```ts
window.addEventListener("persona:clear-chat", (event) => {
console.log("Chat cleared at:", event.detail.timestamp);
// Clear your localStorage, reset state, etc.
});
```
**Event detail:**
- `timestamp`: ISO timestamp string of when the chat was cleared
**Use cases:**
- Clear localStorage chat history
- Reset application state
- Track analytics events
- Sync with backend
**Note:** The widget automatically clears the `"persona-chat-history"` localStorage key by default when chat is cleared. If you set `clearChatHistoryStorageKey` in the config, it will also clear that additional key. You can still listen to this event for additional custom behavior.
### `persona:showEventStream` / `persona:hideEventStream`
Dispatched to programmatically open or close the event stream panel. Requires `showEventStreamToggle: true` in the widget config.
```ts
// Open the event stream panel on all widget instances
window.dispatchEvent(new CustomEvent('persona:showEventStream'))
// Close the event stream panel on all widget instances
window.dispatchEvent(new CustomEvent('persona:hideEventStream'))
```
**Instance scoping:** When multiple widget instances exist on the same page, use the `instanceId` detail to target a specific one. For `createAgentExperience`, the `instanceId` is the original `id` of the mount element. For `initAgentWidget`, it's the `id` of the target element.
```ts
// Target only the widget mounted on #inline-widget
window.dispatchEvent(new CustomEvent('persona:showEventStream', {
detail: { instanceId: 'inline-widget' }
}))
// Events with a non-matching instanceId are ignored
window.dispatchEvent(new CustomEvent('persona:showEventStream', {
detail: { instanceId: 'wrong-id' }
}))
// ^ No effect: no widget has this instanceId
```
### `persona:focusInput`
Dispatched to programmatically focus the chat input on a widget instance.
```ts
// Focus input on all widget instances
window.dispatchEvent(new CustomEvent('persona:focusInput'))
// Focus input on a specific instance
window.dispatchEvent(new CustomEvent('persona:focusInput', {
detail: { instanceId: 'inline-widget' }
}))
```
**Instance scoping:** Same as `persona:showEventStream`: use `detail.instanceId` to target a specific widget. Without `instanceId`, all instances receive the event.
### `persona:chat-ready`
Dispatched on `window` by the automatic installer script (`install.global.js`) when the widget is initialized and its controller API is callable. The `event.detail` contains the `AgentWidgetInitHandle` (the same object returned by `initAgentWidget()`). In a deferred install (the default floating-launcher case) this fires after the user first opens the panel; in an eager install it fires on page load.
```ts
window.addEventListener('persona:chat-ready', (e) => {
const handle = e.detail;
handle.on('user:message', (msg) => console.log(msg));
handle.open();
});
```
The installer also dispatches sibling lifecycle events for diagnostics and analytics:
| Event | `detail` | Fires |
| --- | --- | --- |
| `persona:script-load` | `{ version }` | the installer script executed (before any loading) |
| `persona:launcher-shown` | `{ deferred, element? }` | the floating launcher painted on the page (page-load time) |
| `persona:chat-ready` | the widget handle | the widget is initialized and its API is callable |
| `persona:error` | `{ phase, error }` | a load step (`css` / `bundle` / `init`) failed |
> **Note:** These events are only dispatched by the automatic installer script. Direct calls to `initAgentWidget()` return the handle synchronously and do not fire them.
## Controller Events
The widget controller exposes an event system for reacting to chat events. Use `controller.on(eventName, callback)` to subscribe and `controller.off(eventName, callback)` to unsubscribe.
### Available Events
| Event | Payload | Description |
|-------|---------|-------------|
| `user:message` | `AgentWidgetMessage` | Emitted when a new user message is detected. Includes `viaVoice: true` if sent via voice. |
| `assistant:message` | `AgentWidgetMessage` | Emitted when an assistant message starts streaming |
| `assistant:complete` | `AgentWidgetMessage` | Emitted when an assistant message finishes streaming |
| `voice:state` | `AgentWidgetVoiceStateEvent` | Emitted when voice recognition state changes |
| `voice:status` | `AgentWidgetVoiceStatusEvent` | Emitted when the voice pipeline status changes (e.g. listening, processing, speaking) |
| `action:detected` | `AgentWidgetActionEventPayload` | Emitted when an action is parsed from an assistant message |
| `action:resubmit` | `AgentWidgetActionEventPayload` | Emitted when an action handler requests a follow-up/resubmit after injection |
| `widget:opened` | `AgentWidgetStateEvent` | Emitted when the widget panel opens |
| `widget:closed` | `AgentWidgetStateEvent` | Emitted when the widget panel closes |
| `widget:state` | `AgentWidgetStateSnapshot` | Emitted on any widget state change |
| `message:feedback` | `AgentWidgetMessageFeedback` | Emitted when user provides feedback (upvote/downvote) |
| `message:copy` | `AgentWidgetMessage` | Emitted when user copies a message |
| `message:read-aloud` | `AgentWidgetReadAloudEvent` | Emitted when text-to-speech playback for a message starts, stops, or finishes |
| `eventStream:opened` | `{ timestamp: number }` | Emitted when the event stream panel opens |
| `eventStream:closed` | `{ timestamp: number }` | Emitted when the event stream panel closes |
| `approval:requested` | `{ approval, message }` | Emitted when an approval bubble is created |
| `approval:resolved` | `{ approval, decision }` | Emitted when an approval is approved/denied |
| `stream:paused` | `{ executionId, after }` | Emitted when a durable stream drops and a reconnect is pending |
| `stream:resuming` | `{ executionId, after, attempt }` | Emitted on each durable reconnect attempt |
| `stream:resumed` | `{ executionId, after }` | Emitted when a durable turn resumes to its terminal after a reconnect |
Every event is subscribed the same way (`controller.on(name, cb)`); the
[combined example](#example-listening-to-events) below wires up one of each. For
the common "what did the agent actually do" debugging case, the tool name,
inputs, approval decision, and result are spread across `approval:requested`,
`approval:resolved`, and the tool fields on `assistant:complete`. See
[Recipe: auditing a tool action](#recipe-auditing-a-tool-action-the-receipt-after-a-mutative-step)
for the assembled picture.
### Event Payload Types
```typescript
// Voice state event
type AgentWidgetVoiceStateEvent = {
active: boolean;
source: "user" | "auto" | "restore" | "system";
timestamp: number;
};
// Widget state event (for opened/closed)
type AgentWidgetStateEvent = {
open: boolean;
source: "user" | "auto" | "api" | "system";
timestamp: number;
};
// Widget state snapshot
type AgentWidgetStateSnapshot = {
open: boolean;
launcherEnabled: boolean;
voiceActive: boolean;
streaming: boolean;
};
// Action event payload
type AgentWidgetActionEventPayload = {
action: AgentWidgetParsedAction;
message: AgentWidgetMessage;
};
// Message feedback
type AgentWidgetMessageFeedback = {
type: "upvote" | "downvote";
messageId: string;
message: AgentWidgetMessage;
};
// Voice pipeline status (distinct from voice:state on/off)
type AgentWidgetVoiceStatusEvent = {
status: VoiceStatus; // e.g. "idle" | "listening" | "processing" | "speaking"
timestamp: number;
};
// Text-to-speech playback transitions (message:read-aloud)
type AgentWidgetReadAloudEvent = {
messageId: string | null; // the message being read (or that just stopped)
message: AgentWidgetMessage | null; // the message object, when still in the thread
state: ReadAloudState; // the new playback state
timestamp: number;
};
// Approval bubble (approval:requested → { approval, message },
// approval:resolved → { approval, decision })
type AgentWidgetApproval = {
id: string;
status: "pending" | "approved" | "denied" | "timeout";
agentId: string;
executionId: string;
toolName: string; // the tool the agent wants to run
toolType?: string;
description: string; // human-readable summary line
reason?: string; // agent-authored justification, if provided
parameters?: unknown; // the inputs the agent proposed
resolvedAt?: number;
};
// Tool call as it appears on a streamed/completed assistant message
// (message.toolCall, or each entry of message.tools[])
type AgentWidgetToolCall = {
id: string;
name?: string; // tool name
status: "pending" | "running" | "complete";
args?: unknown; // the inputs that were sent
result?: unknown; // the tool's return value (the "receipt")
durationMs?: number; // wall-clock duration once complete
startedAt?: number;
completedAt?: number;
};
// Durable reconnect events (stream:paused / stream:resuming / stream:resumed)
type AgentWidgetStreamEvent = {
executionId: string;
after: string; // the SSE cursor the reconnect resumes from
attempt?: number; // 1-based, present on stream:resuming
};
```
### Example: Listening to Events
```ts
const chat = initAgentWidget({
target: 'body',
config: { apiUrl: '/api/chat/dispatch' }
});
// Listen for new user messages
chat.on('user:message', (message) => {
console.log('User sent:', message.content);
if (message.viaVoice) {
console.log('Message was sent via voice recognition');
}
});
// Listen for completed assistant responses
chat.on('assistant:complete', (message) => {
console.log('Assistant replied:', message.content);
});
// Listen for voice state changes
chat.on('voice:state', (event) => {
console.log('Voice active:', event.active, 'Source:', event.source);
});
// Listen for widget open/close
chat.on('widget:opened', (event) => {
console.log('Widget opened by:', event.source);
});
chat.on('widget:closed', (event) => {
console.log('Widget closed by:', event.source);
});
// Listen for parsed actions from assistant messages
chat.on('action:detected', ({ action, message }) => {
console.log('Action detected:', action.type, action.payload);
});
// Approvals: requested before a gated tool runs, resolved on the user's choice
chat.on('approval:requested', ({ approval }) => {
console.log('Approval needed for', approval.toolName, approval.parameters);
});
chat.on('approval:resolved', ({ approval, decision }) => {
console.log(approval.toolName, 'was', decision); // "approved" | "denied" | ...
});
// Feedback and copy
chat.on('message:feedback', ({ type, messageId }) => {
console.log(type, 'on', messageId); // "upvote" | "downvote"
});
chat.on('message:copy', (message) => {
console.log('Copied:', message.content);
});
// Any widget state change (open, launcher, voice, streaming) in one event
chat.on('widget:state', (snapshot) => {
console.log('State:', snapshot);
});
// Durable reconnect lifecycle (only fires when reconnectStream is configured)
chat.on('stream:paused', (e) => console.log('Dropped, will retry from', e.after));
chat.on('stream:resuming', (e) => console.log('Reconnect attempt', e.attempt));
chat.on('stream:resumed', (e) => console.log('Resumed', e.executionId));
```
### Recipe: auditing a tool action (the receipt after a mutative step)
A common need is to inspect what an agent actually *did*, not just what it said:
the tool name, the inputs it used, whether it was approved, and the result it got
back. That information lives across two boundaries, the approval (before the call)
and the completed assistant message (after it). Subscribe to both to assemble the
full receipt:
```ts
const chat = initAgentWidget({
target: 'body',
config: { apiUrl: '/api/chat/dispatch' }
});
// 1. Before a gated tool runs: the proposed call and its inputs.
chat.on('approval:requested', ({ approval }) => {
console.log('Proposed:', approval.toolName, {
inputs: approval.parameters, // what the agent wants to pass
reason: approval.reason, // the agent's own justification, if any
summary: approval.description, // the human-readable line shown in the bubble
});
});
// 2. The user's decision on that call.
chat.on('approval:resolved', ({ approval, decision }) => {
console.log('Decision:', approval.toolName, '→', decision);
});
// 3. After the turn completes: the result of each tool that ran.
chat.on('assistant:complete', (message) => {
for (const tool of message.tools ?? []) {
console.log('Receipt:', tool.name, {
inputs: tool.args, // the inputs actually sent
result: tool.result, // the tool's return value
durationMs: tool.durationMs,
status: tool.status, // "complete" once finished
});
}
});
```
`message.tools` is the array of every tool call in that assistant turn;
`message.toolCall` is the single-call shorthand when there is exactly one. Both
carry the `AgentWidgetToolCall` shape documented above.
For WebMCP tools specifically, the `toolName` matches the tool you registered on
`document.modelContext`, and `approval.parameters` / `tool.args` are the same
inputs that tool's `execute(args)` received in the page. See
[WebMCP page tools](#webmcp-page-tools) above.
### Example: Voice Mode Persistence
The `user:message` event is useful for implementing custom voice mode persistence across page navigations:
```ts
const chat = initAgentWidget({
target: 'body',
config: {
apiUrl: '/api/chat/dispatch',
voiceRecognition: { enabled: true }
}
});
// Track if the user is in "voice mode"
chat.on('user:message', (message) => {
localStorage.setItem('voice-mode', message.viaVoice ? 'true' : 'false');
});
// On page load, restore voice mode if the user was using voice
if (localStorage.getItem('voice-mode') === 'true') {
chat.startVoiceRecognition();
}
```
Note: The built-in `persistState` option handles this automatically when configured:
```ts
initAgentWidget({
target: 'body',
config: {
persistState: true, // Automatically persists open state and voice mode
voiceRecognition: { enabled: true, autoResume: 'assistant' }
}
});
```
## State Loaded Hook
The `onStateLoaded` hook is called after state is loaded from the storage adapter, but before the widget initializes. Use this to transform or inject messages based on external state (e.g., navigation flags, checkout returns).
Returning `{ state, open: true }` also tells the widget to open the panel after initialization: useful when injecting a post-navigation message that the user should immediately see.
```ts
// Plain state transform
initAgentWidget({
target: 'body',
config: {
storageAdapter: createLocalStorageAdapter('my-chat'),
onStateLoaded: (state) => {
const navMessage = consumeNavigationFlag();
if (navMessage) {
return {
...state,
messages: [...(state.messages || []), {
id: `nav-${Date.now()}`,
role: 'assistant',
content: navMessage,
createdAt: new Date().toISOString()
}]
};
}
return state;
}
}
});
// Return { state, open: true } to also open the panel
initAgentWidget({
target: 'body',
config: {
storageAdapter: createLocalStorageAdapter('my-chat'),
onStateLoaded: (state) => {
const navMessage = consumeNavigationFlag();
if (navMessage) {
return {
state: {
...state,
messages: [...(state.messages || []), {
id: `nav-${Date.now()}`,
role: 'assistant',
content: navMessage,
createdAt: new Date().toISOString()
}]
},
open: true
};
}
return state;
}
}
});
```
**Use cases:**
- Inject messages after page navigation (e.g., "Here are our products!") and open the panel
- Add confirmation messages after checkout/payment returns
- Transform or filter loaded messages
- Inject system messages based on external state
The hook receives the loaded state and must return the (potentially modified) state synchronously.
### Recipe: proactive open with a greeting
Auto-opening the panel with an assistant greeting is the sanctioned proactive
pattern, and it stays a hook rather than a config flag: it is only ever correct
under host-owned conditions (a first visit, a specific route, a returning
checkout), never as a default. `onStateLoaded` is the one place that can inject
the greeting and request the open in the same synchronous pass, before the
widget paints, so nothing pops in after the fact.
```ts
const greeting = (text: string) => ({
id: `greeting-${Date.now()}`,
role: "assistant" as const,
content: text,
createdAt: new Date().toISOString(),
});
initAgentWidget({
target: "#launcher-root",
config: {
apiUrl: "/api/chat/dispatch",
onStateLoaded: (state) => {
const hasHistory = (state.messages?.length ?? 0) > 0;
// Only greet a fresh conversation on the page that warrants it.
if (hasHistory || !location.pathname.startsWith("/pricing")) return state;
return {
state: {
...state,
messages: [greeting("Comparing plans? I can size one for your team.")],
},
open: true,
};
},
},
});
```
Notes:
- Guard on `state.messages`. The hook runs on every load, so an unguarded
greeting re-opens the panel and re-greets on every navigation, the behavior
users read as spam.
- Returning `{ state, open: true }` opens the panel; returning plain `state`
injects the greeting without opening, which is the quieter variant.
- The greeting here is a real session message: it persists, appears in
`getMessages()`, and is visible to the model on the next turn. For a
display-only greeting that never reaches the payload, inject nothing here and
use the welcome card copy instead.
- With `persistState: false` there is no storage adapter, so the hook receives
an empty state on every load and the guard above always passes. Track the
greeting yourself in that mode.
- Configuring `onStateLoaded` opts the script-tag install out of deferred
launcher loading (the hook can request open, so the full bundle has to load up
front). See the deferred launcher notes in
[Script Tag Installation](./INSTALLATION-FRAMEWORKS.md).
---
# UI Features & Components
> Part of the [@runtypelabs/persona](../README.md) documentation.
## Message Actions (Copy, Upvote, Downvote)
The widget includes built-in action buttons for assistant messages that allow users to copy message content and provide feedback through upvote/downvote buttons.
### Configuration
```ts
const controller = initAgentWidget({
target: '#app',
config: {
apiUrl: '/api/chat/dispatch',
// Message actions configuration
messageActions: {
enabled: true, // Enable/disable all action buttons (default: true)
showCopy: true, // Show copy button (default: true)
showUpvote: true, // Show upvote button (default: false - requires backend)
showDownvote: true, // Show downvote button (default: false - requires backend)
visibility: 'hover', // 'hover' or 'always' (default: 'hover')
align: 'right', // 'left', 'center', or 'right' (default: 'right')
layout: 'pill-inside', // 'pill-inside' (compact floating) or 'row-inside' (full-width bar)
// Optional callbacks (called in addition to events)
onCopy: (message) => {
console.log('Copied:', message.id);
},
onFeedback: (feedback) => {
console.log('Feedback:', feedback.type, feedback.messageId);
// Send to your analytics/backend
fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(feedback)
});
}
}
}
});
```
### Feedback Events
Listen to feedback events via the controller:
```ts
// Copy event - fired when user copies a message
controller.on('message:copy', (message) => {
console.log('Message copied:', message.id, message.content);
});
// Feedback event - fired when user upvotes or downvotes
controller.on('message:feedback', (feedback) => {
console.log('Feedback received:', {
type: feedback.type, // 'upvote' or 'downvote'
messageId: feedback.messageId,
message: feedback.message // Full message object
});
});
```
### Feedback Types
```typescript
type AgentWidgetMessageFeedback = {
type: 'upvote' | 'downvote';
messageId: string;
message: AgentWidgetMessage;
};
type AgentWidgetMessageActionsConfig = {
enabled?: boolean;
showCopy?: boolean;
showUpvote?: boolean;
showDownvote?: boolean;
visibility?: 'always' | 'hover';
onFeedback?: (feedback: AgentWidgetMessageFeedback) => void;
onCopy?: (message: AgentWidgetMessage) => void;
};
```
### Visual Behavior
- **Hover mode** (`visibility: 'hover'`): Action buttons appear when hovering over assistant messages
- **Always mode** (`visibility: 'always'`): Action buttons are always visible
- **Copy button**: Shows a checkmark briefly after successful copy
- **Vote buttons**: Toggle active state and are mutually exclusive (upvoting clears downvote and vice versa)
## Loading & Idle Indicators
The widget displays visual indicators during different states of the conversation:
- **Loading indicator**: Shown while waiting for a response (standalone) or when an assistant message is streaming but has no content yet (inline)
- **Idle indicator**: Shown when the widget is idle (not streaming) and has at least one message - useful for showing the assistant is "waiting" for user input
### Configuration
```ts
const controller = initAgentWidget({
target: '#app',
config: {
apiUrl: '/api/chat/dispatch',
loadingIndicator: {
// Show/hide bubble styling around standalone indicator (default: true)
showBubble: false,
// Custom loading indicator renderer
render: ({ location, config, defaultRenderer }) => {
// location: 'standalone' (separate bubble) or 'inline' (inside message)
if (location === 'standalone') {
const el = document.createElement('div');
el.innerHTML = '';
el.setAttribute('data-preserve-animation', 'true');
return el;
}
// Use default 3-dot bouncing indicator for inline
return defaultRenderer();
},
// Custom idle state indicator (shown after response completes)
renderIdle: ({ lastMessage, messageCount, config }) => {
// Only show after assistant messages
if (lastMessage?.role !== 'assistant') return null;
const el = document.createElement('div');
el.textContent = 'What would you like to do next?';
el.setAttribute('data-preserve-animation', 'true');
return el;
}
}
}
});
```
### Indicator Locations
| Location | When Shown | Description |
|----------|------------|-------------|
| `standalone` | Waiting for stream to start | Separate bubble shown after user sends a message |
| `inline` | Streaming with empty content | Inside the assistant message bubble |
| `idle` | Not streaming, has messages | After assistant finishes responding |
### Animation Preservation
When using custom animated indicators, add the `data-preserve-animation="true"` attribute to prevent the DOM morpher from interrupting CSS animations during updates:
```ts
render: () => {
const el = document.createElement('div');
el.setAttribute('data-preserve-animation', 'true');
el.innerHTML = `
⟳
`;
return el;
}
```
### Hiding Indicators
Return `null` from any render function to hide that indicator:
```ts
loadingIndicator: {
// Hide loading indicator entirely
render: () => null,
// Hide idle indicator (default behavior)
renderIdle: () => null
}
```
### Using Plugins
You can also customize indicators via plugins, which take priority over config:
```ts
const customIndicatorPlugin = {
id: 'custom-indicators',
renderLoadingIndicator: ({ location, defaultRenderer }) => {
if (location === 'standalone') {
return createCustomSpinner();
}
return defaultRenderer();
},
renderIdleIndicator: ({ lastMessage, messageCount }) => {
if (messageCount === 0) return null;
if (lastMessage?.role !== 'assistant') return null;
return createIdleAnimation();
}
};
initAgentWidget({
target: '#app',
config: {
plugins: [customIndicatorPlugin]
}
});
```
### Type Definitions
```typescript
// Loading indicator context
type LoadingIndicatorRenderContext = {
config: AgentWidgetConfig;
streaming: boolean;
location: 'inline' | 'standalone';
defaultRenderer: () => HTMLElement;
};
// Idle indicator context
type IdleIndicatorRenderContext = {
config: AgentWidgetConfig;
lastMessage: AgentWidgetMessage | undefined;
messageCount: number;
};
// Configuration
type AgentWidgetLoadingIndicatorConfig = {
showBubble?: boolean;
render?: (context: LoadingIndicatorRenderContext) => HTMLElement | null;
renderIdle?: (context: IdleIndicatorRenderContext) => HTMLElement | null;
};
```
### Priority Chain
Indicators are resolved in this order:
1. **Plugin hook** (`renderLoadingIndicator` / `renderIdleIndicator`)
2. **Config function** (`loadingIndicator.render` / `loadingIndicator.renderIdle`)
3. **Default** (3-dot bouncing animation for loading, `null` for idle)
## Ask User Question
The `ask_user_question` feature turns a LOCAL agent tool into an interactive prompt with tappable option pills. When the agent calls the `ask_user_question` tool, the server pauses execution and emits a `step_await` event; the widget renders an answer-pill sheet over the composer; the user picks / types / dismisses; the widget POSTs the answer to `/v1/dispatch/resume` and the paused execution continues with a structured `tool_result`.
This is the recommended pattern for human-in-the-loop clarifying questions.
### Exposing the tool to the agent
The simplest setup is `expose: true`: the widget advertises a built-in `ask_user_question` tool definition (model-facing description + JSON schema) on every dispatch via `clientTools[]`, the same wire surface WebMCP page tools use. No server-side declaration needed; the server registers it as a LOCAL tool under its bare name and any flow's agent can call it.
```ts
features: {
askUserQuestion: { expose: true }
}
```
`expose` defaults to `false` because flows that already declare the tool do not need it. Declaring it in both places is benign: the API dedupes by tool name with the `clientTools[]` entry winning, so exactly one tool reaches the model, using the widget's description and schema. Still declare it in one place. `expose` is also ignored when `enabled: false`, so the agent is never offered a question tool the widget can't render an answer UI for.
The alternative is declaring the tool server-side in your `RuntypeFlowConfig` (a `runtimeTools` LOCAL tool entry); the exported `ASK_USER_QUESTION_CLIENT_TOOL` / `ASK_USER_QUESTION_PARAMETERS_SCHEMA` constants give you the same description and schema to reuse there. Either way, pair your proxy with a `POST` handler that forwards to the upstream `/resume` endpoint (see `@runtypelabs/persona-proxy` and your deployment’s `resume` route).
### Configuration
```ts
features: {
askUserQuestion: {
enabled: true, // default: true. When false, the tool falls through to the normal tool-bubble path.
expose: false, // default: false. When true, advertises the built-in tool to the agent via clientTools[].
layout: 'rows', // default: 'rows'. Use 'pills' for the legacy compact wrap layout.
slideInMs: 180, // slide-in animation duration.
freeTextLabel: 'Other…',
freeTextPlaceholder: 'Type your answer…',
submitLabel: 'Send', // submit label for free-text / multi-select.
nextLabel: 'Next', // grouped (multi-question) payloads.
backLabel: 'Back',
submitAllLabel: 'Submit all',
skipLabel: 'Skip',
groupedAutoAdvance: true, // single-select intermediate pages auto-advance.
styles: {
sheetBackground: '#ffffff',
sheetBorder: '#e5e7eb',
sheetShadow: '0 12px 28px -10px rgba(0,0,0,0.15)',
pillBackground: 'transparent',
pillBackgroundSelected: '#0f0f0f',
pillTextColor: '#1f2937',
pillTextColorSelected: '#fafafa',
pillBorderRadius: '999px',
customInputBackground: '#ffffff'
}
}
}
```
The default `rows` layout renders full-width choices with descriptions always visible and an inline free-text row when `allowFreeText !== false`. `pills` preserves the older compact wrapped pills where descriptions surface as tooltips and the "Other…" pill expands into an input.
A tool call may include 1–8 questions. Single-question payloads render as one sheet. Multi-question payloads render as a paginated "Question N of M" stepper with Back / Next / Skip / Submit-all controls; progress and partial answers persist on the tool message so a refresh can restore the user's place. On the final page, users always confirm with Submit-all: auto-advance never auto-submits the entire group.
The composer-overlay sheet is the question UI. After the user answers, the picked answer (or grouped summary) appears as a normal user bubble so the transcript reads naturally; the answered tool message stores structured answers for review/re-rendering.
### DOM events
The widget dispatches two events on the mount element so the host page can react without touching the plugin API:
| Event | Detail |
|---|---|
| `persona:askUserQuestion:answered` | `{ toolUseId, answer, answers?, values, isFreeText, source }` where `answers` is the structured question→answer map and `source` is `'pick' \| 'multi' \| 'free-text' \| 'submit-all'` |
| `persona:askUserQuestion:dismissed` | `{ toolUseId }` |
```ts
mount.addEventListener('persona:askUserQuestion:answered', (event) => {
const { answer, source } = event.detail;
console.log('User picked', answer, 'via', source);
});
```
### Custom UI via the `renderAskUserQuestion` plugin hook
For full control over the question UI, a modal, a sidebar form, a command palette, whatever, register a plugin with `renderAskUserQuestion`. Returning a non-null `HTMLElement` renders inline in the transcript and suppresses the built-in overlay sheet. Returning `null` falls through to the default sheet.
```ts
import type { AgentWidgetPlugin } from '@runtypelabs/persona';
const customAskPlugin: AgentWidgetPlugin = {
id: 'custom-ask',
renderAskUserQuestion: ({ payload, complete, resolve, dismiss }) => {
const prompt = payload?.questions?.[0];
if (!prompt) return null; // streaming: wait for more data, or show a skeleton
const root = document.createElement('div');
root.className = 'my-question-card';
const q = document.createElement('p');
q.textContent = prompt.question ?? '';
root.appendChild(q);
(prompt.options ?? []).forEach((option) => {
const btn = document.createElement('button');
btn.textContent = option.label;
btn.addEventListener('click', () => resolve(option.label));
root.appendChild(btn);
});
if (prompt.allowFreeText !== false) {
const input = document.createElement('input');
input.placeholder = 'Other…';
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && input.value.trim()) resolve(input.value.trim());
});
root.appendChild(input);
}
const close = document.createElement('button');
close.textContent = '×';
close.addEventListener('click', () => dismiss());
root.appendChild(close);
return root;
}
};
initAgentWidget({
target: '#app',
config: {
plugins: [customAskPlugin]
}
});
```
### Type Definitions
```ts
type AskUserQuestionOption = {
label: string;
description?: string;
preview?: string; // reserved for future richer rendering
};
type AskUserQuestionPrompt = {
question: string;
header?: string; // short group label, ≤12 chars
options: AskUserQuestionOption[]; // 2–4 options
multiSelect?: boolean; // allow multiple picks with a Submit/Next action
allowFreeText?: boolean; // show an "Other…" free-text input (default true)
};
type AskUserQuestionPayload = {
questions: AskUserQuestionPrompt[]; // 1–8 questions; extras are dropped with a warning
};
// Plugin hook signature
renderAskUserQuestion?: (context: {
message: AgentWidgetMessage;
payload: Partial | null; // may be partial mid-stream
complete: boolean; // true once tool-call args fully stream
resolve: (answer: string) => void; // posts /resume with structured toolOutput
dismiss: () => void; // sends "(dismissed)" sentinel
config: AgentWidgetConfig;
}) => HTMLElement | null;
```
For plugins that want to re-parse a tool message outside the hook context, the widget also exports a `parseAskUserQuestionPayload(message)` helper that returns `{ payload, complete }` using the same partial-JSON logic the built-in sheet uses.
### Priority chain
1. **Plugin hook** (`renderAskUserQuestion` returning a non-null element): fully owns the UI; built-in overlay is suppressed.
2. **Built-in overlay sheet**: when the feature is enabled and no plugin handles it.
3. **Generic tool bubble**: when `features.askUserQuestion.enabled` is `false`, the tool call renders through the normal `renderToolCall` path.
## Suggested Replies
The `suggest_replies` feature lets the agent offer tappable next actions for the user's next message. Rendering is transcript-derived: whenever a `suggest_replies` tool call appears in the message list, the widget renders its suggestions using `config.suggestions.followUps`. Unlike `ask_user_question`, nothing blocks on the user: the agent's turn completes, and selecting an item sends or fills its prompt according to configuration.
The **immediate** auto-resume applies to the hosted parked-execution path: a `local` tool declaration parks the execution server-side, and the widget resumes it right away with a canned "shown" result. Backends that emit the tool call fire-and-forget (a `tool_start`/`tool_complete` pair, no pause) need no resume at all, and the widget never POSTs one. See [Integration paths](#integration-paths).
This is the recommended pattern for follow-up discovery: teaching users what to ask next without forcing typing.
### One feature, three vocabularies
The same feature is named differently at each layer, and each name is load-bearing where it lives:
| Layer | Name | Why |
| --- | --- | --- |
| Config | `suggestions.followUps` | Names the user-facing intent, and pairs with `suggestions.starters`. |
| Wire, tool, events | `suggest_replies` (tool name), `features.suggestReplies` (deprecated config alias), `persona:suggestReplies:*` (legacy events) | A released wire contract and shipped event names, so they stay frozen. |
| DOM | `follow-up` (attribute values such as `data-persona-suggestion-surface="follow-up"`) | Follows kebab-case DOM convention. |
There is one feature behind all three spellings, so `suggestions.followUps`, the `suggest_replies` tool call, and the `follow-up` DOM attributes always describe the same surface.
### Where suggestion things live
| Concern | Home |
| --- | --- |
| Enable/expose, items, variant, placement, behavior, caps | `suggestions.*` |
| Colors, radius, states | `theme.components.suggestion` |
| Ranking, custom markup, selection interception | `config.plugins` |
| Deprecated aliases | `features.suggestReplies`, `suggestionChips`, `suggestionChipsConfig` |
`suggestions.starters` owns the pre-conversation surface and
`suggestions.followUps` owns the agent-produced one, including the two
capability keys (`enabled`, `expose`) described below.
### Integration paths
Chips render whenever a `suggest_replies` tool call reaches the transcript, so
the question is only how the model gets the tool and whether the execution
pauses. Pick the row that matches your setup:
| Your setup | How the model gets the tool | Config | Resume? |
| --- | --- | --- | --- |
| Hosted Runtype, flow/agent declares it | `runtimeTools` entry with `toolType: "local"` | default (`enabled: true, expose: false`) | Yes: execution parks, widget auto-resumes |
| Hosted Runtype, zero backend changes | Widget advertises it on `clientTools[]` | `expose: true` | Yes: same parked-execution and auto-resume path |
| Hosted Runtype, no-pause variant (advanced) | `runtimeTools` entry with a server-executed type (for example a `custom` no-op) | default | No: `tool_start` carries the arguments and the loop continues |
| BYO backend (AI SDK, LangGraph, and similar) | Your framework's `tools:` parameter (no `execute`) plus your system prompt | default (`expose` irrelevant) | No: emit fire-and-forget `tool_start`/`tool_complete` |
| BYO backend, prompt-only | System prompt asks for structured output, your adapter parses it and synthesizes the `tool_start` frame | default | No |
| No model at all | `controller.setFollowUpSuggestions(items)` | `enabled` may even be `false`: it gates the tool, not the host surface | n/a |
| Feature off | n/a | `enabled: false` | Tool renders as a plain bubble and is not auto-resumed; on the hosted path with a `local` declaration the execution parks |
On the hosted path nothing server-side injects steering for client tools, so the
tool description is the only automatic lever on how often the model calls it.
Instruction-level guidance is the primary knob: a line such as "offer 2-3
follow-up suggestions via `suggest_replies` after each answer" in the agent
instructions does more for call frequency than any widget setting. On BYO paths
you own both the system prompt and the tool description.
Declaration is required on the hosted lane: providers reject unknown tool names,
and nothing parses narrated tool calls out of the model's text. The prompt-only
row above works because your own adapter, not the API, turns the model's output
into a tool frame.
### Follow-up suggestions from any backend
No part of the render path inspects declarations, `expose`, execution ids, or
Runtype metadata, so any backend that puts a `suggest_replies` tool call on the
wire gets chips. The minimal fire-and-forget sequence is four frames:
```text
execution_start
tool_start { toolCallId: "call_1", toolName: "suggest_replies",
parameters: { suggestions: [ { label: "Track my order" } ] } }
tool_complete { toolCallId: "call_1", success: true }
execution_complete
```
No `/resume` endpoint is involved, and the widget's default config
(`enabled: true, expose: false`) is already correct: `expose` only matters when
you want the widget to advertise the tool for you.
Gotchas, all verified against the client:
- Arguments must ride `tool_start.parameters` (or `.args`), as an object or a
JSON string. `tool_input_delta` and `tool_input_complete` are display-only, so
streaming the arguments incrementally never populates the tool call and the
chips never appear.
- The stream must terminate with `execution_complete`. Chips are disabled while
the widget considers itself streaming and re-enable on the idle flip, so an
unterminated stream renders chips that stay permanently dead.
- Omit `origin` on frames carrying `suggest_replies` (or send `"sdk"`). The
client reads `origin` on `await` frames only, where `origin: "webmcp"` renames
the tool to `webmcp:suggest_replies` and routes it to the page-tool bridge;
`tool_start` ignores the field entirely. Omitting it is the rule that holds for
both frame kinds. The `ai-sdk-webmcp` shim hardcodes `origin: "webmcp"` for
every tool call, so it needs a branch if you reuse it.
- Items may be plain strings or `{ label, prompt?, description? }`. `id`, `icon`,
`behavior`, and `emphasis` are stripped from agent payloads; reclaim them with
the `transformSuggestions` plugin hook. The cap is 4 items, extras are dropped
with a warning.
- Emit an `await` frame instead of the `tool_start`/`tool_complete` pair only if
your backend genuinely parks. That arms the widget's auto-resume, which POSTs
to `${apiUrl}/resume`, so you have to implement that endpoint or the POST hits
a 404.
Runnable references: [`examples/echo-hono`](../../../examples/echo-hono) is the
minimal keyless one (deterministic suggestions, no model, runs offline), and
[`examples/ai-sdk-next`](../../../examples/ai-sdk-next) is the model-driven one
(tool defined in the AI SDK, steering line in the system prompt, `tool-call`
stream parts mapped onto tool frames).
### Exposing the tool to the agent
```ts
suggestions: {
followUps: { expose: true }
}
```
`expose` defaults to `false` because flows that already declare the tool via
`runtimeTools` do not need it. Declaring it in both places is benign, not a
double presentation: the API dedupes by tool name with the `clientTools[]` entry
winning, so exactly one tool reaches the model. The consequences are that the
widget's description and schema win over the flow's, and the effective type
becomes `local`, so the execution pauses and waits for the widget's auto-resume
even if the flow declared a server-executed type. Declare the tool in one place
so the description and pause behavior are obvious from one file.
`expose` is also ignored when `enabled: false`: a disabled feature neither
renders chips nor auto-resumes, so exposing the tool alongside it would park the
execution on a generic tool bubble forever. (The same applies to a
server-declared `suggest_replies` with `enabled: false`: treat that combination
as a configuration error.)
`features.suggestReplies.enabled` and `features.suggestReplies.expose` keep
working as deprecated aliases. Resolution is per key, and `suggestions.followUps`
wins: an embed that disables the feature under `features` and then adds
presentation-only `suggestions.followUps` keys is not silently re-enabled. When
the two homes disagree the widget warns, in debug mode or not: a config conflict
is a developer error. The warning fires once per distinct conflict per page load.
The aliases are removed in 5.0.
For server-side declaration, the exported `SUGGEST_REPLIES_CLIENT_TOOL` / `SUGGEST_REPLIES_PARAMETERS_SCHEMA` constants provide the same description and schema to reuse in a flow's `runtimeTools`.
### Tool schema
The advertised schema is object-only, so strict structured-output modes never
have to flatten a union:
```ts
{
suggestions: Array<{
label: string // 1-80 chars, the short visible text
prompt?: string // 1-500 chars, defaults to the label
description?: string // 1-160 chars, one line of supporting copy
}> // 1-4 items
}
```
The model owns semantics, the host owns presentation. `id`, `icon`, `behavior`,
and `emphasis` are not advertised, and `parseSuggestRepliesPayload` strips them
from agent payloads even if a model ignores `additionalProperties`. Reclaim them
with the `transformSuggestions` plugin hook (see the
[recipes in PLUGINS.md](./PLUGINS.md#transform-recipes)).
Plain strings stay tolerated on the parse side for older flows that emit
`suggestions: string[]`; a string is treated as both label and prompt.
### Lifecycle
Chip visibility is derived from the transcript, not toggled imperatively: the widget shows the chips of the **last** `suggest_replies` tool message that has **no user message after it**. That one rule covers everything:
- Chips soft-dismiss the moment any user message lands: typed, voice, or a chip tap (which itself sends a user message).
- Chips survive panel close/reopen and page reload (the tool message persists in history and the rule re-evaluates on hydrate). On the parked-execution path, if the page reloads before the automatic resume fired, the execution stays paused server-side; tapping a chip starts a fresh dispatch and the conversation recovers naturally.
- When one turn carries several `suggest_replies` calls, every parked call is resumed but only the latest renders (latest wins).
- Chips are disabled while a response is streaming, like all composer controls.
No transcript bubble is rendered for the tool message: the chips are the entire UI. When `enabled: false`, the message falls through to the generic tool bubble instead.
Follow-ups can render after the transcript or above the composer. Plugins can
transform their data, replace each item, and intercept selection using the same
hooks as starter prompts. See [PLUGINS.md](./PLUGINS.md#suggestion-hooks).
### Configuration
```ts
suggestions: {
followUps: {
enabled: true, // default: true
expose: false, // default: false
variant: "chip", // "chip" | "card" | "list"
placement: "auto", // "auto" | "after-message" | "composer"
behavior: "send", // "send" | "fill"
overflow: "scroll", // "scroll" | "wrap"
maxItems: 4,
},
}
```
| Property | Default | Description |
| --- | --- | --- |
| `enabled` | `true` | Render follow-ups and auto-resume the tool call. When `false`, `suggest_replies` falls through to the generic tool bubble and is not auto-resumed. |
| `expose` | `false` | Advertise the built-in local tool on `clientTools[]`. Forced off when `enabled` is `false`. |
| `variant` | `"chip"` | Item density: `chip`, `card`, or `list`. |
| `placement` | `"auto"` | `auto` renders after the transcript in regular panels and above the composer in composer-bar mode. Explicit `after-message` and `composer` are literal. |
| `behavior` | `"send"` | `send` sends the prompt immediately; `fill` drafts it in the composer. Per-item `behavior` overrides the surface. |
| `overflow` | `"scroll"` | Layout for items past the surface width: `scroll` or `wrap`. Applies to the `chip` variant only; `card` and `list` layouts manage their own stacking. |
| `maxItems` | `4` | Cap applied after the plugin transform chain. |
There is no `followUps.items`: follow-ups are contextual, so they come from the
agent's `suggest_replies` call or from `controller.setFollowUpSuggestions`. A
static list belongs in `suggestions.starters`.
Styling comes from the semantic suggestion theme tokens under
`theme.components.suggestion`. The legacy `suggestionChipsConfig` remains
supported for backwards-compatible font and padding overrides.
### Presentation pairings
`variant` and `placement` are independent axes, so every combination renders.
These are the pairings the styles were designed around:
| Surface | Variant | Placement | Notes |
| --- | --- | --- | --- |
| Starters | `card` | `welcome` | The default welcome layout: room for `description` and `icon`. |
| Starters | `chip` | `composer` | Compact row above the input for a returning user. |
| Follow-ups | `chip` (`overflow: "scroll"`) | `composer` or `after-message` | The default, and the tuned case for horizontal overflow. |
| Follow-ups | `list` | `after-message` | Stacked full-width rows for longer labels. |
These render but are not tuned for, so choose them knowingly rather than by
accident:
- `card` in the composer slot: wide cards in a narrow row, so labels wrap and the
row grows taller than the composer.
- `list` starters on `welcome`: works, but loses the card layout's icon and
description treatment that the welcome surface is sized for.
### Programmatic follow-ups
Host code can show follow-ups without an agent round trip:
```ts
controller.setFollowUpSuggestions([
"Track my order",
{ label: "Talk to a human", prompt: "I want to talk to a person", behavior: "fill" },
]);
controller.clearFollowUpSuggestions();
```
Semantics:
- **Ephemeral.** The list is UI state only. It never enters the transcript, the
wire payload, or persisted state, so it does not survive a refresh.
- **Latest writer wins.** The call overrides chips showing now, and a
`suggest_replies` payload arriving afterwards overrides the host list.
- **Same lifecycle as agent follow-ups.** The overlay clears when the next user
message appends, or on `clearFollowUpSuggestions()` (an empty array clears
too). Agent-set chips are untouched by the clear.
- **Same pipeline.** Items render through `suggestions.followUps`, the plugin
hooks, and both the unified and legacy DOM events, with `source: "host"`.
- **Renders even when disabled.** `enabled: false` disables the tool, not the
surface, so host-set follow-ups still render.
### DOM events
Build against the unified events. They cover both surfaces and carry the full
item:
| Event | Detail |
|---|---|
| `persona:suggestion:shown` | `{ suggestions, surface, source, variant }`: normalized items, surface `"starter"` or `"followUp"`, source `"config"`, `"agent"`, or `"host"`. Fires once per distinct set. |
| `persona:suggestion:selected` | `{ suggestion, surface, source, behavior }`: fires before the action, and is cancelable with `preventDefault()`. |
The `suggestReplies` pair is frozen for back-compat. It fires only on the
follow-up surface, carries string payloads, is not cancelable, and is removed in
5.0:
| Legacy event | Detail |
|---|---|
| `persona:suggestReplies:shown` | `{ suggestions: string[] }`: fires once per distinct chip set |
| `persona:suggestReplies:selected` | `{ suggestion: string }`: fires before the chip text is sent |
Both families keep dispatching, for agent-produced and host-set follow-ups
alike.
## Dropdown Menu
A reusable dropdown menu utility for building custom menus in plugins, custom components, or host-page UI that matches the widget's theme.
### Basic usage
```ts
import { createDropdownMenu } from '@runtypelabs/persona';
const button = document.querySelector('#my-button')!;
const wrapper = document.createElement('div');
wrapper.style.position = 'relative';
button.parentElement!.insertBefore(wrapper, button);
wrapper.appendChild(button);
const dropdown = createDropdownMenu({
items: [
{ id: 'edit', label: 'Edit', icon: 'pencil' },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' },
{ id: 'delete', label: 'Delete', icon: 'trash-2', destructive: true, dividerBefore: true },
],
onSelect: (id) => console.log('Selected:', id),
anchor: wrapper,
position: 'bottom-left', // or 'bottom-right'
});
wrapper.appendChild(dropdown.element);
button.addEventListener('click', () => dropdown.toggle());
```
### Escaping overflow containers
When the anchor is inside a container with `overflow: hidden`, use the `portal` option to render the menu at a higher DOM level while keeping CSS variable inheritance:
```ts
const dropdown = createDropdownMenu({
items: [...],
onSelect: (id) => { /* handle */ },
anchor: myButton,
position: 'bottom-right',
portal: document.querySelector('[data-persona-root]')!,
});
// No need to append: portal mode appends automatically
```
### Header dropdown menus
Trailing header actions support built-in dropdown menus via the `menuItems` property:
```ts
createAgentExperience(mount, {
layout: {
header: {
layout: 'minimal',
trailingActions: [
{
id: 'options',
icon: 'chevron-down',
ariaLabel: 'Options',
menuItems: [
{ id: 'settings', label: 'Settings', icon: 'settings' },
{ id: 'help', label: 'Help', icon: 'help-circle' },
{ id: 'logout', label: 'Log out', icon: 'log-out', destructive: true, dividerBefore: true },
]
}
],
onAction: (actionId) => {
// Receives the menu item id when selected
console.log('Action:', actionId);
}
}
}
});
```
### Theming
Dropdown menus are styled via CSS custom properties with semantic fallbacks:
| Variable | Description | Fallback |
|----------|-------------|----------|
| `--persona-dropdown-bg` | Menu background | `--persona-surface` |
| `--persona-dropdown-border` | Menu border | `--persona-border` |
| `--persona-dropdown-radius` | Border radius | `0.625rem` |
| `--persona-dropdown-shadow` | Box shadow | `0 4px 16px rgba(0,0,0,0.12)` |
| `--persona-dropdown-item-color` | Item text color | `--persona-text` |
| `--persona-dropdown-item-hover-bg` | Item hover background | `--persona-container` |
| `--persona-dropdown-destructive-color` | Destructive item color | `#ef4444` |
Artifact toolbar copy menu tokens (`copyMenuBackground`, `copyMenuBorder`, etc.) also set the dropdown variables as defaults, so dropdown theming works with the existing artifact token config.
### Type definitions
```ts
interface DropdownMenuItem {
id: string;
label: string;
icon?: string; // Lucide icon name
destructive?: boolean;
dividerBefore?: boolean;
}
interface CreateDropdownOptions {
items: DropdownMenuItem[];
onSelect: (id: string) => void;
anchor: HTMLElement;
position?: 'bottom-left' | 'bottom-right';
portal?: HTMLElement;
}
interface DropdownMenuHandle {
element: HTMLElement;
show: () => void;
hide: () => void;
toggle: () => void;
destroy: () => void;
}
```
## Button Utilities
Composable button factories for building custom toolbars, actions, and toggle controls that match the widget's theme.
### Icon button
```ts
import { createIconButton } from '@runtypelabs/persona';
const refreshBtn = createIconButton({
icon: 'refresh-cw',
label: 'Refresh',
onClick: () => handleRefresh(),
});
toolbar.appendChild(refreshBtn);
```
### Label button
```ts
import { createLabelButton } from '@runtypelabs/persona';
const copyBtn = createLabelButton({
icon: 'copy',
label: 'Copy',
variant: 'default', // 'default' | 'primary' | 'destructive' | 'ghost'
onClick: () => copyToClipboard(),
});
```
### Toggle group
```ts
import { createToggleGroup } from '@runtypelabs/persona';
const toggle = createToggleGroup({
items: [
{ id: 'preview', icon: 'eye', label: 'Preview' },
{ id: 'source', icon: 'code-2', label: 'Source' },
],
selectedId: 'preview',
onSelect: (id) => setViewMode(id),
});
toolbar.appendChild(toggle.element);
// Programmatic update (does not fire onSelect)
toggle.setSelected('source');
```
### Theming
All button utilities are styled via CSS custom properties:
| Variable | Component | Description | Fallback |
|----------|-----------|-------------|----------|
| `--persona-icon-btn-bg` | Icon button | Background | `--persona-surface` |
| `--persona-icon-btn-border` | Icon button | Border | `--persona-border` |
| `--persona-icon-btn-color` | Icon button | Icon color | `--persona-text` |
| `--persona-icon-btn-hover-bg` | Icon button | Hover background | `--persona-container` |
| `--persona-icon-btn-hover-color` | Icon button | Hover color | `inherit` |
| `--persona-icon-btn-active-bg` | Icon button | Pressed/active bg | `--persona-container` |
| `--persona-icon-btn-active-border` | Icon button | Pressed/active border | `--persona-border` |
| `--persona-icon-btn-padding` | Icon button | Padding | `0.25rem` |
| `--persona-icon-btn-radius` | Icon button | Border radius | `--persona-radius-md` |
| `--persona-label-btn-bg` | Label button | Background | `--persona-surface` |
| `--persona-label-btn-border` | Label button | Border | `--persona-border` |
| `--persona-label-btn-color` | Label button | Text color | `--persona-text` |
| `--persona-label-btn-hover-bg` | Label button | Hover background | `--persona-container` |
| `--persona-label-btn-font-size` | Label button | Font size | `0.75rem` |
| `--persona-toggle-group-gap` | Toggle group | Gap between items | `0` |
| `--persona-toggle-group-radius` | Toggle group | First/last radius | `--persona-icon-btn-radius` |
These can also be set via the widget config's theme token system:
```ts
createAgentExperience(mount, {
darkTheme: {
components: {
iconButton: {
background: 'transparent',
border: 'none',
hoverBackground: '#2B2B2B',
hoverColor: '#E5E5E5',
},
toggleGroup: {
gap: '0',
borderRadius: '8px',
},
}
}
});
```
## Runtype adapter
This package ships with a Runtype adapter by default. The proxy handles all flow configuration, keeping the client lightweight and flexible.
**Flow configuration happens server-side** - you have three options:
1. **Use default flow** - The proxy includes a basic streaming chat flow out of the box
2. **Reference a Runtype flow ID** - Configure flows in your Runtype dashboard and reference them by ID
3. **Define custom flows** - Build flow configurations directly in the proxy
The client simply sends messages to the proxy, which constructs the full Runtype payload. This architecture allows you to:
- Change models/prompts without redeploying the widget
- A/B test different flows server-side
- Enforce security and cost controls centrally
- Support multiple flows for different use cases
## Dynamic Forms (Recommended)
For rendering AI-generated forms, use the **component middleware** approach with the `DynamicForm` component. This allows the AI to create contextually appropriate forms with any fields:
```typescript
import { componentRegistry, initAgentWidget } from "@runtypelabs/persona";
import { DynamicForm } from "./components"; // Your DynamicForm component
// Register the component
componentRegistry.register("DynamicForm", DynamicForm);
initAgentWidget({
target: "#app",
config: {
apiUrl: "/api/chat/dispatch-component",
parserType: "json",
enableComponentStreaming: true,
formEndpoint: "/form",
// Optional: customize form appearance
formStyles: {
borderRadius: "16px",
borderWidth: "1px",
borderColor: "#e5e7eb",
padding: "1.5rem",
titleFontSize: "1.25rem",
buttonBorderRadius: "9999px"
}
}
});
```
The AI responds with JSON like:
```json
{
"text": "Please fill out this form:",
"component": "DynamicForm",
"props": {
"title": "Contact Us",
"fields": [
{ "label": "Name", "type": "text", "required": true },
{ "label": "Email", "type": "email", "required": true }
],
"submit_text": "Submit"
}
}
```
**Demos and reference:**
- [`apps/web/dynamic-components.html`](../../../apps/web/dynamic-components.html): primary demo with three DynamicForm layout variants (Compact / Spacious / Branded) plus smaller ProductCard, SimpleChart, StatusBadge, and InfoCard directives.
- [`apps/web/dynamic-form-fields.html`](../../../apps/web/dynamic-form-fields.html): every field type, layout width, helper-text, required marking, and sensitive-masking pattern in one page.
- [`docs/DYNAMIC-FORMS.md`](./DYNAMIC-FORMS.md): full reference: field schema, `formStyles` tokens, layout patterns, recipes, and how to extend the example component (new field types, sections, conditional fields).
The shipped `DynamicForm` is an **example** in [`apps/web/src/components.ts`](../../../apps/web/src/components.ts): copy it into your app and customize. It supports text/email/tel/url/number/date/time/textarea, half-width pairs, auto-grow textareas, required-asterisk marking, inline validation, a success recap card with sensitive-field masking, and edit-after-submit. See [DYNAMIC-FORMS.md](./DYNAMIC-FORMS.md) for the full surface area.
## Directive postprocessor (Deprecated)
> **⚠️ Deprecated:** The `directivePostprocessor` approach is deprecated in favor of the component middleware with `DynamicForm`. The old approach only supports predefined form templates ("init" and "followup"), while the new approach allows AI-generated forms with any fields.
`directivePostprocessor` looks for either `` tokens or
`{"component":"form","type":"init"}` blocks and swaps them for placeholders that the widget upgrades into interactive UI. This approach is limited to the predefined form templates in `formDefinitions`.
---
# Script Tag Installation & Framework Integration
> Part of the [@runtypelabs/persona](../README.md) documentation.
## Script tag installation
The widget can be installed via a simple script tag, perfect for platforms where you can't compile custom code. There are two methods:
### Method 1: Automatic installer (recommended)
The easiest way is to use the automatic installer script. It handles loading CSS and JavaScript, then initializes the widget automatically:
```html
```
**Installer options:**
- `version` - Asset version to pin. On the first-party CDN (`cdn.runtype.com`) this stays first-party and resolves to `/persona//`; on other origins it switches asset loading to npm-CDN URLs (`"latest"` when only `cdn` is set). **Prefer pinning via the installer script URL itself** (e.g. `https://cdn.runtype.com/persona/4.13.0/install.global.js`) — the sibling assets follow the installer's own directory automatically.
- `cdn` - npm CDN provider: `"jsdelivr"` or `"unpkg"`. By default the installer loads `widget.css` / `index.global.js` / `launcher.global.js` from the same directory it was itself served from, so self-hosted and first-party CDN copies work with no extra config (and satisfy the same CSP that allowed the installer). Setting `cdn` opts into npm-CDN URLs instead; jsDelivr `@latest` is the fallback when the installer's own URL can't be determined (e.g. bundled/module usage). The installer logs a `console.warn` whenever resolved assets would load from a different origin than the installer itself, since a strict CSP (e.g. Runtype-hosted apps) silently blocks cross-origin assets.
- `cssUrl` - Custom CSS URL (overrides CDN)
- `jsUrl` - Custom JS URL (overrides CDN)
- `target` - CSS selector or element where widget mounts (default: `"body"`)
- `config` - Widget configuration object (see Configuration reference)
- `autoInit` - Automatically initialize after loading (default: `true`)
- `clientToken` - Client token for authentication (alternative to proxy `apiUrl`)
- `flowId` - Flow ID for client token authentication
- `apiUrl` - API URL for the chat endpoint (can also be set inside `config`)
- `previewQueryParam` - Query parameter key that gates widget loading; widget only loads when the parameter is present and truthy
- `useShadowDom` - Use Shadow DOM for style isolation (default: `false`)
- `windowKey` - If provided, stores the widget handle on `window[windowKey]` for programmatic access
- `onScriptLoad` - Fired as soon as the installer script executes, before it loads or gates anything (diagnostics / timing); signature: `({ version }) => void`
- `onLauncherShown` - Fired when the floating launcher is painted on the page (page-load time: for "widget appeared" analytics); signature: `({ deferred, element? }) => void`
- `onChatReady` - Fired when the widget is initialized and its controller API is callable (after first open in a deferred install); signature: `(handle) => void`
- `onError` - Fired when a load step fails (`css` / `bundle` / `init`), so ad-blocked / timed-out installs don't fail silently; signature: `({ phase, error }) => void`
**Example with version pinning** (pin the installer script URL — sibling assets follow its directory, so no `version` key is needed):
```html
```
### Programmatic access with the installer
The installer is fully asynchronous (it waits for framework hydration, then loads CSS and JS). To interact with the widget after it initializes, use one of these approaches:
**`onChatReady` callback**: best when config and access logic live in the same script:
```html
```
**`persona:chat-ready` event**: best for decoupled integration (e.g. tag managers, separate scripts):
```html
```
**`windowKey`**: stores the handle on `window[windowKey]` for persistent global access. Combine with `onChatReady` or `persona:chat-ready` to know when it's available:
```html
```
### Method 2: Manual installation
For more control, manually load CSS and JavaScript:
```html
```
**CDN options:**
- **jsDelivr** (recommended): `https://cdn.jsdelivr.net/npm/@runtypelabs/persona@VERSION/dist/`
- **unpkg**: `https://unpkg.com/@runtypelabs/persona@VERSION/dist/`
Replace `VERSION` with `latest` for auto-updates, or a specific version like `0.1.0` for stability.
**Available files:**
- `widget.css` - Stylesheet (required)
- `index.global.js` - Widget JavaScript (IIFE format)
- `install.global.js` - Automatic installer script
> **Do NOT load `dist/index.js` (the ESM build) directly in a browser.** It keeps dependencies like `marked` as bare import specifiers, so `
```
**Using with automatic installer script:**
```html
```
**Alternative: Using `streamParser` with installer script:**
If you need a custom parser, you can still use `streamParser`:
```html
```
Alternatively, you can set it after the script loads:
```html
```
**Custom JSON parser example:**
```javascript
const jsonParser = () => {
let extractedText = null;
return {
// Extract text field from JSON as it streams in
// Return null if not JSON or text not available yet
processChunk(accumulatedContent) {
const trimmed = accumulatedContent.trim();
// Return null if not JSON format
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return null;
}
const match = accumulatedContent.match(/"text"\s*:\s*"([^"]*(?:\\.[^"]*)*)"/);
if (match) {
extractedText = match[1].replace(/\\"/g, '"').replace(/\\n/g, '\n');
return extractedText;
}
return null;
},
getExtractedText() {
return extractedText;
}
};
};
initAgentWidget({
target: '#chat-root',
config: {
apiUrl: '/api/chat/dispatch',
streamParser: jsonParser,
postprocessMessage: ({ text, raw }) => {
// raw contains the structured payload (JSON, XML, etc.)
return markdownPostprocessor(text);
}
}
});
```
**Custom XML parser example:**
```javascript
const xmlParser = () => {
let extractedText = null;
return {
processChunk(accumulatedContent) {
// Return null if not XML format
if (!accumulatedContent.trim().startsWith('<')) {
return null;
}
// Extract text from ... tags
const match = accumulatedContent.match(/]*>([\s\S]*?)<\/text>/);
if (match) {
extractedText = match[1];
return extractedText;
}
return null;
},
getExtractedText() {
return extractedText;
}
};
};
```
**Parser interface:**
```typescript
interface AgentWidgetStreamParser {
// Process a chunk and return extracted text (if available)
// Return null if the content doesn't match this parser's format or text is not yet available
processChunk(accumulatedContent: string): Promise | string | null;
// Get the currently extracted text (may be partial)
getExtractedText(): string | null;
// Optional cleanup when parsing is complete
close?(): Promise | void;
}
```
The parser's `processChunk` method is called for each chunk. If the content matches your parser's format, return the extracted text and the raw payload. Built-in parsers already do this, so action handlers and middleware can read the original structured content without re-implementing a parser. Return `null` if the chunk isn't ready yet: the widget will keep waiting or fall back to plain text.
---
# Message Injection API
The Persona widget supports programmatic message injection, allowing you to add messages to the conversation from external sources such as tool call responses, system events, or third-party integrations.
## Overview
Message injection is useful for:
- **Tool call responses**: Inject search results, product listings, or API responses
- **System context**: Add context about user behavior (e.g., "User is viewing product X")
- **External integrations**: Push messages from CRM systems, analytics, or other services
- **Dual-content messages**: Show rich content to users while sending concise summaries to the LLM
## API Reference
### `injectMessage(options)`
The primary method for injecting messages into the conversation.
```typescript
interface InjectMessageOptions {
role: 'user' | 'assistant' | 'system';
content: string; // User-facing (UI display)
llmContent?: string; // LLM-facing (defaults to content)
contentParts?: ContentPart[]; // Multi-modal (highest priority for LLM)
rawContent?: string; // Raw structured payload (e.g. directive JSON)
id?: string; // Custom message ID
createdAt?: string; // ISO timestamp
sequence?: number; // Sort order
streaming?: boolean; // For streaming updates
}
```
### Convenience Methods
```typescript
// Assistant messages (role: 'assistant')
widgetHandle.injectAssistantMessage(options);
// User messages (role: 'user')
widgetHandle.injectUserMessage(options);
// System messages (role: 'system')
widgetHandle.injectSystemMessage(options);
// Component directives (assistant message that renders a registered component)
widgetHandle.injectComponentDirective({
component: 'DynamicForm',
props: { title: 'Book a demo', fields: [/* ... */] },
text: 'Share your details to book a demo.',
llmContent: '[Showed booking form]' // optional, redacted version for the LLM
});
```
## Content Priority
When building the API payload, content is resolved in this priority order:
1. `contentParts` - Multi-modal content (images, files)
2. `llmContent` - Explicit LLM-specific content
3. `rawContent` - Backward compatibility for structured parsers
4. `content` - Display content as fallback
## Examples
### Basic Message Injection
```javascript
const widgetHandle = initAgentWidget({
apiUrl: 'https://api.example.com/chat'
});
// Simple assistant message
widgetHandle.injectAssistantMessage({
content: 'Here are your search results...'
});
```
### Dual-Content (User vs LLM)
Show rich content to users while sending a concise summary to the LLM:
```javascript
// User sees full product details
// LLM receives concise summary to save tokens
widgetHandle.injectAssistantMessage({
content: `**Found 3 products:**
- iPhone 15 Pro - $1,199 (SKU: IP15P-256)
- iPhone 15 - $999 (SKU: IP15-128)
- iPhone 14 - $799 (SKU: IP14-128)`,
llmContent: '[Search results: 3 iPhones found, $799-$1199]'
});
```
### System Context Injection
Inject context that guides LLM behavior without cluttering the chat:
```javascript
// Minimal display, rich context for LLM
widgetHandle.injectSystemMessage({
content: '[Context updated]',
llmContent: 'User is viewing iPhone 15 Pro product page. Cart contains 2 items totaling $45.99. User has Gold membership.'
});
```
### Sensitive Data Redaction
Show sensitive information to users while redacting it from LLM:
```javascript
// User sees their order details
// LLM only sees that an order exists
widgetHandle.injectAssistantMessage({
content: `Your order #12345:
- Card ending in 4242
- Shipping to: 123 Main St, Anytown, USA`,
llmContent: '[Order confirmation displayed to user]'
});
```
### Streaming Updates
For long-running operations, use streaming to show progress:
```javascript
const messageId = 'search-results-123';
// Initial streaming message
widgetHandle.injectAssistantMessage({
id: messageId,
content: 'Searching...',
streaming: true
});
// Update with partial results
widgetHandle.injectAssistantMessage({
id: messageId,
content: 'Found 2 results so far...',
streaming: true
});
// Final update
widgetHandle.injectAssistantMessage({
id: messageId,
content: 'Here are all 5 results...',
llmContent: '[5 search results]',
streaming: false
});
```
## Component Directive Injection
When you have a renderer registered via `componentRegistry.register(...)`, you can inject an assistant message that renders that component: exactly the same path Persona uses for streamed `{ "text": "...", "component": "...", "props": {...} }` directives.
This is useful for:
- **Previews and replays**: render a component without round-tripping to the LLM
- **Debug toggles**: add a button that injects the form/widget you want to QA
- **Local tools**: have a tool callback render a registered component instead of plain text
- **Restoring state**: rehydrate a directive after a user action without re-streaming
### Using `injectComponentDirective` (recommended)
```javascript
import { initAgentWidget, componentRegistry } from '@runtypelabs/persona';
import { DynamicForm } from './components';
componentRegistry.register('DynamicForm', DynamicForm);
const widget = initAgentWidget({
target: 'body',
config: { apiUrl: '/api/chat/dispatch', parserType: 'json' }
});
widget.injectComponentDirective({
component: 'DynamicForm',
props: {
title: 'Book a demo',
fields: [
{ label: 'Name', type: 'text', required: true },
{ label: 'Email', type: 'email', required: true }
],
submit_text: 'Request meeting'
},
text: 'Share your details to book a demo.',
llmContent: '[Showed booking form]'
});
```
The helper builds the canonical directive JSON (`{ text, component, props }`), sets `content` to `text`, sets `rawContent` to the JSON, and forwards `llmContent` so the LLM sees a redacted summary on subsequent turns instead of the full directive.
### Using `rawContent` directly
If you already have a serialized directive (e.g. cached from a previous response), set it on `rawContent` and the directive renderer will pick it up:
```javascript
const directive = JSON.stringify({
text: 'Booking form',
component: 'DynamicForm',
props: { /* ... */ }
});
widget.injectAssistantMessage({
content: 'Booking form', // bubble copy
rawContent: directive, // makes the directive renderable
llmContent: '[Showed booking form]'
});
```
`hasComponentDirective` and `extractComponentDirectiveFromMessage` look at `rawContent` first; if it's missing, they fall back to parsing `content` when it looks like JSON. So either of these forms also works:
```javascript
// Pass the directive JSON via rawContent (preferred: keeps `content` clean)
widget.injectAssistantMessage({
content: 'Booking form',
rawContent: JSON.stringify({ text: 'Booking form', component: 'DynamicForm', props: {} })
});
// Or pass the directive JSON via content alone (the fallback path)
widget.injectAssistantMessage({
content: JSON.stringify({ text: 'Booking form', component: 'DynamicForm', props: {} })
});
```
The first form is preferred because `content` stays human-readable for plain-text renderers, accessibility tools, and copy actions.
## Migration from `injectTestMessage`
The previous `injectTestMessage` method is deprecated. Migrate using:
**Before:**
```javascript
widgetHandle.injectTestMessage({
type: 'message',
message: {
id: 'msg-123',
role: 'assistant',
content: 'Hello!',
createdAt: new Date().toISOString()
}
});
```
**After:**
```javascript
widgetHandle.injectAssistantMessage({
content: 'Hello!'
});
```
## Best Practices
1. **Use llmContent for token efficiency**: When displaying rich content (tables, lists, detailed info), provide a concise summary in `llmContent`
2. **Redact sensitive data**: Never send PII, payment details, or credentials to the LLM - use `llmContent` for redacted summaries
3. **Use appropriate roles**:
- `assistant` for responses and information display
- `user` for simulating user actions (use sparingly)
- `system` for context injection that should influence LLM behavior
4. **Leverage streaming**: For operations that take time, use streaming updates to keep users informed
5. **Consistent message IDs**: When updating messages, always use the same ID to avoid duplicates
---
# Dynamic Forms
The Persona widget renders AI-generated forms when the assistant emits a JSON
component directive of the form:
```json
{
"text": "Please fill out this form:",
"component": "DynamicForm",
"props": {
"title": "Contact us",
"fields": [
{ "label": "Name", "type": "text", "required": true },
{ "label": "Email", "type": "email", "required": true }
],
"submit_text": "Submit"
}
}
```
The form is rendered by a `DynamicForm` component that ships with the example
app: **not** the widget core. You're expected to copy and customize it. This
doc is the reference for what that component supports out of the box and how
to extend it.
> **Where to find it:** [`apps/web/src/components.ts`](../../../apps/web/src/components.ts) (look for `export const DynamicForm`).
## Live demos
- [`/dynamic-components.html`](../../../apps/web/dynamic-components.html): primary demo, plus three layout variants (Compact, Spacious, Branded) and additional component directive examples.
- [`/dynamic-form-fields.html`](../../../apps/web/dynamic-form-fields.html): field-type reference: every supported `type`, `width: "half"` pairing, helper text, required marking, and sensitive masking.
## Wiring it up
```ts
import { initAgentWidget, componentRegistry } from "@runtypelabs/persona";
import { DynamicForm } from "./components";
componentRegistry.register("DynamicForm", DynamicForm);
initAgentWidget({
target: "#app",
config: {
apiUrl: "/api/chat/dispatch",
parserType: "json",
enableComponentStreaming: true,
formEndpoint: "/form", // POST target on submit
wrapComponentDirectiveInBubble: false // optional: see below
}
});
```
Because `DynamicForm` renders its own card chrome (border, padding, shadow),
set `wrapComponentDirectiveInBubble: false` to suppress Persona's default
bubble wrap and avoid a card-on-card look.
## Field reference
Each entry in `props.fields` is a [`FormField`](../../../apps/web/src/components.ts):
| Property | Type | Notes |
|---------------|--------------------------------------------------------------------------------|----------------------------------------------------------------------------------------|
| `label` | `string` | Required. Rendered as the visible label and used to derive a default `name`. |
| `name` | `string?` | Form field name in the POST payload. Defaults to a slug of `label`. |
| `type` | `"text" \| "email" \| "tel" \| "url" \| "date" \| "time" \| "textarea" \| "number"` | Defaults to `"text"`. `email` and `tel` get format validation. |
| `placeholder` | `string?` | Placeholder text inside the input. |
| `required` | `boolean?` | When `true`, the label gets a red `*` and submit is blocked until populated. |
| `helper_text` | `string?` | Inline help text below the input (alias: `helperText`). |
| `sensitive` | `boolean?` | On the success recap, the value is masked to `••••` instead of shown in clear. |
| `autocomplete`| `string?` | Override the inferred [`autocomplete`](https://developer.mozilla.org/docs/Web/HTML/Attributes/autocomplete) token. |
| `width` | `"full" \| "half"` | Defaults to `"full"`. Two consecutive `"half"` fields share a row in the form grid. |
### Inferred attributes
The component infers `autocomplete` and `inputmode` from the field's
`type`, `name`, and `label` so most common patterns work without extra
props. Examples:
| You write… | Component sets… |
|-----------------------------------------------------|----------------------------------------------------------------|
| `{ "label": "Email", "type": "email" }` | `autocomplete="email"`, `inputmode="email"` |
| `{ "label": "First Name", "type": "text" }` | `autocomplete="given-name"` |
| `{ "label": "Postal Code", "type": "text" }` | `autocomplete="postal-code"`, `inputmode="numeric"` |
| `{ "label": "Phone", "type": "tel" }` | `autocomplete="tel"`, `inputmode="tel"` |
Override with `autocomplete: "off"` (or any standard token) if needed.
## Top-level form props
| Property | Type | Notes |
|-------------------|---------------------|------------------------------------------------------------------------------------------------------------------|
| `title` | `string?` | Heading at the top of the card. |
| `description` | `string?` | Subtitle below the title. |
| `fields` | `FormField[]` | The fields, in render order. |
| `submit_text` | `string?` | Label on the submit button. Defaults to `"Submit"`. (Alias: `submitText`.) |
| `helper_text` | `string?` | Below the submit button: defaults to `"Takes less than 30 seconds."` for forms with > 2 fields. Pass `""` to hide. |
| `success_title` | `string?` | Heading on the success recap card. Defaults to `"You're all set!"`. |
| `success_message` | `string?` | Body copy on the success recap. Falls back to a generic confirmation. |
| `allow_edit` | `boolean?` | When `true` (default), the success card shows an "Edit details" button that returns to the form. |
| `styles` | `DynamicFormStyles?`| Per-instance style overrides. Merged on top of `config.formStyles`. |
## Theming with `formStyles`
`formStyles` is a flat token map. Set it on the widget config for global
defaults, or pass it as `props.styles` for one-off overrides. Tokens fall
back to the existing `--persona-*` CSS variables when omitted.
```ts
initAgentWidget({
target: "#app",
config: {
apiUrl: "/api/chat/dispatch",
formStyles: {
borderRadius: "16px",
padding: "1.5rem",
titleFontSize: "1.25rem",
buttonBorderRadius: "9999px"
}
}
});
```
| Token | Default | Notes |
|------------------------|----------------------|------------------------------------------------------------------|
| `margin` | `0.5rem 0` | Outer margin around the card. |
| `borderRadius` | `14px` | Card corner radius. |
| `border` | `: ` | Full CSS shorthand. Overrides `borderWidth` + `borderColor`. |
| `borderWidth` | `1px` | Used when `border` is not set. |
| `borderColor` | theme `--persona-border` | Used when `border` is not set. |
| `padding` | `0.875rem 1rem` | Card padding. |
| `maxWidth` | `460px` | Card max-width. |
| `boxShadow` | subtle stack | Card drop shadow. |
| `titleFontSize` | `1rem` | |
| `titleFontWeight` | `700` | |
| `descriptionFontSize` | `0.8125rem` | |
| `labelFontSize` | `0.8125rem` | |
| `labelFontWeight` | `500` | |
| `inputFontSize` | `0.8125rem` | |
| `inputPadding` | `0.4375rem 0.625rem` | |
| `inputBorderRadius` | `0.5rem` | |
| `inputBorder` | `1px solid …` | Resting border (focus ring is always shown via box-shadow). |
| `buttonPadding` | `0.5rem 1rem` | |
| `buttonBorderRadius` | `0.5rem` | |
| `buttonFontSize` | `0.8125rem` | |
| `buttonFontWeight` | `600` | |
| `successAccentColor` | theme `--persona-accent` | Color used on the success card. |
| `errorColor` | `#ef4444` | Required asterisk + invalid input ring. |
| `helperFontSize` | `0.75rem` | Used by the bottom helper row and per-field helper text. |
| `successCardPadding` | `0.5rem 0.25rem` | Padding on the inner success recap card. |
## Layout patterns
### Half-width pairs
Two consecutive fields with `width: "half"` share a row. Useful for
First Name / Last Name, City / Postal Code, Card Number / CVC.
```json
{
"fields": [
{ "label": "First Name", "type": "text", "width": "half", "required": true },
{ "label": "Last Name", "type": "text", "width": "half", "required": true },
{ "label": "Email", "type": "email", "required": true }
]
}
```
A trailing single `"half"` (no partner) still spans one column. The grid
gap and column count come from `formStyles`: adjust `inputPadding` and
`labelFontSize` to keep paired fields readable on narrow widgets.
### Auto-growing textarea
Textareas start at single-input height and grow as the user types, up to
`maxHeight: 140px`. No extra props needed: just `type: "textarea"`. This
keeps long forms compact at rest without making the user feel rushed.
### Sensitive masking
Set `sensitive: true` on a field and the **success recap card** will mask
the value to `••••`. The form itself still shows the value as
typed (so the user can correct mistakes); only the post-submit summary
masks it. Useful for API keys, account numbers, phone numbers, and
similar identifiers users want kept out of plain view in confirmations.
> **Not for cardholder data.** Collecting credit-card numbers in a
> chat-widget input puts your application in **PCI DSS** scope. Use a
> vendor-hosted iframe (Stripe Elements, Adyen Drop-in, Braintree
> Hosted Fields) instead: masking on the recap is not a substitute for
> keeping PAN out of your DOM and your server payload.
## Submission
On submit, the form POSTs JSON to `config.formEndpoint` (default
`"/form"`). Validation runs first; required fields and the built-in
email/phone format checks block submission and surface errors inline.
Successful submission:
1. Saves the payload to `localStorage` keyed by the assistant message
ID, via [`user-action-store.ts`](../../../apps/web/src/user-action-store.ts).
On reload, the form re-renders into the success state instead of
re-prompting.
2. Animates to the success recap card showing the submitted values.
3. Optionally shows an "Edit details" button (`allow_edit: true`) that
restores the form.
Custom submit logic: replace the form's `submit` handler in your fork of
`DynamicForm`, or wire `formEndpoint` to a route that does additional
processing.
## Recipes
### Lead capture (booking)
```json
{
"title": "Book a demo",
"description": "Share your details and we'll follow up to confirm.",
"fields": [
{ "label": "First Name", "type": "text", "required": true, "width": "half" },
{ "label": "Last Name", "type": "text", "required": true, "width": "half" },
{ "label": "Email", "type": "email", "required": true },
{ "label": "Company", "type": "text", "width": "half" },
{ "label": "Headcount", "type": "number", "width": "half" },
{
"label": "What are you trying to solve?",
"type": "textarea",
"placeholder": "A few sentences is fine."
}
],
"submit_text": "Request meeting"
}
```
### Address
```json
{
"title": "Shipping address",
"fields": [
{ "label": "Full name", "type": "text", "required": true },
{ "label": "Street", "type": "text", "required": true },
{ "label": "City", "type": "text", "width": "half", "required": true },
{ "label": "Postal Code", "type": "text", "width": "half", "required": true },
{ "label": "Country", "type": "text", "required": true }
],
"submit_text": "Save address"
}
```
### Quick survey
```json
{
"title": "How was your demo?",
"fields": [
{
"label": "On a scale of 1–10, how likely are you to recommend us?",
"type": "number"
},
{
"label": "What stood out?",
"type": "textarea"
}
],
"submit_text": "Submit"
}
```
## Extending the component
The shipped `DynamicForm` is an **example**: fork it. Common extensions:
- **New field types** (`select`, `radio`, `checkbox`, `password`, `file`):
add a branch in the `if (inputType === "textarea") { … } else { … }`
switch in the field-rendering loop. Each branch creates the control,
applies the same focus/hover/error treatment, and pushes a `FieldHandle`
for validation and submit handling.
- **Conditional fields** (`showIf` / `hideIf`): add an `eval`-style hook
on the field schema that the component evaluates on every input event,
toggling `display` on the field's group.
- **Sections / dividers**: extend `FormField` to a discriminated union
(`{ kind: "field" | "section" | "divider" }`) and render section
headers as full-width grid rows.
- **Custom submit**: replace the `fetch(formEndpoint, …)` call with a
callback prop and wire it to your business logic.
Want any of these upstream as defaults? Open an issue with the use case.
## Programmatic preview / debug
Use [`injectComponentDirective`](./MESSAGE-INJECTION.md#component-directive-injection)
to render a form directly from host code without going through the LLM:
```ts
widget.injectComponentDirective({
component: "DynamicForm",
props: {
title: "Book a demo",
fields: [/* … */],
submit_text: "Request meeting"
},
text: "Share your details to book a demo.",
llmContent: "[Showed booking form]"
});
```
Useful for design QA, replay, debug toggles, and local tools that
should render a registered component without round-tripping the model.
---
# Code Snippet Generation API
The `generateCodeSnippet` function programmatically generates ready-to-use code snippets for embedding the widget. This is useful for building configuration tools, documentation generators, CLIs, or automated setup workflows. Prefer the server/Worker-safe `@runtypelabs/persona/codegen` subpath when you only need snippet generation; the package root also re-exports it for browser/bundler consumers already using the widget runtime.
## Basic Usage
```typescript
import { generateCodeSnippet } from '@runtypelabs/persona/codegen';
import type { CodeFormat, CodeGeneratorOptions } from '@runtypelabs/persona/codegen';
const config = {
apiUrl: '/api/chat/dispatch',
theme: {
semantic: {
colors: {
primary: 'palette.colors.primary.500',
accent: 'palette.colors.accent.600',
},
},
},
launcher: {
enabled: true,
title: 'AI Assistant',
},
};
// Generate ESM code (default)
const esmCode = generateCodeSnippet(config);
// Generate for different formats
const reactCode = generateCodeSnippet(config, 'react-component');
const scriptCode = generateCodeSnippet(config, 'script-manual');
```
## Available Formats
| Format | Description |
|--------|-------------|
| `esm` | ES Module import (default) |
| `react-component` | React component with `useEffect` |
| `react-advanced` | React component with DOM context collection and action handling |
| `script-installer` | Auto-installer script tag (JSON config only) |
| `script-manual` | Manual script tag with full control |
| `script-advanced` | Script tag with DOM context collection and action handling |
## `windowKey` Option
Pass `windowKey` in the options to emit a `windowKey` property in the generated `initAgentWidget()` call, storing the widget handle on `window[windowKey]` for programmatic access:
```typescript
const code = generateCodeSnippet(config, 'script-installer', { windowKey: 'myChat' });
```
How it works per format:
- **`script-installer`**: The `data-config` JSON includes `windowKey` alongside a nested `config` object, matching the install script's expected structure.
- **`script-manual`** and **`script-advanced`**: `windowKey` is added to the `initAgentWidget()` call options.
- **`esm`**, **`react-component`**, **`react-advanced`**: No effect: these formats return the handle directly as a variable.
## Custom Hooks
The third parameter accepts options including custom hooks that inject code into the generated snippet. Hooks can be provided as strings or functions (functions are automatically serialized via `.toString()`).
```typescript
const code = generateCodeSnippet(config, 'esm', {
hooks: {
// Custom headers function
getHeaders: async () => ({
'Authorization': `Bearer ${localStorage.getItem('token')}`
}),
// Feedback callback
onFeedback: (feedback) => {
fetch('/api/feedback', {
method: 'POST',
body: JSON.stringify(feedback)
});
},
// Copy callback
onCopy: (message) => {
console.log('Copied:', message.id);
},
// Request middleware (merged with DOM context in advanced formats)
requestMiddleware: ({ payload }) => ({
...payload,
metadata: { timestamp: Date.now() }
}),
// Custom action handlers (prepended to defaults in advanced formats)
actionHandlers: [
(action, context) => {
if (action.type === 'show_modal') {
showModal(action.payload);
return { handled: true };
}
}
],
// Custom message postprocessor. Persona still sanitizes this HTML by default
// via config.sanitize (DOMPurify); provide config.sanitize for a custom allowlist.
postprocessMessage: ({ text }) => marked.parse(text),
// Custom stream parser factory
streamParser: () => createCustomParser()
}
});
```
## Available Hooks
| Hook | Type | Description |
|------|------|-------------|
| `getHeaders` | `() => Record` | Returns custom headers for API requests |
| `onFeedback` | `(feedback) => void` | Called when user provides feedback (upvote/downvote) |
| `onCopy` | `(message) => void` | Called when user copies a message |
| `requestMiddleware` | `(ctx) => payload` | Transforms request payload before sending |
| `actionHandlers` | `Array<(action, ctx) => result>` | Custom action handlers for structured responses |
| `actionParsers` | `Array<(ctx) => action>` | Custom parsers to extract actions from messages |
| `postprocessMessage` | `(ctx) => string` | Custom message postprocessor (overrides default) |
| `contextProviders` | `Array<() => context>` | Additional context providers for requests |
| `streamParser` | `() => StreamParser` | Custom stream parser factory |
## Hook Format Notes
- **String hooks**: Passed through directly into generated code
- **Function hooks**: Serialized via `.toString()` - must be self-contained (no closures)
- **Advanced formats** (`react-advanced`, `script-advanced`): Custom `actionHandlers` are prepended to built-in handlers; `requestMiddleware` is merged with DOM context collection
```typescript
// Both approaches work:
// As string
generateCodeSnippet(config, 'esm', {
hooks: {
getHeaders: "async () => ({ 'X-Custom': 'value' })"
}
});
// As function (auto-serialized)
generateCodeSnippet(config, 'esm', {
hooks: {
getHeaders: async () => ({ 'X-Custom': 'value' })
}
});
```
## TypeScript Types
```typescript
import type {
CodeFormat,
CodeGeneratorHooks,
CodeGeneratorOptions
} from '@runtypelabs/persona/codegen';
// CodeFormat options
type CodeFormat =
| 'esm'
| 'react-component'
| 'react-advanced'
| 'script-installer'
| 'script-manual'
| 'script-advanced';
// Hook definitions (string or function)
type CodeGeneratorHooks = {
getHeaders?: string | (() => Record | Promise>);
onFeedback?: string | ((feedback: { type: string; messageId: string; message: unknown }) => void);
onCopy?: string | ((message: unknown) => void);
requestMiddleware?: string | ((context: { payload: unknown; config: unknown }) => unknown);
actionHandlers?: string | Array<(action: unknown, context: unknown) => unknown>;
actionParsers?: string | Array<(context: unknown) => unknown>;
postprocessMessage?: string | ((context: { text: string; message?: unknown; streaming?: boolean; raw?: string }) => string);
contextProviders?: string | Array<() => unknown>;
streamParser?: string | (() => unknown);
};
// Options object
type CodeGeneratorOptions = {
hooks?: CodeGeneratorHooks;
includeHookComments?: boolean;
windowKey?: string; // Emit windowKey in generated initAgentWidget call (script formats only)
target?: string; // CSS selector mount target; defaults to 'body'
};
```
## Example: Building a Configuration UI
```typescript
import { generateCodeSnippet, type CodeFormat } from '@runtypelabs/persona/codegen';
// User configures widget in a UI (palette swatches → semantic roles)
const userConfig = {
apiUrl: form.apiUrl.value,
theme: {
palette: {
colors: {
primary: { 500: colorPicker.primary.value },
accent: { 600: colorPicker.accent.value },
},
},
semantic: {
colors: {
primary: 'palette.colors.primary.500',
accent: 'palette.colors.accent.600',
},
},
},
launcher: {
enabled: launcherToggle.checked,
title: form.launcherTitle.value,
},
};
// User selects output format
const format: CodeFormat = formatSelect.value as CodeFormat;
// User optionally adds custom hooks
const hooks = {
getHeaders: headerEditor.value || undefined,
onFeedback: feedbackEditor.value || undefined,
};
// Generate code snippet
const code = generateCodeSnippet(userConfig, format, { hooks, target: '#chat-root' });
// Display in code editor
codeEditor.setValue(code);
```
---
# Widget Theme & Configuration Reference
This document provides definitions of all themable configuration options for Persona Widget v2.0.
## Theme Architecture
The v2 theme system uses a **three-layer token architecture**:
```
┌─────────────────────────────────────────────────────────────┐
│ COMPONENT TOKENS │
│ button.background, launcher.size, panel.borderRadius │
├─────────────────────────────────────────────────────────────┤
│ SEMANTIC TOKENS │
│ colors.primary, colors.text, spacing.md, typography.base │
├─────────────────────────────────────────────────────────────┤
│ BASE TOKENS │
│ palette.colors.blue.500, palette.spacing.4, palette.radius │
└─────────────────────────────────────────────────────────────┘
```
- **Palette**: Raw design values (color scales, spacing, typography, shadows, radii)
- **Semantic**: Intent-based tokens that reference palette values (e.g., `primary`, `surface`, `text`)
- **Components**: Component-specific tokens that reference semantic or palette values
Token references are resolved at runtime:
```
semantic.colors.primary → palette.colors.primary.500 → #171717
```
## Quick Start
### Simple Color Override
```typescript
initAgentWidget({
target: '#chat',
config: {
theme: {
palette: {
colors: {
primary: { 500: '#7c3aed', 600: '#6d28d9' }
}
}
}
}
});
```
### Using the Theme API
```typescript
import { createTheme, brandPlugin, accessibilityPlugin } from '@runtypelabs/persona';
const theme = createTheme({
palette: {
colors: {
primary: { 500: '#7c3aed' }
}
},
semantic: {
colors: {
primary: 'palette.colors.primary.500',
surface: 'palette.colors.gray.50'
}
}
}, {
plugins: [
accessibilityPlugin(),
brandPlugin({ colors: { primary: '#7c3aed' } })
]
});
initAgentWidget({
config: { theme }
});
```
### Flat v1 themes (removed)
`config.theme` / `config.darkTheme` must be **`DeepPartial`** (`palette` / `semantic` / `components`). The old flat v1 object shape is **not** supported: there is no runtime migration and no `migrateV1Theme` helper. Port themes to the token tree (see **Breaking Changes from v1** below).
---
## Dark Mode Support
### Configuration Options
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `theme` | `DeepPartial` | (light defaults) | Theme tokens for light mode |
| `colorScheme` | `'light' \| 'dark' \| 'auto'` | `'light'` | Color scheme mode |
### Color Scheme Modes
- **`'light'`** (default): Always use light palette
- **`'dark'`**: Always use dark palette (inverted grays)
- **`'auto'`**: Detect from page settings and switch automatically
### Auto Detection Order
When `colorScheme: 'auto'`, the widget detects dark mode by:
1. Checking if `` has `dark` class (e.g., ``)
2. Falling back to `prefers-color-scheme: dark` media query
The widget automatically updates when:
- The `dark` class is added/removed from ``
- System color scheme preference changes
### Usage Examples
**Auto-detection with custom colors:**
```typescript
initAgentWidget({
target: '#chat',
config: {
colorScheme: 'auto',
theme: {
palette: {
colors: {
primary: { 500: '#6366f1', 600: '#4f46e5' }
}
}
}
}
});
```
**Runtime theme switching:**
```typescript
const controller = initAgentWidget({
target: '#chat',
config: { colorScheme: 'auto' }
});
// Switch to forced dark mode
controller.update({ colorScheme: 'dark' });
// Switch back to auto-detection
controller.update({ colorScheme: 'auto' });
```
---
## Palette Tokens (`theme.palette.*`)
### Color Scales (`palette.colors.*`)
Each color has shades from 50 (lightest) to 950 (darkest):
| Scale | Colors |
|-------|--------|
| `primary` | Main brand color (default: blue) |
| `secondary` | Secondary color (default: purple) |
| `accent` | Accent color (default: cyan) |
| `gray` | Neutral grays |
| `success` | Success states (default: green) |
| `warning` | Warning states (default: yellow) |
| `error` | Error states (default: red) |
```typescript
palette: {
colors: {
primary: {
50: '#ffffff', 100: '#f5f5f5', 200: '#d4d4d4',
300: '#a3a3a3', 400: '#737373', 500: '#171717',
600: '#0f0f0f', 700: '#0a0a0a', 800: '#050505',
900: '#030303', 950: '#000000'
}
}
}
```
### Spacing (`palette.spacing.*`)
| Key | Value |
|-----|-------|
| `0` | `0px` |
| `1` | `0.25rem` |
| `2` | `0.5rem` |
| `3` | `0.75rem` |
| `4` | `1rem` |
| `6` | `1.5rem` |
| `8` | `2rem` |
| `12` | `3rem` |
### Typography (`palette.typography.*`)
| Key | Values |
|-----|--------|
| `fontFamily` | `sans`, `serif`, `mono` |
| `fontSize` | `xs` (0.75rem), `sm` (0.875rem), `base` (1rem), `lg`, `xl`, `2xl`, `3xl`, `4xl` |
| `fontWeight` | `normal` (400), `medium` (500), `semibold` (600), `bold` (700) |
| `lineHeight` | `tight` (1.25), `normal` (1.5), `relaxed` (1.625) |
### Shadows (`palette.shadows.*`)
| Key | Description |
|-----|-------------|
| `none` | No shadow |
| `sm` | Subtle shadow |
| `md` | Medium shadow |
| `lg` | Large shadow |
| `xl` | Extra-large shadow |
| `2xl` | Maximum shadow |
### Radius (`palette.radius.*`)
| Key | Value |
|-----|-------|
| `none` | `0px` |
| `sm` | `0.125rem` |
| `md` | `0.375rem` |
| `lg` | `0.5rem` |
| `xl` | `0.75rem` |
| `2xl` | `1rem` |
| `full` | `9999px` |
---
## Semantic Tokens (`theme.semantic.*`)
Semantic tokens provide intent-based naming that references palette values.
### Colors (`semantic.colors.*`)
| Token | Default Reference | Description |
|-------|-------------------|-------------|
| `primary` | `palette.colors.primary.500` | Primary brand color |
| `secondary` | `palette.colors.gray.500` | Secondary color |
| `accent` | `palette.colors.primary.600` | Accent/interactive color |
| `surface` | `palette.colors.gray.50` | Panel/card backgrounds |
| `background` | `palette.colors.gray.50` | Page background |
| `container` | `palette.colors.gray.100` | Container backgrounds |
| `text` | `palette.colors.gray.900` | Primary text |
| `textMuted` | `palette.colors.gray.500` | Muted/secondary text |
| `textInverse` | `palette.colors.gray.50` | Text on dark backgrounds |
| `border` | `palette.colors.gray.200` | Default border color |
| `divider` | `palette.colors.gray.200` | Divider lines |
### Interactive States (`semantic.colors.interactive.*`)
| Token | Default Reference |
|-------|-------------------|
| `default` | `palette.colors.primary.500` |
| `hover` | `palette.colors.primary.600` |
| `focus` | `palette.colors.primary.700` |
| `active` | `palette.colors.primary.800` |
| `disabled` | `palette.colors.gray.300` |
### Feedback Colors (`semantic.colors.feedback.*`)
| Token | Default Reference |
|-------|-------------------|
| `success` | `palette.colors.success.500` |
| `warning` | `palette.colors.warning.500` |
| `error` | `palette.colors.error.500` |
| `info` | `palette.colors.primary.500` |
### Spacing (`semantic.spacing.*`)
| Token | Default Reference |
|-------|-------------------|
| `xs` | `palette.spacing.1` (0.25rem) |
| `sm` | `palette.spacing.2` (0.5rem) |
| `md` | `palette.spacing.4` (1rem) |
| `lg` | `palette.spacing.6` (1.5rem) |
| `xl` | `palette.spacing.8` (2rem) |
| `2xl` | `palette.spacing.10` (2.5rem) |
---
## Component Tokens (`theme.components.*`)
### Button (`components.button.*`)
| Variant | Properties |
|---------|-----------|
| `primary` | `background`, `foreground`, `borderRadius`, `padding` |
| `secondary` | `background`, `foreground`, `borderRadius`, `padding` |
| `ghost` | `background`, `foreground`, `borderRadius`, `padding`, `hoverBackground` |
The **`ghost`** variant styles the composer's transparent icon buttons — the
attachment (`📎`) and mention/"add context" (`@`) affordances. It maps to these
convenience CSS variables (consumed by `.persona-attachment-button` /
`.persona-mention-button`):
| Variable | Token | Default |
|----------|-------|---------|
| `--persona-button-ghost-bg` | `ghost.background` | `transparent` |
| `--persona-button-ghost-fg` | `ghost.foreground` | `semantic.colors.text` |
| `--persona-button-ghost-radius` | `ghost.borderRadius` | `palette.radius.md` |
| `--persona-button-ghost-hover-bg` | `ghost.hoverBackground` | `rgba(0, 0, 0, 0.05)` |
Restyle both buttons at once via `theme.components.button.ghost.*` (no need to
touch `--persona-primary`). Icon and tooltip text remain per-feature config
(`contextMentions.buttonIconName/buttonTooltipText`,
`attachments.buttonIconName/buttonTooltipText`).
### Input (`components.input.*`)
| Token | Default Reference |
|-------|-------------------|
| `background` | `semantic.colors.surface` |
| `placeholder` | `semantic.colors.textMuted` |
| `focus.border` | `semantic.colors.interactive.focus` |
| `focus.ring` | `semantic.colors.interactive.focus` |
### Launcher (`components.launcher.*`)
| Token | Default |
|-------|---------|
| `size` | `60px` |
| `iconSize` | `28px` |
| `borderRadius` | `palette.radius.full` |
| `shadow` | `palette.shadows.lg` |
### Panel (`components.panel.*`)
| Token | Default |
|-------|---------|
| `width` | `min(440px, calc(100vw - 24px))` |
| `maxWidth` | `440px` |
| `height` | `600px` |
| `maxHeight` | `calc(100vh - 80px)` |
| `borderRadius` | `palette.radius.xl` |
| `shadow` | `palette.shadows.xl` |
| `inset` | `16px` |
| `canvasBackground` | `transparent` |
`inset` and `canvasBackground` take effect only when the panel renders as a detached card, i.e. when you set `launcher.detachedPanel: true` or `artifacts.layout.paneAppearance: "detached"`. On a flush panel they are inert. `inset` is the gap between the detached card and the edges of the region it occupies, so the canvas behind it shows on all four sides.
In an inline embed, `paneAppearance: "detached"` insets the whole split from the container edges on all sides once an artifact opens (the chat is flush until then). `launcher.detachedPanel: true` is the way to keep the widget inset even when no artifact is open. The only difference is when the margin exists: on artifact open versus always.
`canvasBackground` fills that revealed region in docked and inline embed modes; it defaults to `transparent`. In sidebar (floating) mode it is a no-op: the gaps must stay click-through for the host page, so the host page shows through the gap the same way a floating panel does, and no canvas can paint over it. When it does apply, set it to the semantic background so the inset reads on any host page, even a solid white one:
```js
launcher: { mountMode: "docked", detachedPanel: true },
theme: { components: { panel: { inset: "24px", canvasBackground: "semantic.colors.background" } } }
```
The existing `borderRadius`, `shadow`, and `border` tokens style the detached card's elevation. See the widget docs for how detached appearance behaves per layout mode.
The `shadow` token's `palette.shadows.xl` default applies to floating panels and detached cards. Flush inline embeds (`launcher.enabled: false` without `detachedPanel`) render with no shadow by default: a panel that fills its container is not a floating card. Set `components.panel.shadow` explicitly to elevate a flush embed anyway.
When the panel renders as a detached card the widget root carries a `data-persona-panel-detached` attribute. It is a stable styling hook: host pages and custom CSS can key off `[data-persona-panel-detached]` to target the detached state without depending on internal class names.
Elevation is per card, not per group. When a detached artifact pane is open beside the chat in the desktop side-by-side layout, the outer panel drops its shadow and each surface carries its own: the chat column and the artifact pane each read as a separate card over the canvas, instead of one outer shadow wrapping both plus the gap between them. `shadow` still tunes that elevation, applied to each card. The mount root carries `persona-artifact-detached-split` while this state is active. To flatten the chat column while the pane stays raised (elevation on the panel only, the Claude.ai look), set `artifacts.layout.chatShadow: "none"`; it drives the `--persona-artifact-chat-shadow` token, a front lookup that only affects the chat card and defaults to the same elevation as the pane when unset.
### Chat surface: card vs flush
With the detached pane appearance, `artifacts.layout.chatSurface` chooses how the chat reads:
- `card` (default): the chat is an inset card matching the artifact pane, so the split shows two matched cards over the canvas.
- `flush`: the chat is flat, flush background (no border, radius, or shadow) and only the artifact pane insets as a floating card. This is the flat-chat plus floating-pane reference look, elevation on the pane alone. Flush is a steady state: the chat stays flat whether or not a pane is open, so opening or closing an artifact never flips the chat chrome. `chatShadow` is moot here (the chat card is gone). The outer panel fills its container flush, so it squares its corners by default; an explicit `components.panel.borderRadius` still wins.
The flush chat paints no backdrop of its own: the container, messages body, and composer footer backgrounds go transparent (and the footer's top hairline is dropped), so the host page shows through and the chat reads as part of the page with no theme changes. To pin an explicit backdrop color instead, set `components.panel.canvasBackground`; it colors the whole area behind the flush chat and the floating pane. Do not re-tint `semantic.colors.surface` for this: that token is the background of message bubbles, cards, and the composer input, so tinting it bleeds into every element surface. Flush only takes effect on an inline embed (the container-filling case) with the detached pane appearance; in floating, docked, or sidebar modes, and on a panel or seamless appearance, it is a no-op that falls back to the card look.
The artifact pane's `layout.paneAppearance` picks how the desktop split reads:
- `panel` (default): one welded card. The border and radius wrap chat and the artifact pane together, with a hairline divider on the pane's chat-facing edge and no gap.
- `seamless`: the same welded card with no internal divider or chrome.
- `detached`: two separate elevated cards with the page canvas showing through the gap (described above).
Panel and seamless carry `persona-artifact-welded-split` on the mount root while active. `unifiedSplitChrome` is a deprecated no-op (welding is the default); `unifiedSplitOuterRadius` still overrides the pane's outer-right corner radius. An explicit `splitGap`, `paneBorder`, `paneBorderLeft`, or `paneShadow` overrides the welded defaults.
### Header (`components.header.*`)
| Token | Default Reference |
|-------|-------------------|
| `background` | `semantic.colors.surface` |
| `border` | `semantic.colors.border` |
| `borderRadius` | `palette.radius.xl palette.radius.xl 0 0` |
| `padding` | `semantic.spacing.md` |
`title` and `subtitle` take the shared `TextStyleTokens` shape
(`fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`,
`color`). Unset keys fall back to the built-in header type: the title renders
at `1rem` / `1.5rem` / weight `600`, the subtitle at `0.75rem` / `1rem`.
| Token | Default |
|-------|---------|
| `title.fontFamily` / `subtitle.fontFamily` | inherited from the widget font |
| `title.fontSize` | `"1rem"` |
| `title.fontWeight` | `"600"` |
| `title.lineHeight` | `"1.5rem"` |
| `title.color` | `semantic.colors.primary` |
| `subtitle.fontSize` | `"0.75rem"` |
| `subtitle.lineHeight` | `"1rem"` |
| `subtitle.color` | `semantic.colors.textMuted` |
> `titleForeground` and `subtitleForeground` are legacy aliases of
> `title.color` and `subtitle.color`. Both still work; the `title`/`subtitle`
> color wins when both are set.
### Message (`components.message.*`)
| Token | Default Reference |
|-------|-------------------|
| `user.background` | `semantic.colors.primary` |
| `user.text` | `semantic.colors.textInverse` |
| `user.borderRadius` | `palette.radius.lg` |
| `assistant.background` | `semantic.colors.container` |
| `assistant.text` | `semantic.colors.text` |
| `assistant.borderRadius` | `palette.radius.lg` |
### Voice (`components.voice.*`)
| Token | Default Reference |
|-------|-------------------|
| `recording.indicator` | `palette.colors.error.500` |
| `recording.background` | `palette.colors.error.50` |
| `recording.border` | `palette.colors.error.200` |
| `processing.icon` | `palette.colors.primary.500` |
| `processing.background` | `palette.colors.primary.50` |
| `speaking.icon` | `palette.colors.success.500` |
### Approval (`components.approval.*`)
The default approval renderer is a neutral surface card whose primary action
anchors to the brand primary. Defaults map to semantic tokens (so the card
tracks your theme), not the old warning/success/error palette:
| Token | Default Reference |
|-------|-------------------|
| `requested.background` | `semantic.colors.surface` |
| `requested.border` | `semantic.colors.border` |
| `requested.text` | `palette.colors.gray.900` |
| `requested.shadow` | `"0 1px 2px 0 rgba(11,11,11,0.06), 0 2px 8px 0 rgba(11,11,11,0.04)"` |
| `approve.background` | `semantic.colors.primary` (→ `--persona-button-primary-bg`) |
| `approve.foreground` | `semantic.colors.textInverse` (→ `--persona-button-primary-fg`) |
| `deny.background` | `semantic.colors.container` |
| `deny.foreground` | `semantic.colors.text` |
**Per-widget overrides (`config.approval.*`)** take precedence over the tokens above:
| Property | Description |
|----------|-------------|
| `enableAlwaysAllow` | `false` by default. `true` adds the split "Always allow / Allow once" control + keyboard shortcuts; forwards `{ remember: true }` to `onDecision` (needs a backend to persist the policy) |
| `detailsDisplay` | `"collapsed"` (default) / `"expanded"` / `"hidden"` — initial state of the tool-arguments disclosure |
| `showDetailsLabel` / `hideDetailsLabel` | Disclosure toggle labels |
| `backgroundColor` / `borderColor` | Card container styling |
| `shadow` | Box-shadow for the card; pass `"none"` to remove it. Overrides the `requested.shadow` token / `--persona-approval-shadow` |
| `titleColor` / `descriptionColor` | Title and summary text (`title` no longer renders in the default card — use `formatDescription` to customize the summary line) |
| `parameterBackgroundColor` / `parameterTextColor` | Parameters code block |
| `approveLabel` / `approveButtonColor` / `approveButtonTextColor` | Primary (Allow / Always allow) button + caret |
| `denyLabel` / `denyButtonColor` / `denyButtonTextColor` | Deny button |
> The legacy yellow look: set `config.approval.backgroundColor`/`borderColor` (or the `components.approval.requested.*` tokens) back to the warning palette to restore it. The `--persona-approval-*` aliases are still honored fallback-first.
### Attachment (`components.attachment.*`)
| Token | Default Reference |
|-------|-------------------|
| `image.background` | `palette.colors.gray.100` |
| `image.border` | `palette.colors.gray.200` |
### Intro Card (`components.introCard.*`)
The welcome panel rendered above the message list when no messages exist.
Set `welcome.variant: "none"` to hide it entirely, `welcome.variant: "hero"` to
center it in the empty conversation, or use `layout.slots["body-top"]` to
replace it with a custom element.
| Token | Default Reference |
|-------|-------------------|
| `background` | `semantic.colors.surface` |
| `borderRadius` | `palette.radius.2xl` |
| `padding` | `semantic.spacing.lg` |
| `shadow` | `"0 5px 15px rgba(15, 23, 42, 0.08)"` *(matches the legacy `persona-shadow-sm` look)* |
| `border` | `"none"` *(full CSS border shorthand, e.g. `"1px solid rgba(0,0,0,0.1)"`)* |
`title` and `subtitle` take the shared `TextStyleTokens` shape
(`fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`,
`color`). They have no token defaults: unset keys fall back in CSS to the
built-in welcome type.
| Token | Default |
|-------|---------|
| `title.fontFamily` / `subtitle.fontFamily` | inherited from the widget font |
| `title.fontSize` | `"1.125rem"` |
| `title.fontWeight` | `"600"` |
| `title.lineHeight` | `"1.75rem"` |
| `title.color` | `--persona-primary` |
| `subtitle.fontSize` | `"0.875rem"` |
| `subtitle.lineHeight` | `"1.25rem"` |
| `subtitle.color` | `--persona-muted` |
### Suggestion (`components.suggestion.*`)
Three variants (`chip`, `card`, `list`), each a full chrome set. Two spacing
tokens are easy to confuse:
| Token | Default | Meaning |
|-------|---------|---------|
| `gap` | `"0.5rem"` (chip), `"0.625rem"` (card, list) | Space inside one item, between its icon and its copy |
| `itemGap` | `"8px"` | Space between suggestion items in the container |
### Composer (`components.composer.*`)
The message input form at the bottom of the panel. `padding` and `gap` shape the
form itself; `fontSize` and `lineHeight` set the textarea's type.
| Token | Default | CSS Variable |
|-------|---------|--------------|
| `shadow` | `palette.shadows.none` | `--persona-composer-shadow` |
| `padding` | `"0.75rem 1rem"` | `--persona-composer-padding` |
| `gap` | `"0.5rem"` *(textarea row to actions row)* | `--persona-composer-gap` |
| `fontSize` | `"0.875rem"` | `--persona-composer-font-size` |
| `lineHeight` | `"1.25rem"` | `--persona-composer-line-height` |
```typescript
const theme = createTheme({
components: {
composer: {
padding: '1rem 1.25rem',
gap: '0.75rem',
fontSize: '1rem',
lineHeight: '1.5rem',
},
},
});
```
`padding` and `gap` do not apply to the collapsed pill in
`launcher.mountMode: "composer-bar"`, which keeps its own single-row geometry.
`fontSize` and `lineHeight` apply to both. On coarse-pointer devices the
textarea is still pinned to `1rem` so iOS Safari does not zoom on focus.
### Scroll To Bottom (`components.scrollToBottom.*`)
| Token | Default Reference |
|-------|-------------------|
| `background` | `components.button.primary.background` |
| `foreground` | `components.button.primary.foreground` |
| `border` | `semantic.colors.primary` |
| `size` | `"40px"` |
| `borderRadius` | `palette.radius.full` |
| `shadow` | `palette.shadows.sm` |
| `padding` | `"0.5rem 0.875rem"` |
| `gap` | `"0.5rem"` |
| `fontSize` | `"0.875rem"` |
| `iconSize` | `"14px"` |
---
## Plugin System
Plugins transform theme tokens before they are resolved.
```typescript
import { createTheme, createPlugin } from '@runtypelabs/persona';
const myPlugin = createPlugin({
name: 'company-theme',
version: '1.0.0',
transform(theme) {
return { ...theme, /* modifications */ };
},
cssVariables: {
'--company-brand': '#ff0000'
},
afterResolve(resolved) {
return { ...resolved, /* post-processing */ };
}
});
const theme = createTheme(undefined, { plugins: [myPlugin] });
```
### Built-in Plugins
| Plugin | Description |
|--------|-------------|
| `accessibilityPlugin()` | Enhanced focus indicators and disabled states |
| `animationsPlugin()` | Adds transition and easing tokens |
| `brandPlugin({ colors: { primary: '#hex' } })` | Auto-generates color scales from a single brand color |
| `reducedMotionPlugin()` | Disables all animations (sets transitions to 0ms) |
| `highContrastPlugin()` | Enhances contrast for visual accessibility |
---
## CSS Variables
### Naming Convention
All CSS variables use the `--persona-` prefix:
```
palette.colors.primary.500 → --persona-palette-colors-primary-500
semantic.colors.primary → --persona-semantic-colors-primary
components.button.background → --persona-components-button-background
```
### Convenience Aliases
Common tokens have short aliases for easier use in custom CSS:
```css
--persona-primary /* semantic.colors.primary */
--persona-secondary /* semantic.colors.secondary */
--persona-accent /* semantic.colors.accent */
--persona-surface /* semantic.colors.surface */
--persona-background /* semantic.colors.background */
--persona-container /* semantic.colors.container */
--persona-text /* semantic.colors.text */
--persona-text-muted /* semantic.colors.textMuted */
--persona-text-inverse /* semantic.colors.textInverse */
--persona-border /* semantic.colors.border */
--persona-divider /* semantic.colors.divider */
--persona-muted /* alias for --persona-text-muted */
```
### Scrollbars
Every scroller in the widget shares one thin scrollbar appearance, themed
through `components.scrollbar.*`:
```typescript
theme: {
components: {
scrollbar: {
thumb: "#00dfc1", // default: semantic.colors.border
track: "transparent", // default: transparent
},
},
}
```
The resolved values surface as `--persona-scrollbar-thumb` /
`--persona-scrollbar-track` for plain-CSS overrides. Visibility is a policy,
not a style: `features.scrollBehavior.scrollbar` accepts `"on-scroll"`
(default: hidden at rest, revealed by reader input, pinned visible while
scrolled away from the latest), `"auto"` (native visibility semantics), or
`"hidden"`. The artifact tab strip keeps its existing
`--persona-artifact-tab-list-scrollbar` variable as an alias layered on the
shared thumb token.
### Voice Aliases
```css
--persona-voice-recording-indicator
--persona-voice-recording-bg
--persona-voice-processing-icon
--persona-voice-speaking-icon
```
### Approval Aliases
```css
--persona-approval-bg
--persona-approval-border
--persona-approval-text
--persona-approval-shadow
--persona-approval-approve-bg
--persona-approval-deny-bg
```
### Attachment Aliases
```css
--persona-attachment-image-bg
--persona-attachment-image-border
```
### Panel Aliases
```css
--persona-panel-border /* components.panel.border */
--persona-panel-shadow /* components.panel.shadow */
--persona-panel-inset /* components.panel.inset, default 16px */
--persona-panel-canvas-bg /* components.panel.canvasBackground, default transparent */
```
`--persona-panel-inset` and `--persona-panel-canvas-bg` drive the detached panel appearance (`launcher.detachedPanel: true` or `artifacts.layout.paneAppearance: "detached"`): the inset is the gap around the card and the canvas background fills the region revealed behind it.
### Intro Card Aliases
```css
--persona-intro-card-bg
--persona-intro-card-radius
--persona-intro-card-padding
--persona-intro-card-shadow
```
The intro card is flat by default (transparent background, no shadow), and a flat card drops the horizontal component of its stock padding (`--persona-intro-card-padding` resolves to `1.5rem 0`) so the welcome text lines up with the transcript and composer column. Setting `components.introCard.background` or `.shadow` restores the full `1.5rem` interior inset, and any explicit `components.introCard.padding` value is used as-is (`1.5rem 1.5rem` forces the symmetric inset on a flat card).
### Artifact Card Aliases
```css
--persona-artifact-card-bg
--persona-artifact-card-border
--persona-artifact-card-radius
--persona-artifact-card-hover-bg
--persona-artifact-card-hover-border
```
The artifact reference card is the tappable card rendered inline in the chat thread that opens an artifact. Style it via `components.artifact.card`:
| Property | CSS var | Description |
|----------|---------|-------------|
| `card.background` | `--persona-artifact-card-bg` | Resting card background |
| `card.border` | `--persona-artifact-card-border` | Full border shorthand, e.g. `1px solid #e5e7eb` |
| `card.borderRadius` | `--persona-artifact-card-radius` | Corner radius. When unset it falls back to the assistant bubble radius (`--persona-message-assistant-radius`), so cards match message bubbles by default |
| `card.hoverBackground` | `--persona-artifact-card-hover-bg` | Background on hover |
| `card.hoverBorderColor` | `--persona-artifact-card-hover-border` | Border color on hover |
Interactive-state defaults: the hover and active backgrounds for icon buttons, label buttons, artifact tabs, and the artifact card default to a visible gray step (`gray.100` for hover, `gray.200` for active, `gray.300` for the active icon-button border) whenever the theme's `container` color equals its `surface` color, which is the case for the default preset. When a theme sets `container` to a distinct color, those states anchor to `container` instead. Every one of these stays fully overridable via `components.iconButton`, `components.labelButton`, `components.artifact.tab`, and `components.artifact.card`.
### Artifact Inline Aliases
```css
--persona-artifact-inline-bg
--persona-artifact-inline-border
--persona-artifact-inline-radius
--persona-artifact-inline-chrome-bg
--persona-artifact-inline-chrome-border
--persona-artifact-inline-title-color
--persona-artifact-inline-muted-color
--persona-artifact-inline-frame-height
```
Inline artifact blocks (`display: "inline"`) render a file-preview frame with a flush title/toolbar chrome bar above the preview body. Style it via `components.artifact.inline`:
| Property | CSS var | Description |
|----------|---------|-------------|
| `inline.background` | `--persona-artifact-inline-bg` | Preview frame background. Falls back to `semantic.colors.surface` |
| `inline.border` | `--persona-artifact-inline-border` | Full border shorthand for the frame, e.g. `1px solid #e5e7eb` |
| `inline.borderRadius` | `--persona-artifact-inline-radius` | Frame corner radius. When unset it falls back to the assistant bubble radius (`--persona-message-assistant-radius`), so inline blocks match message bubbles by default |
| `inline.chromeBackground` | `--persona-artifact-inline-chrome-bg` | Background of the title/toolbar chrome bar |
| `inline.chromeBorder` | `--persona-artifact-inline-chrome-border` | Bottom border of the title bar |
| `inline.titleColor` | `--persona-artifact-inline-title-color` | Title text color (artifact basename) |
| `inline.mutedColor` | `--persona-artifact-inline-muted-color` | Muted text for the type label / streaming status |
| `inline.frameHeight` | `--persona-artifact-inline-frame-height` | Preview iframe height inside the inline body (default `320px`) |
Color-family tokens (`background`, `chromeBackground`, `chromeBorder`, `titleColor`, `mutedColor`) accept semantic paths that resolve at build time, e.g. `chromeBackground: "semantic.colors.container"`. Defaults draw from the same semantic `surface` / `border` / text / muted family the card uses, so light and dark themes work with zero config.
The chrome action buttons (copy, expand, and custom `inlineActions`) reuse the document-toolbar button styles, so `components.artifact.toolbar` styles both pane and inline controls — there is no separate inline button token set.
Two `data-persona-theme-zone` regions are exposed for visual editors: `artifact-inline` (the whole inline block) and `artifact-inline-chrome` (the title bar).
Host styling example:
```ts
theme: {
components: {
artifact: {
inline: {
borderRadius: "12px",
chromeBackground: "semantic.colors.container",
frameHeight: "400px",
},
toolbar: {
iconBorderRadius: "6px",
},
},
},
}
```
### Artifact Preview Loading Indicator
While a previewable file artifact (HTML/SVG) renders inside its sandboxed
iframe, Persona shows a loading overlay. The default indicator is an **icon
spinner** (no text): icon-first loading is the norm for preview surfaces
(Sandpack, embeds, v0/ChatGPT) and design systems (Apple HIG, Material, Carbon,
Geist), and a concise work-naming label is faded in only as an escalation once
the wait crosses `labelDelayMs` (default 2s). Under `prefers-reduced-motion:
reduce` the spinner stops rotating (the static arc still reads as a ring) and
the label fade is dropped.
Style the spinner with plain CSS variables (no theme-token path needed):
| CSS var | Default | Description |
|---------|---------|-------------|
| `--persona-artifact-spinner-size` | `28px` | Spinner diameter |
| `--persona-artifact-spinner-color` | `--persona-accent` → `--persona-primary` → `#171717` | Rotating arc color (the interactive/brand hue) |
| `--persona-artifact-spinner-track-color` | `--persona-border` → `#e5e7eb` | Faint full ring behind the arc |
| `--persona-artifact-spinner-speed` | `0.8s` | One full rotation duration |
| `--persona-artifact-frame-loading-color` | `--persona-text-muted` → `#6b7280` | Escalation label text color |
The spinner element itself uses the reusable class `persona-spinner` (a small
`