# 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 `` with `.persona-spinner-track` + `.persona-spinner-arc` circles), so these vars also style it anywhere else the spinner is reused. **Escalation label** (`features.artifacts.filePreview.loading`): set `label: "..."` to change the escalation text, or `label: false` for an icon-only indicator that never shows text. `labelDelayMs` controls how long after the overlay appears the label fades in. **Full indicator override** (`renderIndicator`): replace the spinner + label entirely with your own element (a brand mark, skeleton, custom animation). It is called once when the overlay is built: ```ts config: { features: { artifacts: { filePreview: { loading: { label: "Building preview…", labelDelayMs: 1500, renderIndicator: ({ artifactId, config }) => { const el = document.createElement("div"); el.className = "my-brand-loader"; return el; // return null to fall back to the default spinner }, }, }, }, }, } ``` Returning `null` (or throwing) falls back to the default spinner, mirroring the `renderInline` / `renderCard` null-falls-back contract. When a custom indicator is used, the escalation-label logic is skipped (the host owns the content); the overlay backdrop, timing, and dismissal stay widget-owned either way. --- ## Launcher (`config.launcher.*`) ### Basic | Property | Description | |----------|-------------| | `enabled` | Show/hide the launcher button | | `title` | Header title text | | `subtitle` | Header subtitle text | | `textHidden` | Hide title/subtitle on launcher | | `iconUrl` | Custom launcher icon URL | | `position` | `"bottom-right" \| "bottom-left" \| "top-right" \| "top-left"` | | `autoExpand` | Auto-open widget on page load | | `width` | Chat panel width | | `mountMode` | `"floating" \| "docked"` | ### Full Height & Sidebar | Property | Default | Description | |----------|---------|-------------| | `fullHeight` | `false` | Fill full height of container | | `sidebarMode` | `false` | Position as sidebar flush with viewport | | `sidebarWidth` | `"420px"` | Sidebar width | | `heightOffset` | `0` | Pixel offset to subtract from calculated panel height | ### Docked Panel | Property | Default | Description | |----------|---------|-------------| | `dock.side` | `"right"` | Which side of the wrapped target container the panel should appear on | | `dock.width` | `"420px"` | Expanded dock width when open | | `dock.animate` | `true` | When `false`, open/close snaps with no CSS transition (`resize`: width; `overlay`: transform on the panel; `push`: margin on the track) | | `dock.reveal` | `"resize"` | `"resize"`: flex column `0` ↔ `width` (panel fills the slot, so it stretches during the animation). `"emerge"`: same column animation and **content reflow**, but the chat UI stays **`dock.width`** wide and is **clipped** by the slot (full-width floating-style entrance). `"overlay"`: overlay + `transform`. `"push"`: sliding track (Shopify-style) | When `mountMode` is `"docked"`, `initAgentWidget({ target })` wraps the target container and renders Persona in a sibling dock slot. `body` and `html` are not valid targets. `position`, `fullHeight`, and `sidebarMode` are ignored in docked mode. With `dock.reveal: "resize"`, a closed dock uses a **`0px`** column; `"overlay"` and `"push"` slide instead of shrinking the main column during the animation (`overlay`: transform on the panel; `push`: margin on the track: never a transform, which would hijack the containing block of `position: fixed`/`sticky` content inside the wrapped target). The floating launcher stays hidden in docked mode: use `controller.open()` or your own trigger. **Scoping push/overlay:** Only the subtree under `target` is wrapped. Put **headers, sidebars, or settings chrome** *outside* that element (siblings in your layout) when you want them fixed; point `target` at the inner column or canvas that should move with the dock (see `apps/web` docked demo: `#workspace-dock-target`). For **`dock.side: "left"`**, keep the rail **in normal flow beside the dock stage** (e.g. flex row `[nav | stage]`) so the panel does not paint **under** a floating rail. For a **right** dock, an optional **full-width stage** with an **absolutely positioned** left rail can let push translate the canvas **behind** a persistent sidebar (Shopify-style). The embedded dock demo toggles between those two chrome layouts using `data-dock-side` on `#workspace-main`. `position: fixed`/`sticky` content inside the wrapped target stays viewport-anchored (it is **not** pushed with the canvas), so a `right: 0` fixed element will sit under an open right-side panel: offset it while the dock is open via the shell attribute, e.g. `[data-persona-dock-open="true"] .my-fixed-bar { right: 420px; }`, or move it outside the target. **Breaking change:** `dock.collapsedWidth` was removed; a collapsed rail is no longer configurable. ### Agent Icon | Property | Description | |----------|-------------| | `agentIconText` | Emoji/text for agent icon | | `agentIconName` | Icon name | | `agentIconHidden` | Hide agent icon | | `agentIconSize` | Icon size | | `agentIconBackgroundColor` | Background color of the agent icon circle (any CSS color; overrides the default primary color) | ### Call to Action Icon | Property | Description | |----------|-------------| | `callToActionIconText` | Emoji/text for CTA | | `callToActionIconName` | Icon name | | `callToActionIconColor` | Icon color | | `callToActionIconBackgroundColor` | Background color | | `callToActionIconHidden` | Hide CTA icon | | `callToActionIconPadding` | Padding | | `callToActionIconSize` | Size | ### Launcher Styling | Property | Default | Description | |----------|---------|-------------| | `border` | `"1px solid #e5e7eb"` | Border style for the launcher button | | `shadow` | `"0 10px 15px -3px rgba(0,0,0,0.1), ..."` | Box shadow for the launcher button; pass `"none"` to remove it. Overrides the `components.launcher.shadow` token / `--persona-launcher-shadow` | | `collapsedMaxWidth` | *(unset)* | CSS `max-width` for the floating launcher pill when the panel is closed; title/subtitle truncate with ellipsis (full text in `title` tooltip). Does not change the open panel width (`width`). | ### Header Icon | Property | Description | |----------|-------------| | `headerIconSize` | Header icon size | | `headerIconName` | Header icon name | | `headerIconHidden` | Hide header icon | ## Send Button (`config.sendButton.*`) | Property | Description | |----------|-------------| | `backgroundColor` | Button background | | `textColor` | Text/icon color | | `borderWidth` | Border width | | `borderColor` | Border color | | `paddingX` / `paddingY` | Padding | | `iconText` | Emoji/text | | `iconName` | Icon name | | `useIcon` | Use icon vs text | | `size` | Button size | | `tooltipText` | Tooltip text | | `showTooltip` | Show tooltip | ## Close Button (`config.launcher.*`) | Property | Description | |----------|-------------| | `closeButtonSize` | Button size | | `closeButtonColor` | Icon color | | `closeButtonBackgroundColor` | Background | | `closeButtonBorderWidth` | Border width | | `closeButtonBorderColor` | Border color | | `closeButtonBorderRadius` | Border radius | | `closeButtonPaddingX` / `closeButtonPaddingY` | Padding | | `closeButtonPlacement` | `"inline" \| "top-right"` | | `closeButtonIconName` | Icon name | | `closeButtonIconText` | Emoji/text | | `closeButtonTooltipText` | Tooltip text | | `closeButtonShowTooltip` | Show tooltip | ## Clear Chat Button (`config.launcher.clearChat.*`) | Property | Description | |----------|-------------| | `enabled` | Show clear chat button | | `placement` | `"inline" \| "top-right"` | | `iconName` | Icon name | | `iconColor` | Icon color | | `backgroundColor` | Background | | `borderWidth` / `borderColor` / `borderRadius` | Border styling | | `size` | Button size | | `paddingX` / `paddingY` | Padding | | `tooltipText` | Tooltip text | | `showTooltip` | Show tooltip | ## Voice Recognition (`config.voiceRecognition.*`) | Property | Description | |----------|-------------| | `enabled` | Enable voice input | | `pauseDuration` | Pause duration (ms) before auto-stop | | `iconName` / `iconSize` / `iconColor` | Icon styling | | `backgroundColor` / `borderColor` / `borderWidth` | Button styling | | `paddingX` / `paddingY` | Padding | | `tooltipText` / `showTooltip` | Tooltip | | `recordingIconColor` | Icon color when recording | | `recordingBackgroundColor` | Background when recording | | `recordingBorderColor` | Border when recording | | `showRecordingIndicator` | Show recording indicator | | `autoResume` | `boolean \| "assistant"` - Auto-resume listening | ## Text-to-Speech (`config.textToSpeech.*`) Controls spoken playback of assistant messages. Two entry points share this config: **auto-speak** (read each assistant reply automatically when `enabled` is `true`) and the per-message **"Read aloud" button** (enabled separately via `messageActions.showReadAloud` — see [Message Actions](#message-actions-configmessageactions)). Both speak through a pluggable `SpeechEngine`: the browser Web Speech API by default, or a hosted engine returned from `createEngine`. | Property | Default | Description | |----------|---------|-------------| | `enabled` | `false` | Auto-speak assistant replies as they complete. (The "Read aloud" button works independently of this, via `messageActions.showReadAloud`.) | | `provider` | `"browser"` | `"browser"` uses the Web Speech API for all assistant messages; `"runtype"` lets the realtime voice service handle TTS for voice interactions | | `browserFallback` | `false` | When `provider: "runtype"`, also speak text-typed responses via the browser (no effect for `"browser"`) | | `voice` | — | Voice name for browser TTS (e.g. `"Google US English"`). Falls back to auto-detect if not found | | `pickVoice` | — | Custom voice picker used when `voice` is unset: `(voices: SpeechSynthesisVoice[]) => SpeechSynthesisVoice` | | `rate` | `1` | Speech rate (browser range `0.1`–`10`) | | `pitch` | `1` | Speech pitch (browser range `0`–`2`) | | `createEngine` | — | Factory for a custom/hosted `SpeechEngine` used by both auto-speak and the read-aloud button: `() => SpeechEngine \| Promise`. May be async (resolved on first playback, inside the user gesture). A server engine (e.g. Runtype TTS) can stream audio through the realtime voice `VoicePlaybackEngine` | **Read-aloud control & events.** When `messageActions.showReadAloud` is on, each assistant message gets a play/pause/resume button. Drive or observe it programmatically: - `widget.toggleReadAloud(messageId)` — play → pause → resume (or play → stop when the engine can't pause) - `widget.stopReadAloud()` — stop any playback - `widget.getReadAloudState(messageId)` — `'idle' \| 'loading' \| 'playing' \| 'paused'` - `widget.onReadAloudChange((messageId, state) => …)` — subscribe to every transition - `widget.on('message:read-aloud', (e) => …)` — controller event fired on every transition (`e.messageId`, `e.message`, `e.state`, `e.timestamp`), parallel to `message:copy` / `message:feedback` ## Status Indicator (`config.statusIndicator.*`) | Property | Default | Description | |----------|---------|-------------| | `visible` | `true` | Show status indicator | | `idleText` | `"Online"` | Idle text | | `connectingText` | `"Connecting..."` | Connecting text | | `connectedText` | `"Connected"` | Connected text | | `errorText` | `"Error"` | Error text | | `pausedText` | `"Connection lost…"` | Shown when a durable stream dropped and is awaiting reconnect | | `resumingText` | `"Reconnecting…"` | Shown while a durable reconnect attempt is in flight | ## Tool Call Display (`config.toolCall.*`) ### Styling | Property | Description | |----------|-------------| | `shadow` | Box-shadow for tool call bubbles; pass `"none"` to remove it. Overrides the `components.toolBubble.shadow` token / `--persona-tool-bubble-shadow` | | `backgroundColor` / `borderColor` / `borderWidth` / `borderRadius` | Container styling | | `headerBackgroundColor` / `headerTextColor` / `headerPaddingX` / `headerPaddingY` | Header styling | | `contentBackgroundColor` / `contentTextColor` / `contentPaddingX` / `contentPaddingY` | Content styling | | `codeBlockBackgroundColor` / `codeBlockBorderColor` / `codeBlockTextColor` | Code block styling | | `toggleTextColor` | Expand/collapse toggle color | | `labelTextColor` | Section label color ("Arguments", "Result", etc.) | ### Text Templates | Property | Default | Description | |----------|---------|-------------| | `activeTextTemplate` |: | Header text while tool is running. Placeholders: `{toolName}`, `{duration}` (live-updating) | | `completeTextTemplate` |: | Header text when tool is complete. Placeholders: `{toolName}`, `{duration}` | Templates support **inline formatting markers**: `~dim text~`, `*italic text*`, `**bold text**`. These are parsed at render time and rendered as styled spans. They compose with all animation modes. **Example:** `"Calling {toolName}... ~{duration}~"` renders the duration in a muted/dim style. ### Display Features (`config.features.toolCallDisplay.*`) | Property | Default | Description | |----------|---------|-------------| | `collapsedMode` | `"tool-call"` | What collapsed rows show: `"tool-call"` \| `"tool-name"` \| `"tool-preview"` | | `activePreview` | `false` | Show a lightweight preview block on active collapsed tool calls | | `activeMinHeight` |: | CSS min-height for active collapsed rows (e.g. `"100px"`) | | `previewMaxLines` | `3` | Maximum preview lines for collapsed active tool calls | | `grouped` | `false` | Visually group consecutive tool call rows | | `expandable` | `true` | Allow expand/collapse toggle; `false` shows summary only | | `loadingAnimation` | `"none"` | Animation mode: `"none"` \| `"pulse"` \| `"shimmer"` \| `"shimmer-color"` \| `"rainbow"` | | Property (`config.toolCall.*`) | Default | Description | |----------|---------|-------------| | `loadingAnimationDuration` | `2000` | Cycle duration in ms | | `loadingAnimationColor` | `currentColor` | Primary color for `shimmer-color` mode | | `loadingAnimationSecondaryColor` | `#3b82f6` | Secondary color for `shimmer-color` mode | ### Custom Rendering Hooks | Property | Description | |----------|-------------| | `renderCollapsedSummary` | Override collapsed summary. Context includes `elapsed` (static string) and `createElapsedElement()` (returns a live-updating ``) | | `renderCollapsedPreview` | Override collapsed preview content for active tool rows | | `renderGroupedSummary` | Override grouped tool container summary | ## Reasoning Display (`config.reasoning.*`) ### Display Features (`config.features.reasoningDisplay.*`) | Property | Default | Description | |----------|---------|-------------| | `activePreview` | `false` | Show a lightweight preview block on active collapsed reasoning rows | | `activeMinHeight` |: | CSS min-height for active collapsed rows (e.g. `"100px"`) | | `previewMaxLines` | `3` | Maximum preview lines for collapsed active reasoning rows | | `expandable` | `true` | Allow expand/collapse toggle; `false` shows summary only | | `loadingAnimation` | `"none"` | Animation mode: `"none"` \| `"pulse"` \| `"shimmer"` \| `"shimmer-color"` \| `"rainbow"` | ### Text Templates | Property | Default | Description | |----------|---------|-------------| | `activeTextTemplate` |: | Header text while reasoning is active. Placeholder: `{duration}` (live-updating) | | `completeTextTemplate` |: | Header text when reasoning is complete. Placeholder: `{duration}` | Templates support **inline formatting markers**: `~dim text~`, `*italic text*`, `**bold text**`. Same syntax as tool call templates. **Example:** `"Thinking... ~{duration}~"` renders the duration in a muted/dim style. | Property (`config.reasoning.*`) | Default | Description | |----------|---------|-------------| | `loadingAnimationDuration` | `2000` | Cycle duration in ms | | `loadingAnimationColor` | `currentColor` | Primary color for `shimmer-color` mode | | `loadingAnimationSecondaryColor` | `#3b82f6` | Secondary color for `shimmer-color` mode | ### Custom Rendering Hooks | Property | Description | |----------|-------------| | `renderCollapsedSummary` | Override collapsed summary. Context includes `elapsed` (static string) and `createElapsedElement()` (returns a live-updating ``) | | `renderCollapsedPreview` | Override collapsed preview content for active reasoning rows | ## Message Actions (`config.messageActions.*`) ### Basic Options | Property | Default | Description | |----------|---------|-------------| | `enabled` | `true` | Enable/disable message actions entirely | | `showCopy` | `true` | Show copy button | | `showUpvote` | `false` | Show upvote button (requires backend) | | `showDownvote` | `false` | Show downvote button (requires backend) | | `showReadAloud` | `false` | Show "Read aloud" (text-to-speech) button. Uses the browser Web Speech API by default, or a hosted engine via `textToSpeech.createEngine`; voice/rate/pitch come from `textToSpeech` | ### Appearance | Property | Default | Description | |----------|---------|-------------| | `visibility` | `"hover"` | `"always"` shows buttons always, `"hover"` shows on hover only | | `align` | `"right"` | Horizontal alignment: `"left"` \| `"center"` \| `"right"` | | `layout` | `"pill-inside"` | Layout style: `"pill-inside"` \| `"row-inside"` | ### Callbacks | Property | Description | |----------|-------------| | `onFeedback` | Callback when user submits feedback: `(feedback: { type: 'upvote' \| 'downvote', messageId: string }) => void` | | `onCopy` | Callback when user copies a message: `(message: AgentWidgetMessage) => void` | ## Suggestions (`config.suggestions.*`) Persona separates starter prompts from optional follow-ups: - `suggestions.starters` is shown before the first user message. - `suggestions.followUps` presents the latest `suggest_replies` tool payload, and owns the `enabled` / `expose` keys for that built-in tool. - `ask_user_question` remains the correct primitive for a required answer. Strings are valid item shorthand. Rich items can separate a short visible `label` from the `prompt` that is sent or placed in the composer: ```ts suggestions: { starters: { placement: "auto", variant: "card", behavior: "fill", maxItems: 4, items: [ { id: "compare", label: "Compare plans", prompt: "Compare the plans for a team of 20", description: "Review features, limits, and pricing", icon: "dollar-sign", iconColor: "#16a34a", emphasis: "primary", }, "Show me a quick tour", ], }, followUps: { placement: "auto", variant: "chip", behavior: "send", overflow: "scroll", maxItems: 4, }, } ``` | Starter property | Default | Description | |----------|---------|-------------| | `items` | `suggestionChips` | `(string \| AgentWidgetSuggestionObject)[]` | | `variant` | `"card"` | `"card" \| "chip" \| "list"` | | `placement` | `"auto"` | `"auto" \| "welcome" \| "composer"`; `auto` uses the welcome surface when the welcome card renders and the composer otherwise. Explicit values are literal: pinned `"welcome"` renders nothing when the welcome card is hidden | | `behavior` | `"send"` | `"send"` sends immediately; `"fill"` drafts in the composer | | `overflow` | `"wrap"` | `"scroll" \| "wrap"` | | `maxItems` | `4` | Maximum number shown | | Follow-up property | Default | Description | |----------|---------|-------------| | `enabled` | `true` | Render follow-ups and auto-resume `suggest_replies` | | `expose` | `false` | Advertise the built-in tool on `clientTools[]`; forced off when `enabled` is `false` | | `variant` | `"chip"` | `"chip" \| "card" \| "list"` | | `placement` | `"auto"` | `"auto" \| "after-message" \| "composer"`; `auto` uses `"after-message"` in regular panels and `"composer"` in composer-bar mode | | `behavior` | `"send"` | `"send" \| "fill"` | | `overflow` | `"scroll"` | `"scroll" \| "wrap"` | | `maxItems` | `4` | Maximum number shown | Each rich item supports `id`, `label`, `prompt`, `description`, `icon`, `iconColor`, `behavior`, and `emphasis`. Per-item `behavior` overrides the surface. `emphasis: "primary"` renders a quiet accent (accent border, faint accent wash, accent icon), not a solid fill; go louder with the token overrides below. `iconColor` takes any CSS color and tints that item's glyph alone, leaving the label, border, and background neutral: use it to color-code a set without promoting one item. Follow-ups have no `items` key: they come from the agent's `suggest_replies` call or from `controller.setFollowUpSuggestions()`, an ephemeral host overlay that clears on the next user message and is never persisted. The deprecated `features.suggestReplies.enabled` / `.expose` aliases still resolve per key, with `suggestions.followUps` winning. ### Async starters Starters are static config, but the starter surface re-renders on every `controller.update()`, so fetched or personalized starters are a two-step recipe: paint with a static set, then swap in the fetched one. ```ts const chat = initAgentWidget({ target: "#launcher-root", config: { apiUrl: "/api/chat/dispatch", // Rendered immediately, so the welcome surface is never empty. suggestions: { starters: { items: ["Track an order", "Start a return"] } }, }, }); const items = await fetch("/api/starters").then((r) => r.json()); chat.update({ suggestions: { starters: { items } } }); ``` Notes: - `items` is an array, so it replaces wholesale. Pass the full list on every update rather than a delta. - Presentation keys merge, so an update carrying only `items` keeps the `variant`, `placement`, `behavior`, and `maxItems` set at init. - Starters still dismiss on the first user message. An update that lands after the user has sent something renders nothing, which is the intended behavior: resolve the fetch early and let the static set cover the gap. - Plugin `transformSuggestions` hooks run on the updated set too, so ranking and enrichment stay in one place. A first-class async provider (`starters.items` as a promise-returning function) is deliberately not offered: this recipe covers it with no new config surface. ### Suggestion theme tokens Each variant under `theme.components.suggestion` supports: `background`, `foreground`, `border`, `borderRadius`, `padding`, `shadow`, `gap`, `minHeight`, `fontSize`, `lineHeight`, `iconSize`, `hoverBackground`, `hoverForeground`, `hoverBorder`, `pressedBackground`, `focusRing`, and `disabledOpacity`. ```ts theme: { components: { suggestion: { chip: { background: "semantic.colors.surface", border: "semantic.colors.border", borderRadius: "palette.radius.full", hoverBackground: "palette.colors.gray.100", focusRing: "semantic.colors.interactive.focus", }, card: { borderRadius: "palette.radius.xl", padding: "1rem", // Cards rest shadowless by default and gain a shadow plus a 1px lift // on hover; set `shadow` to keep one at rest. shadow: "palette.shadows.sm", }, list: { background: "transparent", minHeight: "44px", }, }, }, } ``` The deprecated `suggestionChips: string[]` and `suggestionChipsConfig` fields remain backwards compatible. They continue to render starter chips above the composer until a `suggestions.starters` configuration is supplied. ### Complete customization with plugins Theme tokens and `config.suggestions` cover the common visual and behavioral choices. For product-specific ranking or markup, pass an `AgentWidgetPlugin` through `config.plugins`: - `transformSuggestions` filters, reorders, or enriches normalized starter and follow-up data before `maxItems`. Hooks receive resolved items (strings are already expanded) and may return the loose shape, which is re-normalized before the next hook. This is also where a host re-adds the `icon`, `emphasis`, and per-item `behavior` that the `suggest_replies` schema deliberately withholds from the model. - `renderSuggestion` replaces an individual item. It receives `defaultRenderer()` for progressive customization and `select()` for the standard events plus send/fill behavior. - `onSuggestionSelect` observes selection and can return `false` to cancel the built-in action. The plugin contexts distinguish the `"starter"` / `"followUp"` surface and the `"config"` / `"agent"` / `"host"` source. The `persona:suggestion:selected` DOM event is also cancelable with `event.preventDefault()`. See [`docs/PLUGINS.md`](./docs/PLUGINS.md#suggestion-hooks) and the live [`suggestions-demo.html`](../../apps/web/suggestions-demo.html). ### Suggestion events - `persona:suggestion:shown` includes suggestions, surface, source, and variant. - `persona:suggestion:selected` includes the selected item and send/fill mode. It is cancelable; call `event.preventDefault()` to suppress the action. - The follow-up surface also retains `persona:suggestReplies:shown` and `persona:suggestReplies:selected` for backwards compatibility, for both agent-set and host-set items. ## Layout (`config.layout.*`) ### Header (`layout.header.*`) | Property | Description | |----------|-------------| | `layout` | `"default" \| "minimal"` | | `showIcon` / `showTitle` / `showSubtitle` | Show/hide elements | | `showCloseButton` / `showClearChat` | Show/hide buttons | | `render` | Custom render function | ### Messages (`layout.messages.*`) | Property | Description | |----------|-------------| | `layout` | `"bubble"` (bubbles for both), `"minimal"` (user bubble with an open assistant response), or `"flat"` (open messages for both) | | `groupConsecutive` | Group consecutive same-role messages | | `avatar.show` / `avatar.position` / `avatar.userAvatar` / `avatar.assistantAvatar` | Avatar config | | `timestamp.show` / `timestamp.position` / `timestamp.format` | Timestamp config | | `user.width` / `assistant.width` | `"content"` (shrink-wrap, default) or `"full"` (fill the available transcript track) | | `user.maxWidth` / `assistant.maxWidth` | Optional CSS max-width. Defaults to `"85%"` for content width and `"100%"` for full width | | `renderUserMessage` / `renderAssistantMessage` | Custom render functions | Role width is independent of the message chrome preset. For example, a modern AI-assistant layout can combine a content-sized user bubble with a flat, full-width assistant response: ```ts layout: { contentMaxWidth: "72ch", messages: { layout: "minimal", user: { width: "content", maxWidth: "80%", }, assistant: { width: "full", }, }, } ``` `"full"` means the full track inside the transcript's padding and `layout.contentMaxWidth`; when an avatar is shown, the message fills the space remaining after the avatar and gap. ### Slots (`layout.slots.*`) Available: `header-left`, `header-center`, `header-right`, `body-top`, `messages`, `body-bottom`, `footer-top`, `composer`, `footer-bottom` Each renderer receives `{ config, defaultContent }`. Returning an element replaces the slot's default content; returning `null` leaves it alone. `body-top` is the welcome surface: its `defaultContent()` is the intro card (title, subtitle, and any starters placed there), so a custom welcome can either replace it or wrap it. ```ts layout: { slots: { "body-top": ({ defaultContent }) => { const wrapper = document.createElement("div"); const banner = document.createElement("p"); banner.textContent = "Support replies in about 2 minutes."; wrapper.append(banner); const card = defaultContent(); if (card) wrapper.append(card); return wrapper; }, }, } ``` ## Markdown (`config.markdown.*`) ### Options (`markdown.options.*`) | Property | Default | Description | |----------|---------|-------------| | `gfm` | `true` | Enable GitHub Flavored Markdown | | `breaks` | `true` | Convert `\n` to `
` | | `pedantic` | `false` | Original markdown.pl behavior | | `headerIds` | `false` | Add id attributes to headings | | `headerPrefix` | `""` | Prefix for heading ids | | `mangle` | `true` | Mangle email addresses | | `silent` | `false` | Don't throw on parse errors | ### Other Options | Property | Default | Description | |----------|---------|-------------| | `disableDefaultStyles` | `false` | Disable default markdown CSS | | `renderer` | `undefined` | Custom renderer overrides | ### Override Methods (4 Levels) **Level 1: CSS Variables** (simplest) ```css :root { /* Headers */ --persona-md-h1-size: 1.5rem; --persona-md-h1-weight: 700; --persona-md-h2-size: 1.25rem; --persona-md-h3-size: 1.125rem; /* Tables */ --persona-md-table-border-color: #e5e7eb; --persona-md-table-header-bg: #f8fafc; --persona-md-table-cell-padding: 0.5rem 0.75rem; /* Edge fade shown when a wide table scrolls horizontally; set to 0 to disable. */ --persona-md-table-scroll-fade: 24px; /* Blockquotes */ --persona-md-blockquote-border-color: var(--persona-accent); --persona-md-blockquote-text-color: var(--persona-muted); /* Code blocks */ --persona-md-code-block-bg: var(--persona-container); --persona-md-code-block-border-color: var(--persona-border); /* Inline code */ --persona-md-inline-code-bg: var(--persona-container); /* Horizontal rules */ --persona-md-hr-color: var(--persona-divider); } ``` **Level 2: Markdown Options** (moderate) ```typescript config: { markdown: { options: { gfm: true, breaks: true, headerIds: true, headerPrefix: 'chat-' } } } ``` **Level 3: Custom Renderers** (full control) ```typescript config: { markdown: { renderer: { heading(token) { return `${token.text}`; }, link(token) { return `${token.text}`; } } } } ``` Available renderer overrides: `heading`, `code`, `blockquote`, `table`, `link`, `image`, `list`, `listitem`, `paragraph`, `codespan`, `strong`, `em`, `hr`, `br`, `del`, `checkbox`, `html`, `text` **Level 4: postprocessMessage** (complete override) ```typescript import { markdownPostprocessor } from '@runtypelabs/persona'; config: { postprocessMessage: ({ text }) => markdownPostprocessor(text) } ``` ### Persona theme `components.markdown` (SDK) These merge into `PersonaTheme` and are exposed as CSS variables on the widget root (`applyThemeVariables`). | Path | Consumer variable | |------|-------------------| | `inlineCode.background` / `foreground` | `--persona-md-inline-code-bg`, `--persona-md-inline-code-color` | | `link.foreground` | `--persona-md-link-color` (assistant chat markdown links + artifact pane markdown) | | `heading.h1.fontSize` / `fontWeight` | `--persona-md-h1-size`, `--persona-md-h1-weight` (only when set) | | `heading.h2.fontSize` / `fontWeight` | `--persona-md-h2-size`, `--persona-md-h2-weight` (only when set) | ### Markdown CSS Variables Reference ```css :root { /* Links (theme-driven via --persona-md-link-color when set) */ --persona-md-link-color: var(--persona-accent, #0f0f0f); /* Headers */ --persona-md-h1-size: 1.5rem; --persona-md-h1-weight: 700; --persona-md-h1-margin: 1rem 0 0.5rem; --persona-md-h1-line-height: 1.25; --persona-md-h2-size: 1.25rem; --persona-md-h2-weight: 700; --persona-md-h2-margin: 0.875rem 0 0.5rem; --persona-md-h2-line-height: 1.3; --persona-md-h3-size: 1.125rem; --persona-md-h3-weight: 600; --persona-md-h3-margin: 0.75rem 0 0.375rem; --persona-md-h3-line-height: 1.4; --persona-md-h4-size: 1rem; --persona-md-h4-weight: 600; --persona-md-h5-size: 0.875rem; --persona-md-h5-weight: 600; --persona-md-h6-size: 0.75rem; --persona-md-h6-weight: 600; /* Tables */ --persona-md-table-border-color: var(--persona-border, #e5e7eb); --persona-md-table-header-bg: var(--persona-container, #f8fafc); --persona-md-table-header-weight: 600; --persona-md-table-cell-padding: 0.5rem 0.75rem; --persona-md-table-border-radius: 0.375rem; --persona-md-table-scroll-fade: 24px; /* Horizontal Rule */ --persona-md-hr-color: var(--persona-divider, #e5e7eb); --persona-md-hr-height: 1px; --persona-md-hr-margin: 1rem 0; /* Blockquotes */ --persona-md-blockquote-border-color: var(--persona-accent, #0f0f0f); --persona-md-blockquote-border-width: 3px; --persona-md-blockquote-padding: 0.5rem 1rem; --persona-md-blockquote-margin: 0.5rem 0; --persona-md-blockquote-bg: transparent; --persona-md-blockquote-text-color: var(--persona-muted, #6b7280); --persona-md-blockquote-font-style: italic; /* Code Blocks */ --persona-md-code-block-bg: var(--persona-container, #f3f4f6); --persona-md-code-block-border-color: var(--persona-border, #e5e7eb); --persona-md-code-block-text-color: inherit; --persona-md-code-block-padding: 0.75rem; --persona-md-code-block-border-radius: 0.375rem; --persona-md-code-block-font-size: 0.875rem; /* Inline Code */ --persona-md-inline-code-bg: var(--persona-container, #f3f4f6); --persona-md-inline-code-padding: 0.125rem 0.375rem; --persona-md-inline-code-border-radius: 0.25rem; --persona-md-inline-code-font-size: 0.875em; /* Strong/Emphasis */ --persona-md-strong-weight: 600; --persona-md-em-style: italic; } ``` ## Copy / Text (`config.copy.*`) | Property | Description | |----------|-------------| | `welcomeTitle` | Welcome message title. Deprecated: use `welcome.title` | | `welcomeSubtitle` | Welcome message subtitle. Deprecated: use `welcome.subtitle` | | `inputPlaceholder` | Input placeholder text | | `sendButtonLabel` | Send button label | ## Feature Flags (`config.features.*`) | Property | Description | |----------|-------------| | `showReasoning` | Show AI reasoning/thinking steps | | `showToolCalls` | Show tool call invocations | | `scrollToBottom` | Shared transcript + event-stream affordance config: `enabled`, `iconName`, `label` (empty string renders icon-only). Defaults: `enabled: true`, `iconName: "arrow-down"`, `label: ""`. | | `artifacts` | Artifact sidebar: `enabled`, `allowedTypes`, optional `layout` (split/drawer sizing, launcher widen, resize handle, `paneAppearance`, `toolbarPreset` `default` \| `document`, `documentToolbarShowCopyLabel`, `documentToolbarShowCopyChevron`, `documentToolbarIconColor`, `documentToolbarToggleActiveBackground`, `documentToolbarToggleActiveBorderColor`, borders, `unifiedSplitChrome`, etc.). See README **Features** table for defaults. | --- ## Theme API Exports ```typescript import { // Theme creation createTheme, resolveTokens, themeToCssVariables, applyThemeVariables, getActiveTheme, getColorScheme, detectColorScheme, createThemeObserver, // Plugins accessibilityPlugin, animationsPlugin, brandPlugin, reducedMotionPlugin, highContrastPlugin, createPlugin, } from '@runtypelabs/persona'; ``` --- ## Breaking Changes from v1 | Change | v1 | v2 | |--------|-----|-----| | CSS variables | `--cw-*` | `--persona-*` | | Tailwind prefix | `tvw-*` | `persona-*` | | Theme config | Flat properties | Layered tokens (palette/semantic/components) | | Dark mode | Separate `darkTheme` object | Unified via `colorScheme` + auto dark palette | | Host element | `.tvw-widget-root` | `.persona-host` | --- # Integration Guides # Using Persona's WebMCP with AI SDK Persona's WebMCP support lets the agent call **page-defined tools** (registered on `document.modelContext`). A common question: can that work against a **direct [Vercel AI SDK](https://ai-sdk.dev) backend** instead of the Runtype API? Yes, and there are two paths depending on whether you keep the Persona widget UI. --- ## The coupling that decides the path The widget owns the WebMCP loop internally: it snapshots page tools into `clientTools[]`, and when the agent calls one it executes the tool on the page and posts the result back. That loop runs over **Persona's SSE wire protocol** (vendor-neutral — the same wire the Runtype API emits): an `await` SSE event pauses the run, and the widget POSTs the tool output to `${apiUrl}/resume` to continue. The control events (`await` / `executionId` / `/resume`) are part of that protocol; `parseSSEEvent` / `customFetch` can adapt *content* framing but the pause/resume contract is fixed. So: - **Keep the Persona widget UI** → your backend must **speak Persona's protocol** (the widget consumes it natively). Build a thin shim over the AI SDK (Path A). - **Don't need the widget UI** → reuse just the transport-agnostic `WebMcpBridge` and drive the AI SDK's **native** client-tool loop (Path B). --- ## Path A: keep the widget, shim the protocol (recommended for "drop-in") A runnable example lives at [`examples/ai-sdk-webmcp/`](../examples/ai-sdk-webmcp/) (live at [ai-sdk-webmcp.persona-chat.dev](https://ai-sdk-webmcp.persona-chat.dev)) The Switchback storefront with the real Persona widget, backed by two AI SDK route handlers. Point the widget at your own endpoint in **proxy mode**: ```ts createAgentExperience(el, { apiUrl: "/api/chat/dispatch", // resume is POSTed to `${apiUrl}/resume` webmcp: { enabled: true, autoApprove: (i) => READ_ONLY.has(i.toolName) }, // ...theme, copy, launcher }); ``` > Your shim must speak the SSE event vocabulary on **both** the dispatch stream and > the `/resume` continuation — a `/resume` stream continues mid-run with no > `execution_start` frame, so the wire format has to stay consistent throughout. The > widget consumes it natively (no per-widget opt-in). ### The wire contract your shim emits Each SSE frame is `event: \ndata: `. Each frame carries one `exec_…` `executionId` for the whole run. | Widget reads | JSON | | --- | --- | | run start | `execution_start` → `{type, executionId, kind:"agent", agentId, startedAt}` | | turn open | `turn_start` → `{type, executionId, id:"turn_…", iteration}` | | text delta | `text_start`·`text_delta`·`text_complete` → `{type, executionId, id:"text_…", delta, iteration}` | | **WebMCP call** | `await` → `{type, executionId, toolName:"", origin:"webmcp", toolId, toolCallId, parameters, awaitedAt}` | | turn done | `turn_complete` + `execution_complete` → `{type, executionId, kind:"agent", success:true, completedAt}` | | failure | `execution_error` → `{type, executionId, kind:"agent", error:{message}}` | Three rules that bite if missed: 1. **`await` carries a BARE `toolName` plus `origin:"webmcp"`.** The widget bridge applies the `webmcp:` prefix and routes the call to its WebMCP bridge (which strips the prefix to look the tool up on the page). Key the pause by `toolCallId` so two parallel calls to the same tool stay distinct. 2. **Don't emit `turn_complete`/`execution_complete` when pausing for a tool.** An `await` ends the HTTP stream; the widget runs the tool and POSTs to `/resume`. Emitting a completion frame would end the turn instead. 3. **Announce `kind:"agent"` on `execution_start`, and mean it.** A `/resume` continuation has no `execution_start` to re-announce the kind; the widget bridge defaults a fresh stream to `kind:"agent"`. If your backend is a real agent that matches by construction. A backend that wrapped an agent in a virtual *flow* (`kind:"flow"`) would mis-route resume tool events unless it re-announced its kind on the resume stream. ### Resume ``` POST ${apiUrl}/resume { "executionId": "...", "toolOutputs": { "": }, "streamResponse": true } ``` `toolOutputs` is keyed by the `toolCallId` you emitted in `await` (falling back to `toolName`). A `WebMcpToolResult` is `{content:[{type:"text",text}], isError?}`. The resume response streams the continued turn with the **same** protocol — the **same** `executionId`, a fresh `turn_start` at an advanced `iteration`, and another `await` if the model calls another tool. ### Mapping to `streamText` The shim ([`app/api/chat/shim.ts`](../examples/ai-sdk-webmcp/app/api/chat/shim.ts)) is small: - Build the widget's `clientTools[]` into an AI SDK `ToolSet` with `tool({ description, inputSchema: jsonSchema(parametersSchema) })` and **no `execute`** No-execute tools are client-side, so the model's call streams out and the turn stops. - Open each turn with `turn_start`, then iterate `result.fullStream`: `text-delta` → `text_start`/`text_delta` (lazily opening the text block); `tool-call` → `await` (bare name + `origin:"webmcp"`, reuse the model's `toolCallId`), then pause. Close the text block with `text_complete` before pausing or completing. - Persist the conversation (messages + pending calls + the tool definitions + the current `iteration`) keyed by a generated `exec_…` `executionId` so `/resume` can continue it under the same id. - On resume, append a tool-result message (`{role:"tool", content:[{type:"tool-result", toolCallId, toolName, output:{type:"text", value}}]}`) and call `streamText` again at `iteration + 1`, streaming identically until a turn finishes with no tool calls (`turn_complete` + `execution_complete`). State between dispatch and resume must outlive a single request and be reachable from a different instance (the two are separate HTTP requests). The example keys it by `executionId` in the **Vercel Runtime Cache** (`@vercel/functions`), with an in-memory fallback for local dev, but that cache is **ephemeral and region-scoped**, so production should use a durable store (Redis/Upstash, Vercel KV, a DB, or a Durable Object). The widget's resume request only sends `{executionId, toolOutputs}` (not the message history), so server-side shared state is required regardless of backend. --- ## Path B: reuse just the bridge (no widget UI) If you're building your own UI, skip the protocol entirely. `WebMcpBridge` is a public export and imports nothing Runtype-specific: ```ts import { WebMcpBridge } from "@runtypelabs/persona"; const bridge = new WebMcpBridge({ enabled: true, onConfirm }); // advertise page tools to your AI SDK backend each turn const clientTools = await bridge.snapshotForDispatch(); // ClientToolDefinition[] // in the AI SDK's native client-tool hook useChat({ async onToolCall({ toolCall }) { return bridge.executeToolCall(toolCall.toolName, toolCall.input); }, }); ``` Here the AI SDK owns the loop (its auto-resubmission replaces `/resume`) and the bridge owns discovery, execution, and the human-approval gate. `parametersSchema` is plain JSON Schema, so it maps directly via the AI SDK's `jsonSchema()` helper. --- # Durable session reconnect A **durable agent run** keeps executing **server side** even when the browser disconnects: the backend persists each streamed SSE frame with an `id: ` cursor line, and exposes a read-only endpoint that replays everything after a cursor and then live-tails. This is the shape of any long-running, resumable agent execution: Claude Managed agents are one example, but so is any async/background agent run whose turns the backend keeps and can re-stream. The mechanism here is backend-agnostic; all it requires is the SSE cursor and a replay-from-cursor endpoint. This guide covers the **client side**. By default Persona finalizes the assistant message the instant the SSE connection drops (tab reload, laptop sleep, a network blip, an upstream stream timeout), so the user sees a truncated answer even though the server has more. With durable reconnect wired up, the widget instead reads the cursor off the wire, recognizes a real drop (as opposed to a graceful finish or an intentional pause), and reconnects to replay the missed frames and keep filling the same bubble. The feature **self-gates** on the wire, not on a specific backend. It only arms when the stream actually carries SSE `id:` lines and an `executionId`, which only a durable, resumable execution emits (with Runtype's Claude Managed lane, that is a saved agent dispatched with a `conversationId`). Streams with no `id:` lines (plain flow dispatch, the non-durable inline lane, or any backend that does not stamp a cursor) never form a resume handle and finalize on drop exactly as before. Shipping the config is safe even on pages that never hit a durable lane. --- ## The three coordinates Reconnecting needs three pieces of addressing: | Coordinate | Identifies | Where it comes from | | --- | --- | --- | | `(agentId, conversationId)` | the backend session that owns this turn | host-known (you already have both) | | `executionId` | which turn to rejoin | comes off the stream (`agentMetadata.executionId`) | | `after=` | the replay cursor | the highest SSE `id:` seq the widget has applied | The widget tracks `executionId` and `lastEventId` for you. `agentId` / `conversationId` are **host owned**: the widget never sees the `conversationId`, it lives only in the closure you give to `reconnectStream` (and to `customFetch`). Those coordinates address a replay-from-cursor endpoint. The Runtype API provides one; a self-hosted async-agent backend exposes an equivalent of the same shape: ``` GET /v1/agents/{agentId}/executions/{executionId}/events?conversationId=&after= ``` It replays the durable log where `seq > after`, then live-tails if the turn is still running, else closes after the replay. The response is `text/event-stream` with the **same** wire vocabulary as the live stream, so the widget consumes it with the same parser. Any backend that can replay an execution's frames after a cursor and keep tailing works the same way. --- ## Step 1: in-session reconnect (network blip, sleep, timeout) For drops where the page is still alive, you only need one config hook: `reconnectStream`. It is the host-owned reconnect transport, symmetric to `customFetch`. When a durable stream drops mid-turn, the widget calls it, pipes the returned `text/event-stream` `Response` through its normal event pipeline, and resumes. ```ts createAgentExperience(el, { apiUrl: "/api/chat/dispatch", // ...your customFetch / getHeaders / theme / launcher reconnectStream: ({ executionId, after, signal }) => fetch( `${baseUrl}/v1/agents/${agentId}/executions/${executionId}` + `/events?conversationId=${conversationId}&after=${after}`, { headers: { Authorization: `Bearer ${token}`, "X-Persona-Version": personaVersion, }, signal, }, ), }); ``` Resolve with the events `Response`. Throw, or resolve with a non-ok response, to signal that this attempt failed: the widget then backs off and retries, and gives up after the bounded attempts. That is all you need for the in-session case. The widget: - enters the `resuming` state and keeps the in-progress bubble open (it is not finalized), - retries with exponential backoff (default `[1000, 2000, 4000, 8000, 8000]` ms, about 5 attempts over about 30 seconds), - also attempts immediately on tab refocus (`visibilitychange`) and on the browser coming back `online`, - seeds the resumed bubble with the text already shown, so the replayed post-cursor deltas append rather than overwrite, - on the graceful terminal, finalizes the message and returns to `idle`. Tune the backoff if you want: ```ts reconnect: { maxAttempts: 8, backoffMs: [500, 1000, 2000, 4000] }, ``` If `reconnectStream` is not configured, a durable drop finalizes the message just like today: in-session reconnect is purely additive. --- ## Step 2: survive a tab reload (the persistence handshake) An in-session drop reuses the live session object. A full tab reload throws it away, so to resume after a reload the resume handle has to be **persisted** and replayed on boot. Two seams cover this, and both keep the host as the single owner of all three coordinates (the `conversationId` is already yours): ```ts createAgentExperience(el, { // ...reconnectStream as above // Called whenever the resume handle changes: created when a durable turn // starts streaming, advanced as the cursor climbs (throttled), and null when // the turn finishes, errors, or is torn down. Persist it next to your // conversationId. onExecutionState: (handle) => { if (handle) { saveResume(conversationId, { executionId: handle.executionId, after: handle.lastEventId, }); } else { clearResume(conversationId); } }, // On the next mount, if you read back a non-terminal handle, pass it here. // The widget enters `resuming` immediately and replays from `after` into the // restored conversation. resume: savedResume?.executionId ? { executionId: savedResume.executionId, after: savedResume.after } : undefined, }); ``` The `handle` passed to `onExecutionState` is a `ResumableHandle`: ```ts type ResumableHandle = { executionId: string; // the durable turn lastEventId: string; // the ?after= cursor assistantMessageId: string; // the open bubble (internal bookkeeping) status: "running"; }; ``` Persist only `executionId` and `lastEventId` (alongside the `conversationId` you already store). The `conversationId` is what ties the persisted handle back to the right backend session, and your `reconnectStream` closure supplies it on reconnect. Order on boot: restore the conversation history first (your `initialMessages` or `storageAdapter`), then pass `resume`. The widget reopens the trailing assistant bubble and the replay (`seq > after`) appends to it. --- ## States, copy, and events While reconnecting, the widget surfaces two statuses in addition to the usual `idle` / `connecting` / `connected` / `error`: - `paused`: a durable stream dropped and a reconnect is pending. - `resuming`: a reconnect attempt is in flight. The in-progress bubble and the typing indicator stay visible throughout. Override the status copy: ```ts statusIndicator: { pausedText: "Connection lost, hold on…", resumingText: "Reconnecting…", }, ``` Three controller events let you react (analytics, a custom banner, a toast): ```ts widget.on("stream:paused", (e) => { /* e.executionId, e.after */ }); widget.on("stream:resuming", (e) => { /* e.executionId, e.after, e.attempt */ }); widget.on("stream:resumed", (e) => { /* e.executionId, e.after */ }); ``` And `controller.reconnect()` triggers a manual retry (for a "Reconnect" button), which also short-circuits the current backoff if one is already running. --- ## How it stays correct - **No gaps, no dupes.** The cursor is the SSE `id:` line (the durable row seq). The widget advances it only on frames it has fully applied, and the server replays strictly `seq > after`, so the replay never overlaps what you already saw. The reconnect seeds the bubble with the already-shown text, so post-cursor deltas append cleanly. - **Drop vs. finish vs. pause.** A graceful end (`execution_complete`) finalizes and clears the handle. An intentional pause (an `ask_user_question` or approval `await`) is left parked, not reconnected. Only a stream that ends with neither, while a resumable handle is live and the user did not cancel, is treated as a drop. - **No duplicate execution.** The reconnect endpoint is a read-only attach: the live turn keeps its single owner, so two tabs watching the same run fan out from one backend session without double-running tools. (This depends on the backend's attach being read-only; it holds for Runtype's session-owner model.) --- ## Caveats - **Durable lane only.** Reconnect arms only on streams that carry `id:` lines and an `executionId`, i.e. a backend running a resumable, server-persisted execution and stamping the cursor. For Runtype's Claude Managed lane that means a saved agent dispatched with a stable `conversationId`; a different backend (an async/background agent runner of your own) just needs to emit the same cursor and expose a replay-from-cursor endpoint. - **`conversationId` is host owned.** The widget never receives it; it lives in your `reconnectStream` / `customFetch` closure and in your own persistence next to the resume handle. - **The cursor is per durable row.** A single row that expands into several frames (some media or artifact rows) could in principle split on a TCP boundary; the widget advances the cursor only on fully-parsed frames to bound this. Plain text deltas, the common case, are one frame per row. --- # Proxy Documentation ## Vanilla Agent Proxy Proxy server library for `@runtypelabs/persona` widget. Handles flow and server-pinned agent configuration, CORS, feedback collection, WebMCP/client-tool forwarding, `/resume` continuations, and secure forwarding to Runtype. ### Installation ```bash npm install @runtypelabs/persona-proxy ``` ### Usage The proxy server handles server-side flow/agent configuration and forwards requests to Runtype. It mounts both the dispatch endpoint (default `/api/chat/dispatch`) and a matching child resume endpoint (`/api/chat/dispatch/resume`) so browser-executed LOCAL tools such as WebMCP page tools, `ask_user_question`, and `suggest_replies` can resume a paused execution. You can configure dispatch in five ways: **Option 1: Use default flow (recommended for getting started)** ```ts // api/chat.ts import { createChatProxyApp } from '@runtypelabs/persona-proxy'; export default createChatProxyApp({ path: '/api/chat/dispatch', allowedOrigins: ['https://www.example.com'] }); ``` **Option 2: 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 3: 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}}" } } ] } }); ``` **Option 4: Reference a Runtype agent ID** ```ts import { createChatProxyApp } from '@runtypelabs/persona-proxy'; export default createChatProxyApp({ path: '/api/chat/dispatch', allowedOrigins: ['https://www.example.com'], agentId: 'agent_abc123' }); ``` **Option 5: Define a server-pinned agent** ```ts import { createChatProxyApp } from '@runtypelabs/persona-proxy'; export default createChatProxyApp({ path: '/api/chat/dispatch', allowedOrigins: ['https://www.example.com'], agentConfig: { name: 'Shopping Assistant', model: 'nemotron-3-ultra-550b-a55b', systemPrompt: 'You are a concise shopping assistant.', loopConfig: { maxTurns: 6 } } }); ``` **Hosting on Vercel:** ```ts import { createVercelHandler } from '@runtypelabs/persona-proxy'; export default createVercelHandler({ allowedOrigins: ['https://www.example.com'], flowId: 'flow_abc123' // Optional }); ``` ### Configuration Options | Option | Type | Description | | --- | --- | --- | | `upstreamUrl` | `string` | Runtype API endpoint (defaults to `https://api.runtype.com/v1/dispatch`) | | `apiKey` | `string` | Runtype API key (defaults to `RUNTYPE_API_KEY` environment variable) | | `path` | `string` | Proxy endpoint path (defaults to `/api/chat/dispatch`) | | `allowedOrigins` | `string[]` | CORS allowed origins | | `requestGuard` | `ProxyRequestGuard` | Optional authorization/rate-limit hook run before JSON parsing, feedback handlers, or upstream requests. Return a `Response` to deny. | | `maxRequestBodyBytes` | `number` | Optional positive-integer UTF-8 JSON body limit for dispatch, resume, and feedback. Disabled by default. | | `flowId` | `string` | Runtype flow ID to use | | `flowConfig` | `RuntypeFlowConfig` | Custom flow configuration | | `feedbackPath` | `string` | Message-feedback endpoint path. Default: `/api/feedback`. | | `onFeedback` | `(feedback) => Promise \| void` | Optional handler for upvote/downvote payloads. | | `previewOriginPattern` | `RegExp \| false` | Additional dynamic preview-origin allowlist (defaults to `https://*.vercel.app`; disable with `false`). | | `agentId` | `string` | Runtype agent ID to use. Mutually exclusive with `flowId`, `flowConfig`, and `agentConfig`. | | `agentConfig` | `AgentConfig` | Server-pinned agent configuration. Mutually exclusive with `flowId`, `flowConfig`, and `agentId`. | Use `requestGuard` to connect the proxy to your authenticated session and shared rate-limit store. Guard responses are returned with their status, body, and headers intact: ```ts const app = createChatProxyApp({ maxRequestBodyBytes: 16 * 1024 * 1024, requestGuard: async ({ request, kind }) => { const user = await authenticate(request); if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 }); const limit = await rateLimiter.check(`${user.id}:${kind}`); if (!limit.allowed) { return Response.json( { error: 'Too many requests' }, { status: 429, headers: { 'Retry-After': String(limit.retryAfter) } } ); } } }); ``` The body limit checks both a valid declared `Content-Length` and the actual UTF-8 byte count. Use a limit large enough for base64-encoded attachments. ### WebMCP and built-in client tools For flow-dispatch and server-agent requests, the proxy preserves `clientTools[]` from the widget payload and forwards them upstream so Runtype can register browser-local tools for that turn. Tool results are sent by the widget to `${path}/resume`, and the proxy forwards that body to the upstream `/resume` endpoint using the same API key. A non-server-agent route rejects a client-supplied `agent` with HTTP 400. Configure `agentId` or `agentConfig` to pin the agent on the server. If browser-selected agent definitions are intentional, send them to a separate backend that authenticates and authorizes that capability instead of relaying them on the proxy's API key. Server-agent routes (`agentId` or `agentConfig`) ignore any client-supplied `agent` field. The browser can contribute messages, `clientTools[]`, `metadata`, `context`, and `inputs`; the model, system prompt, tools, and loop config stay pinned on the server. ### CORS behavior - If `allowedOrigins` is omitted or empty, the proxy reflects the request origin (or `*`). - If `allowedOrigins` is set, exact matches are allowed. - `NODE_ENV=development` reflects local dev origins even when they are not in `allowedOrigins`; an unset `NODE_ENV` is treated as production. - Vercel preview deployments (`VERCEL_ENV=preview`) and origins matching `previewOriginPattern` are reflected so per-branch preview URLs work without enumerating them. CORS is a browser policy, not authentication. Omitting `allowedOrigins` is permissive and does not prevent direct HTTP callers from using the proxy. ### Feedback endpoint `messageActions` feedback can POST to `feedbackPath` (default `/api/feedback`). The built-in handler validates `type` (`upvote`/`downvote`) and `messageId`, adds a timestamp, logs in development, and then calls `onFeedback` if provided. ### Environment Setup Add `RUNTYPE_API_KEY` to your environment. The proxy constructs the Runtype payload (including flow configuration/client tools) and streams the response back to the client. ### Building ```bash pnpm build ``` This generates: - `dist/index.js` (ESM) - `dist/index.cjs` (CJS) - Type definitions in `dist/index.d.ts`