@runtypelabs/persona
v4.34.0
Published
Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.
Readme
Streaming Agent Widget
Installable vanilla JavaScript widget for embedding a streaming AI assistant on any website.
Installation
npm install @runtypelabs/personaBuilding locally
pnpm builddist/index.js(ESM),dist/index.cjs(CJS), anddist/index.global.js(IIFE) provide different module formats.dist/widget.cssis the prefixed Tailwind bundle.dist/install.global.jsis the automatic installer script for easy script tag installation.dist/launcher.global.jsis the tiny critical launcher used by deferred script-tag installs before the full panel bundle loads.dist/webmcp-polyfill.jsis the lazy WebMCP polyfill chunk used by the IIFE bundle only whenconfig.webmcp.enabledis true and the page has nodocument.modelContext.
Using with modules
import '@runtypelabs/persona/widget.css';
import {
initAgentWidget,
createAgentExperience,
markdownPostprocessor,
preprocessStreamingMarkdown,
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, streaming }) =>
markdownPostprocessor(preprocessStreamingMarkdown(text, streaming))
});
// 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). |
| useShadowDom | boolean | Use Shadow DOM for style isolation (default: false). |
| onChatReady | () => void | Callback fired once the configured renderers and styles are ready for display. |
| windowKey | string | If provided, stores the controller on window[windowKey] for global access. Automatically cleaned up on destroy(). |
The returned controller is callable immediately. Await controller.ready when
you need the complete UI: Persona prepares its renderers and styles before
revealing the widget. Script-tag installs keep the launcher visible until this
finishes. An update that needs another renderer keeps the current UI until that
renderer is ready; ordinary updates remain synchronous. Read controller.ready
after the update to await its completion. Failed preparation rejects the promise;
calling update({}) retries missing modules. The installer reports failures through
onError and lets the launcher retry on the next click. This covers Persona's
renderer modules and styles, not external assets fetched by custom renderers.
Script-tag installs accept a managed renderLoading hook for custom loading UI,
with the same 300ms delay and automatic cleanup as the default indicator. See
Custom loading indicator.
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 frompostprocessMessage,markdownPostprocessor, anddirectivePostprocessor. If your custom postprocessor intentionally returns tags or attributes outside the built-in allowlist, providesanitize: (html) => ...; only setsanitize: falsefor fully trusted content.
Documentation
The full reference lives in docs/ and the theming guide:
- Extending Persona: 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: the
AgentWidgetPlugincontract, render/data/interaction hooks, global vs per-instance registration, lifecycle, and the@runtypelabs/persona/plugin-kithelpers - Contributing: current guidance for contributing plugins, themes, adapters, examples, and other customizations back to this monorepo
- Programmatic Control & Events: controller API, message hooks and injection, enriched DOM context, WebMCP page tools, DOM and controller events, state loading
- UI Features & Components: message actions and feedback, loading/idle indicators, approvals, built-in
ask_user_questionandsuggest_repliestools, dropdown menus, button utilities, dynamic forms - Script Tag Installation & Framework Integration: automatic installer, deferred launcher lifecycle hooks, manual script tag setup, React, Next.js, Remix, Gatsby, and Astro guides
- Configuration Reference: every config option: core, client token mode, agent mode, UI & theme, launcher/docking, layout, voice, WebMCP, tool calls, features, suggestions, state & storage
- Context Mentions:
@-mention sources, resolve lifecycle, chip vs inline display, menu positioning, slash commands, and render overrides - Stream Parser Configuration: JSON, XML, and plain-text stream parsers and custom parser factories
- Message Injection: full injection and component-directive reference
- Dynamic Forms: field schema, form styles, and recipes
- Code Generator:
@runtypelabs/persona/codegenoptions for CLI/server-side snippet generation - 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)
// 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
// 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
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
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:
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
awaitlocal-tool pauses and/resumecontinuations. - Tailwind classes are prefixed with
tvw-and scoped to[data-persona-root], so they won't collide with the host page. - Run
pnpm devfrom the repository root to boot the example Runtype proxy (examples/runtype-hono-proxy) and the vanilla demo (apps/web). - The proxy prefers port
43111but automatically selects the next free port if needed. features.askUserQuestion.exposeandsuggestions.followUps.exposeadvertise built-in LOCAL client tools throughclientTools[]; leaveexposeoff if the flow already declares those tools server-side.webmcp: { enabled: true }snapshots page-registered tools ondocument.modelContext, sends them asclientTools[], executes returnedwebmcp:*calls in the browser, and resumes the paused execution.
WebMCP compatibility
Persona supports host-installed @mcp-b/webmcp-polyfill v5 and v6 without
replacing their registry. Producer pages using v5 call
initializeWebMCPPolyfill(); pages using v6 call installWebMCP() before
registering tools. Both initializer exports also work with custom Persona
polyfill loaders. Tool execution supports both JSON-string and object-input
contracts with one invocation, not a failed-call retry.
When no registry exists, Persona lazily installs v5. This preserves the previous browser requirements and callback-result behavior. The demos use v6 explicitly; that runtime requires HTTPS/localhost and Chrome/Firefox 126+ or Safari 18+.
Only the current document's tools are exposed, even when the registry also discovers tools in related frames. The dispatch protocol addresses tools by name and does not support frame-qualified calls.
Callback semantics belong to the installed runtime. In v6, callbacks receive
(input, { signal }); validate input, forward the signal to cancellable work,
and return a JSON-serializable value. Return an MCP result with isError: true
for an actionable error message: v6 replaces thrown callback errors with a
generic error and rejects undefined and other non-serializable results. v5's
existing result conversion remains unchanged. Registration cleanup uses an
AbortSignal in both versions.
Custom Markdown rendering during streaming
postprocessMessage receives the original display text, including incomplete Markdown.
After applying your own text transforms, call preprocessStreamingMarkdown(text, streaming)
before your Markdown parser to use the same display repairs as the built-in renderer:
postprocessMessage: ({ text, streaming }) => {
const transformed = text.replace(/<answer>|<\/answer>/g, '');
return markdownPostprocessor(preprocessStreamingMarkdown(transformed, streaming));
}The helper returns completed text unchanged and does not change stored message content
or the callback's raw payload. It does not parse HTML or sanitize output; Persona's
configured sanitizer still runs after the callback. On the CDN build it is available as
AgentWidget.preprocessStreamingMarkdown. It uses the existing lazy Markdown parser
chunk when ready, with lightweight table repairs available before that chunk loads.
