Custom Message Tags
This guide explains how to create custom renderers for XML tags in LLM responses and metadata handlers for processing non-visual tags in your Pika chat application.
This document describes the current implementation of custom message tags using compiled-in renderers. Pika is evolving toward a more powerful Tags Feature system that supports dynamic tag definitions, web components, and centralized management. The approach described here will continue to work and serves as the foundation for the new system.
Overview
When an LLM generates responses containing XML elements (e.g., <image>
, <download>
, <chart>
), Pika's message rendering system uses the XML tag name to find and instantiate the appropriate renderer component. This system allows you to create rich, interactive chat experiences with custom UI components and data processing.
This document covers the current implementation using compiled-in renderers that are registered directly in your application code. For the evolved approach using dynamic tag definitions and web components, see the Tags Feature documentation .
With custom message tags, you can create rich, interactive chat experiences that go far beyond simple text responses. The system is designed to be flexible and extensible, allowing you to build exactly the user experience your application needs.
How It Works
Message Processing Flow
- LLM Response: The LLM generates a response with XML tags
- Parsing: The message is parsed to identify XML segments
- Tag Identification: Each XML tag is mapped to a renderer or metadata handler
- Rendering: Tag renderers create UI components, metadata handlers call your custom function to cause some side effect
- Display: The final message is rendered with all components in place
Example LLM Response
<trace>{"id": "trace-123", "duration": 1.5, "tokens": 145}</trace>
Here's the weather data you requested:
<chart>{"type": "bar", "data": {"labels": ["Mon", "Tue", "Wed"], "datasets": [{"data": [20, 25, 22]}]}}</chart>
And here's the detailed report:
<download>{"s3Key": "reports/weather-2024.pdf", "title": "Weather Report"}</download>
In this example:
<chart>
renders an interactive chart component<download>
creates a download button<trace>
is processed by a metadata handler (doesn't render visually)
Custom Renderers
Defining Custom Renderers
Custom renderers are registered in apps/pika-chat/src/lib/client/features/chat/message-segments/custom-components/index.ts
:
import type { Component } from 'svelte';
import MyCustomRenderer from './MyCustomRenderer.svelte';
export const customRenderers: Record<string, Component<any>> = {
// Add your custom tag renderers here
mywidget: MyCustomRenderer,
datatable: DataTableRenderer,
'interactive-form': InteractiveFormRenderer
};
Renderer Component Interface
Each renderer component must accept these props:
interface Props {
segment: ProcessedTagSegment;
appState: AppState;
chatAppState: ChatAppState;
}
ProcessedTagSegment Properties
rawContent: string
- The text content between XML tagsstreamingStatus: 'pending' | 'completed' | 'error'
- Current streaming stateid: string
- Unique identifier for this segmenttagType: string
- The XML tag name (e.g., 'chart', 'image')
Key Considerations
- Streaming Support: Handle the
streamingStatus
to show loading states while content is being received - Error Handling: Gracefully handle malformed content or parsing errors
- JSON Parsing: Many tags contain JSON data that needs to be parsed
- Responsive Design: Ensure components work on different screen sizes
Example: Custom Data Table Renderer
<script lang="ts">
import type { AppState } from '$client/app/app.state.svelte';
import { ChatAppState } from '../../chat-app.state.svelte';
import type { ProcessedTagSegment } from '../segment-types';
interface Props {
segment: ProcessedTagSegment;
appState: AppState;
chatAppState: ChatAppState;
}
let { segment }: Props = $props();
let rawTagContent = $derived(segment.rawContent);
let showPlaceholder = $derived(segment.streamingStatus === 'pending');
let error = $state<string | null>(null);
let tableData = $state<{headers: string[], rows: string[][]} | null>(null);
$effect(() => {
if (showPlaceholder) return;
try {
const parsed = JSON.parse(rawTagContent);
if (!parsed.headers || !parsed.rows) {
error = 'Invalid table data format';
return;
}
tableData = parsed;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to parse table data';
}
});
</script>
<div class="my-4">
{#if showPlaceholder}
<div class="animate-pulse bg-gray-100 rounded-lg p-4 text-center text-gray-500">
Loading table...
</div>
{:else if error}
<div class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<p class="font-semibold">Table Error</p>
<p class="text-sm">{error}</p>
</div>
{:else if tableData}
<div class="overflow-x-auto bg-white rounded-lg shadow">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
{#each tableData.headers as header}
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{header}
</th>
{/each}
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each tableData.rows as row}
<tr>
{#each row as cell}
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{cell}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
Metadata Handlers
Metadata handlers process XML tags that don't render visually but perform side effects like adding data to the message or triggering actions.
Defining Metadata Handlers
import type { MetadataTagHandler } from '../segment-types';
export const customMetadataHandlers: Record<string, MetadataTagHandler> = {
analytics: analyticsHandler,
notification: notificationHandler
};
Metadata Handler Interface
type MetadataTagHandler = (segment: MetadataTagSegment, message: ChatMessageForRendering, chatAppState: ChatAppState, appState: AppState) => void;
Example: Analytics Metadata Handler
import type { MetadataTagHandler } from '../segment-types';
export const analyticsHandler: MetadataTagHandler = (segment, message, chatAppState, appState) => {
// Only process completed segments to avoid duplicate processing
if (segment.streamingStatus !== 'completed') return;
try {
const analyticsData = JSON.parse(segment.rawContent);
// Add analytics data to the message
if (!message.metadata) {
message.metadata = {};
}
message.metadata.analytics = analyticsData;
// Trigger analytics tracking
if (analyticsData.event && analyticsData.properties) {
// Send to your analytics service
trackEvent(analyticsData.event, analyticsData.properties);
}
} catch (error) {
console.error('Failed to process analytics metadata', error);
}
};
function trackEvent(event: string, properties: any) {
// Your analytics implementation
console.log('Analytics event:', event, properties);
}
Built-in Examples
Default Renderers
Pika includes several built-in renderers you can reference:
Image Renderer (image
)
- Purpose: Displays images from URLs
- Content: Image URL as plain text
- Features: Loading states, error handling, responsive sizing
Download Renderer (download
)
- Purpose: Creates download buttons for files
- Content: JSON with
s3Key
and optionaltitle
- Features: File download integration, S3 support
Chart Renderer (chart
)
- Purpose: Renders Chart.js charts
- Content: Chart.js configuration JSON
- Features: Dynamic chart loading, responsive design
Prompt Renderer (prompt
)
- Purpose: Creates clickable prompt buttons
- Content: Prompt text or JSON configuration
- Features: Click-to-send functionality
Default Metadata Handlers
Trace Handler (trace
)
- Purpose: Adds execution traces to messages
- Content: JSON with trace information
- Effect: Adds trace data to message.traces array for display at message top
Advanced Patterns
Interactive Components
Create components that can send new messages:
<script lang="ts">
let { segment, chatAppState }: Props = $props();
function handleButtonClick(action: string) {
chatAppState.sendMessage(`Execute action: ${action}`);
}
</script>
<div class="space-x-2">
<button onclick={() => handleButtonClick('approve')} class="bg-green-500 text-white px-4 py-2 rounded">
Approve
</button>
<button onclick={() => handleButtonClick('reject')} class="bg-red-500 text-white px-4 py-2 rounded">
Reject
</button>
</div>
State Management
Share state between renderers using the app state:
<script lang="ts">
let { segment, appState }: Props = $props();
// Access global state
let userPreferences = $derived(appState.user?.preferences);
// Update app state
function updatePreferences(newPrefs: any) {
if (appState.user) {
appState.user.preferences = { ...appState.user.preferences, ...newPrefs };
}
}
</script>
Text Renderer Override
You can override the default text renderer to customize how all non-XML content is displayed:
export const customRenderers: Record<string, Component<any>> = {
text: MyCustomTextRenderer
};
This affects all text content in messages, not just XML tags.
Best Practices
1. Handle Streaming States
Always check streamingStatus
and provide appropriate loading states:
- pending: the content is still streaming and isn't done
- complete: the content is done streaming
- error: something broke when streaming the content
{#if segment.streamingStatus === 'pending'}
<div class="animate-pulse">Loading...</div>
{:else}
<!-- Render actual content -->
{/if}
2. Error Handling
Gracefully handle malformed content (consider that if streamingStatus is 'pending' then your content will be incomplete and if JSON will be malformed until complete):
$effect(() => {
try {
const data = JSON.parse(rawTagContent);
// Process data
} catch (error) {
console.error('Parse error:', error);
errorMessage = 'Invalid data format';
}
});
3. Responsive Design
Ensure components work on all screen sizes:
<div class="overflow-x-auto max-w-full">
<!-- Your content -->
</div>
4. Unique IDs
Use segment.id for unique component identification:
<div id="component-{segment.id}">
<!-- Your content -->
</div>
5. Resource Cleanup
Clean up resources when components are destroyed:
<script lang="ts">
let chart: any = null;
$effect(() => {
// Initialize chart
return () => {
if (chart) {
chart.destroy();
}
};
});
</script>
Debugging
Enable debug logging to see tag processing:
console.log('Processing tag:', segment.tagType, segment.rawContent);
Evolution to Tags Feature System
The custom message tags system described in this document is evolving into the more powerful Tags Feature system . Here's how they relate:
Current System (This Document)
- Compiled-in renderers: Components are registered in your application code
- Direct registration: Uses
customRenderers
andcustomMetadataHandlers
objects - Code-based configuration: Changes require code updates and redeployment
Evolved System (Tags Feature)
- Tag definitions: Formal
TagDefinition
objects with metadata and instructions - Multiple widget types: Supports builtin, custom-compiled-in, web-component, and pass-through
- API management: Create and update tag definitions via REST APIs
- LLM instructions: Structured guidance for when and how LLMs should use tags
Migration Path
The systems work together during transition:
- Current custom renderers continue to work as-is
- Tag definitions can reference existing custom renderers using
widget.type: 'custom-compiled-in'
- Future web components will be loaded dynamically from S3 without code changes
Your existing custom renderers become the foundation for the evolved system - they don't need to be rewritten, just formalized with tag definitions.
Examples Repository
For more examples and starter templates, check the apps/pika-chat/src/lib/client/features/chat/message-segments/default-components/
directory, which contains the built-in renderers you can use as reference implementations.