Skip to content

Widget System & Tag Definitions

Complete reference for Pika's tag definition system that enables AI-driven custom UI components.

The widget system allows LLMs to generate custom UI elements by including special tags in their responses. Each tag is defined by a TagDefinition that specifies:

  • Widget type and rendering
  • When and where it can be used
  • Instructions for the LLM
  • Access control
import type {
TagDefinition,
TagDefinitionLite,
TagDefinitionWidget,
TagInstructionForLlm,
WidgetRenderingContexts
} from 'pika-shared/types/chatbot/chatbot-types';
import type {
WidgetAction,
WidgetMetadata,
WidgetMetadataState,
SpotlightWidgetDefinition,
WidgetCallbackContext
} from 'pika-shared/types/chatbot/webcomp-types';

Complete tag definition with all configuration.

interface TagDefinition<T extends TagDefinitionWidget> {
tag: string;
scope: string;
usageMode: 'global' | 'chat-app';
widget: T;
llmInstructionsMd?: string;
status: 'enabled' | 'disabled' | 'retired';
renderingContexts: WidgetRenderingContexts;
canBeGeneratedByLlm: boolean;
canBeGeneratedByTool: boolean;
description: string;
dontCacheThis?: boolean;
createdBy: string;
lastUpdatedBy: string;
createDate: string;
lastUpdate: string;
}

Fields:

  • tag - Tag name (e.g., 'chart', 'order-status')
  • scope - Namespace to prevent collisions (e.g., 'pika', 'custom')
  • usageMode - 'global' (auto-available) or 'chat-app' (explicit enable)
  • widget - Widget configuration (type and settings)
  • llmInstructionsMd - Markdown instructions injected into prompts
  • status - Lifecycle state
  • renderingContexts - Where widget can be rendered
  • canBeGeneratedByLlm - Whether LLM can use this tag
  • canBeGeneratedByTool - Whether tools can generate this tag
  • description - Admin-facing description

Minimal tag identifier.

interface TagDefinitionLite {
scope: string;
tag: string;
}

Used for referencing tags without full definition.

Four types of widgets are supported:

type TagDefinitionWidget =
| BuiltInWidget
| CustomCompiledInWidget
| WebComponentWidget
| PassThroughWidget;

Pika-provided widgets (chart, image, prompt).

interface BuiltInWidget {
type: 'built-in';
builtInType: 'chart' | 'image' | 'prompt';
}

Svelte components compiled into your app.

interface CustomCompiledInWidget {
type: 'custom-compiled-in';
}

References renderers in customRenderers registry.

Dynamically loaded web components.

interface WebComponentWidget {
type: 'web-component';
webComponent: {
s3Bucket: string;
s3Key: string;
encoding?: 'gzip' | 'none';
};
}

Non-rendering semantic markup.

interface PassThroughWidget {
type: 'pass-through';
}

Automatically available to all chat apps unless explicitly disabled.

const globalTag: TagDefinition<BuiltInWidget> = {
tag: 'chart',
scope: 'pika',
usageMode: 'global', // Available everywhere by default
status: 'enabled',
widget: {
type: 'built-in',
builtInType: 'chart'
},
// ... other fields
};

Disabling global tags per chat app:

// In chat app configuration
features: {
tags: {
enabled: true,
tagsDisabled: [
{ scope: 'pika', tag: 'chart' } // Disable charts in this app
]
}
}

Must be explicitly enabled per chat app.

const chatAppTag: TagDefinition<WebComponentWidget> = {
tag: 'order-status',
scope: 'acme',
usageMode: 'chat-app', // Requires explicit enable
status: 'enabled',
widget: {
type: 'web-component',
webComponent: {
s3Bucket: 'acme-widgets',
s3Key: 'order-status.js',
encoding: 'gzip'
}
},
// ... other fields
};

Enabling chat-app tags:

// In chat app configuration
features: {
tags: {
enabled: true,
tagsEnabled: [
{ scope: 'acme', tag: 'order-status' }
]
}
}

Control where widgets can be rendered.

interface WidgetRenderingContexts {
inline?: boolean;
canvas?: boolean;
dialog?: boolean;
spotlight?: boolean;
}

Contexts:

  • inline - Within message text flow
  • canvas - Full-width dedicated area
  • dialog - Modal/popup overlay
  • spotlight - Featured prominent display

Example:

renderingContexts: {
inline: true, // Can appear in text
canvas: true, // Can appear full-width
dialog: false, // Explicitly disabled for dialogs
spotlight: false // Explicitly disabled for spotlight
}

Guide the LLM on when and how to use tags.

type TagInstructionForLlm =
| { type: 'line'; text: string }
| {
type: 'block';
title: string;
lines: Array<{ type: 'line'; text: string }>;
};
llmInstructions: [
{
type: 'line',
text: 'Use the chart tag when you need to visualize numerical data'
},
{
type: 'block',
title: 'Chart Types',
lines: [
{ type: 'line', text: '- bar: For comparing categories' },
{ type: 'line', text: '- line: For showing trends over time' },
{ type: 'line', text: '- pie: For showing proportions' }
]
},
{
type: 'line',
text: 'Example: <chart type="bar" data=\'{"labels":["A","B"],"datasets":[{"data":[1,2]}]}\' />'
}
]

Instructions can also be provided as Markdown:

llmInstructionsMd: `
# Chart Tag
Use the chart tag when you need to visualize numerical data.
## Chart Types
- **bar**: For comparing categories
- **line**: For showing trends over time
- **pie**: For showing proportions
## Example
\`\`\`xml
<chart type="bar" data='{"labels":["A","B"],"datasets":[{"data":[1,2]}]}' />
\`\`\`
`
type TagStatus = 'enabled' | 'disabled' | 'retired';
  • enabled - Active and available
  • disabled - Temporarily hidden
  • retired - Permanently archived
const chartTag: TagDefinition<BuiltInWidget> = {
tag: 'chart',
scope: 'pika',
usageMode: 'global',
widget: {
type: 'built-in',
builtInType: 'chart'
},
llmInstructionsMd: `Use for data visualization. Supports bar, line, and pie charts.`,
status: 'enabled',
renderingContexts: {
inline: false,
canvas: true,
dialog: true,
spotlight: true
},
canBeGeneratedByLlm: true,
canBeGeneratedByTool: true,
description: 'Renders interactive charts',
createdBy: 'system',
lastUpdatedBy: 'system',
createDate: '2024-01-01T00:00:00Z',
lastUpdate: '2024-01-01T00:00:00Z'
};
const orderStatusTag: TagDefinition<WebComponentWidget> = {
tag: 'order-status',
scope: 'acme',
usageMode: 'chat-app',
widget: {
type: 'web-component',
webComponent: {
s3Bucket: 'acme-components',
s3Key: 'widgets/order-status-v1.js',
encoding: 'gzip'
}
},
llmInstructionsMd: `
# Order Status Widget
Display real-time order status with tracking information.
## Usage
\`\`\`xml
<order-status orderId="12345" />
\`\`\`
## Attributes
- **orderId** (required): The order ID to display
`,
status: 'enabled',
renderingContexts: {
inline: true,
canvas: true,
dialog: false,
spotlight: false
},
canBeGeneratedByLlm: true,
canBeGeneratedByTool: true,
description: 'Shows order status and tracking',
dontCacheThis: false,
createdBy: 'admin@acme.com',
lastUpdatedBy: 'admin@acme.com',
createDate: '2024-01-15T10:00:00Z',
lastUpdate: '2024-01-15T10:00:00Z'
};
const dataTableTag: TagDefinition<CustomCompiledInWidget> = {
tag: 'data-table',
scope: 'custom',
usageMode: 'chat-app',
widget: {
type: 'custom-compiled-in'
// References customRenderers['data-table']
},
llmInstructionsMd: `
Display tabular data with headers and rows.
Example:
\`\`\`xml
<data-table>
{
"headers": ["Name", "Age", "City"],
"rows": [
["John", "30", "NYC"],
["Jane", "25", "LA"]
]
}
</data-table>
\`\`\`
`,
status: 'enabled',
renderingContexts: {
inline: false,
canvas: true,
dialog: false,
spotlight: false
},
canBeGeneratedByLlm: true,
canBeGeneratedByTool: true,
description: 'Renders data tables',
createdBy: 'admin',
lastUpdatedBy: 'admin',
createDate: '2024-01-15T10:00:00Z',
lastUpdate: '2024-01-15T10:00:00Z'
};
const metadataTag: TagDefinition<PassThroughWidget> = {
tag: 'metadata',
scope: 'system',
usageMode: 'global',
widget: {
type: 'pass-through'
},
llmInstructionsMd: 'Use for semantic markup that should not render visually',
status: 'enabled',
renderingContexts: {
inline: true,
canvas: false,
dialog: false,
spotlight: false
},
canBeGeneratedByLlm: true,
canBeGeneratedByTool: true,
description: 'Semantic markup with no visual rendering',
createdBy: 'system',
lastUpdatedBy: 'system',
createDate: '2024-01-01T00:00:00Z',
lastUpdate: '2024-01-01T00:00:00Z'
};

Full access to tag definitions (requires admin permissions).

Create or Update:

POST /api/chat-admin/tagdef
{
"tagDefinition": { /* TagDefinition object */ },
"userId": "admin-user"
}

Delete:

DELETE /api/chat-admin/tagdef
{
"tagDefinition": { "tag": "my-tag", "scope": "custom" },
"userId": "admin-user"
}

Search (including disabled):

POST /api/chat-admin/tagdef/search
{
"includeInstructions": true,
"paginationToken": null
}

Read-only access to enabled tags.

Search (enabled only):

POST /api/chat/tagdef/search
{
"includeInstructions": false,
"paginationToken": null
}
order-status-widget.js
class OrderStatusWidget extends HTMLElement {
connectedCallback() {
const orderId = this.getAttribute('orderId');
this.render(orderId);
}
async render(orderId) {
const status = await this.fetchOrderStatus(orderId);
this.innerHTML = `
<div class="order-status">
<h3>Order #${orderId}</h3>
<p>Status: ${status.state}</p>
<p>Tracking: ${status.tracking}</p>
</div>
`;
}
async fetchOrderStatus(orderId) {
// Fetch from API
return { state: 'Shipped', tracking: '1Z999...' };
}
}
customElements.define('order-status-widget', OrderStatusWidget);
Terminal window
# Compress and upload
gzip order-status-widget.js
aws s3 cp order-status-widget.js.gz s3://my-bucket/widgets/ \
--content-encoding gzip \
--content-type application/javascript
const tagDef: TagDefinition<WebComponentWidget> = {
tag: 'order-status',
scope: 'custom',
usageMode: 'chat-app',
widget: {
type: 'web-component',
webComponent: {
s3Bucket: 'my-bucket',
s3Key: 'widgets/order-status-widget.js.gz',
encoding: 'gzip'
}
},
// ... other fields
};
// POST to /api/chat-admin/tagdef

Components can register themselves in spotlight programmatically at runtime without creating database tag definitions.

Register a web component as a spotlight widget from code.

manuallyRegisterSpotlightWidget(definition: SpotlightWidgetDefinition): void

Parameters:

interface SpotlightWidgetDefinition {
/** Tag name (e.g., 'my-widget') */
tag: string;
/** Scope/namespace (e.g., 'my-app') */
scope: string;
/** Display title in UI */
tagTitle: string;
/** Custom element name (optional, inferred if not provided) */
customElementName?: string;
/** Widget sizing configuration (optional) */
sizing?: WidgetSizing;
/** Component agent instructions (optional) */
componentAgentInstructionsMd?: Record<string, string>;
/**
* Auto-show widget immediately?
* - true: Widget appears in spotlight automatically
* - false: Widget registered but not shown until renderTag() called
* @default true
*/
autoCreateInstance?: boolean;
/**
* Display order in spotlight (lower = higher)
* @default 0
*/
displayOrder?: number;
/**
* Only allow one instance?
* @default true
*/
singleton?: boolean;
/**
* Show in unpinned widget menu?
* @default true
*/
showInUnpinnedMenu?: boolean;
/**
* Optional initial metadata (title, actions, icon) to apply when rendered.
* Metadata can be updated later via getWidgetMetadataAPI().
* @since 0.11.0
*/
metadata?: WidgetMetadata;
}

Behavior:

  • Registration is ephemeral - does not persist across page refreshes
  • Component must re-register on each page load
  • Merges with database-sourced tag definitions
  • Respects user preferences (unpinning, ordering)
  • Compatible with all spotlight features

Example:

import { getPikaContext } from 'pika-shared/util/wc-utils';
class MyWidget extends HTMLElement {
async connectedCallback() {
const context = await getPikaContext(this);
// Register in spotlight
context.chatAppState.manuallyRegisterSpotlightWidget({
tag: 'my-widget',
scope: 'acme',
tagTitle: 'My Widget',
customElementName: 'acme-my-widget',
displayOrder: 0,
autoCreateInstance: true
});
}
}

Common Use Cases:

// Conditional registration (admin-only)
if (context.appState.identity.isSiteAdmin) {
context.chatAppState.manuallyRegisterSpotlightWidget({
tag: 'admin-tools',
scope: 'internal',
tagTitle: 'Admin Tools'
});
}
// Feature-gated widget
const features = await getEnabledFeatures();
if (features.includes('beta-analytics')) {
context.chatAppState.manuallyRegisterSpotlightWidget({
tag: 'analytics',
scope: 'acme',
tagTitle: 'Analytics Dashboard',
displayOrder: 5
});
}
// Base widget for saved instances
context.chatAppState.manuallyRegisterSpotlightWidget({
tag: 'saved-chart',
scope: 'acme',
tagTitle: 'Saved Chart',
autoCreateInstance: false, // Don't show by default
singleton: false, // Allow multiple instances
showInUnpinnedMenu: false // Hide from menu
});

Best Practices:

Widgets can dynamically update their chrome (title bar, actions, loading state):

interface WidgetMetadata {
/** Widget title shown in chrome */
title: string;
/**
* Optional Lucide icon name (fetched automatically and set as iconSvg).
* Use snake-case: 'arrow-big-down' not 'arrowBigDown'
*/
lucideIconName?: string;
/** Optional icon SVG markup for the widget title */
iconSvg?: string;
/** Optional color for the widget icon (hex, rgb, or CSS color name) */
iconColor?: string;
/** Optional action buttons */
actions?: WidgetAction[];
/** Optional loading status */
loadingStatus?: {
loading: boolean;
loadingMsg?: string;
};
}

Action buttons that appear in widget chrome (spotlight, canvas, dialog):

interface WidgetAction {
/** Unique identifier for this action */
id: string;
/** Tooltip/label for the action (also button text in dialog context) */
title: string;
/** SVG markup string for the icon */
iconSvg: string;
/** Whether action is currently disabled */
disabled?: boolean;
/** If true, renders as default/prominent button (dialog footer) */
primary?: boolean;
/**
* Handler called when action is clicked.
* Automatically receives widget context with element, instanceId, and full Pika context.
* @since 0.11.0 - Callback now receives WidgetCallbackContext parameter
*/
callback: (context: WidgetCallbackContext) => void | Promise<void>;
}

Context automatically provided to action button callbacks:

interface WidgetCallbackContext {
/** The web component element */
element: HTMLElement;
/** The unique instance ID assigned to this component */
instanceId: string;
/** The full Pika context with app state, chat state, and more */
context: PikaWCContext;
}
import { extractIconSvg } from 'pika-shared/util/icon-utils';
// Define an action
const refreshAction: WidgetAction = {
id: 'refresh',
title: 'Refresh Data',
iconSvg: await extractIconSvg('refresh-cw', 'lucide'),
disabled: false,
callback: async ({ element, instanceId, context }) => {
// Access the widget element
const widget = element as MyWidget;
// Show loading state
const metadata = context.chatAppState.getWidgetMetadataAPI(
'my-app',
'my-widget',
instanceId,
context.renderingContext
);
metadata.setLoadingStatus(true, 'Refreshing...');
try {
// Refresh data
await widget.refreshData();
context.appState.showToast('Data refreshed!', { type: 'success' });
} catch (error) {
context.appState.showToast('Refresh failed', { type: 'error' });
} finally {
metadata.setLoadingStatus(false);
}
}
};
// Register metadata with action
const metadata: WidgetMetadata = {
title: 'My Widget',
lucideIconName: 'chart-line',
actions: [refreshAction]
};
// Apply metadata when rendering
await context.chatAppState.renderTag('my-app.my-widget', 'canvas', { data }, metadata);

The renderTag() method now accepts optional metadata:

// Render with initial metadata
await chatAppState.renderTag(
'acme.dashboard',
'canvas',
{ userId: '123' },
{
title: 'Sales Dashboard',
lucideIconName: 'bar-chart',
actions: [
{
id: 'export',
title: 'Export Data',
iconSvg: await extractIconSvg('download', 'lucide'),
callback: async ({ context }) => {
// Export logic here
}
}
]
}
);

Metadata can be:

  • Passed initially via renderTag() (as shown above)
  • Updated dynamically via getWidgetMetadataAPI().setMetadata()
  • Applied when manually registering spotlight widgets via SpotlightWidgetDefinition.metadata

When renderTag() is called with 'canvas' or 'dialog' context for a tag that doesn't exist, the system automatically generates a minimal tag definition:

// This works even if 'acme.custom-widget' isn't registered in the database
await chatAppState.renderTag('acme.custom-widget', 'canvas', { data });

Auto-generated tags include:

  • Minimal required fields (scope, tag, status)
  • The requested rendering context enabled
  • usageMode: 'chat-app'
  • status: 'enabled'
  1. Check tag is enabled: status === 'enabled'
  2. Verify usage mode and chat app configuration
  3. Check rendering context matches usage
  4. Ensure web component loaded successfully
  1. Review llmInstructionsMd clarity
  2. Check canBeGeneratedByLlm === true
  3. Verify tag is in chat app's enabled list
  4. Test with explicit examples in prompt
  1. Verify S3 bucket permissions
  2. Check S3 key path is correct
  3. Ensure CORS configured on bucket
  4. Verify encoding matches actual file