Skip to main content

Create HTML artifacts

Stream custom HTML content with html_artifact(...). HTML artifacts are rendered inline in the chat and can be converted to dashboard widgets by users.

Reference implementation in this GitHub repository.

Charts

Architecture

Emit HTML artifacts so custom visualizations render below the answer. HTML content is sanitized with DOMPurify before rendering for security.

agents.json configuration:

return JSONResponse(content={
"vanilla_agent_html": {
"name": "HTML Artifacts Agent",
"description": "An example agent that produces HTML artifacts rendered inline in the chat.",
"endpoints": {"query": "/v1/query"},
"features": {
"streaming": True,
"widget-dashboard-select": False,
"widget-dashboard-search": False,
},
}
})

Query flow

  • Process user request and prepare HTML content
  • Stream explanatory text with message_chunk()
  • Create HTML content as a string (inline styles supported)
  • Emit html_artifact() events with proper configuration
  • HTML renders inline below streamed content
  • Users can click widget icon to add artifacts to dashboard

SSE event format

HTML artifacts are sent as SSE events with the following structure:

{
"event": "copilotMessageArtifact",
"data": {
"type": "html",
"uuid": "unique-id",
"name": "artifact_name",
"description": "A description of the artifact",
"content": "<div>Your HTML content here</div>"
}
}

Core logic

import json
import uuid
from typing import AsyncGenerator
from openbb_ai import message_chunk
from openbb_ai.models import QueryRequest
from sse_starlette.sse import EventSourceResponse

def html_artifact(content: str, name: str, description: str) -> dict:
"""
Create an HTML artifact SSE event.

Args:
content: The HTML content to render
name: A unique name for the artifact
description: A description of the artifact

Returns:
SSE event dict with type="html" artifact
"""
return {
"event": "copilotMessageArtifact",
"data": json.dumps({
"type": "html",
"uuid": str(uuid.uuid4()),
"name": name,
"description": description,
"content": content,
}),
}

# Example HTML template
DASHBOARD_CARD_HTML = """
<div style="font-family: system-ui, -apple-system, sans-serif; padding: 24px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 16px; color: white; max-width: 400px;">
<h2 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 600;">Portfolio Summary</h2>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div style="background: rgba(255,255,255,0.15); padding: 16px; border-radius: 12px;">
<div style="font-size: 28px; font-weight: bold;">$124.5K</div>
<div style="font-size: 13px; opacity: 0.8; margin-top: 4px;">Total Value</div>
</div>
<div style="background: rgba(255,255,255,0.15); padding: 16px; border-radius: 12px;">
<div style="font-size: 28px; font-weight: bold; color: #4ade80;">+12.3%</div>
<div style="font-size: 13px; opacity: 0.8; margin-top: 4px;">Today's Change</div>
</div>
</div>
</div>
"""

@app.post("/v1/query")
async def query(request: QueryRequest) -> EventSourceResponse:
async def execution_loop() -> AsyncGenerator[dict, None]:
client = openai.AsyncOpenAI()

# Stream the LLM response
async for event in await client.chat.completions.create(
model="gpt-4o",
messages=openai_messages,
stream=True,
):
if chunk := event.choices[0].delta.content:
yield message_chunk(chunk).model_dump()

# Emit HTML artifact
yield message_chunk("\n\nHere's a portfolio dashboard card:\n\n").model_dump()
yield html_artifact(
content=DASHBOARD_CARD_HTML,
name="portfolio_dashboard",
description="A portfolio summary dashboard card",
)

yield message_chunk(
"\n\nYou can click the widget icon to add this to your dashboard!"
).model_dump()

return EventSourceResponse(
content=execution_loop(),
media_type="text/event-stream",
)

Example templates

Metric cards

<div style="font-family: system-ui, -apple-system, sans-serif; display: flex; gap: 16px; flex-wrap: wrap;">
<div style="background: #f8fafc; border: 1px solid #e2e8f0; padding: 20px; border-radius: 12px; min-width: 140px;">
<div style="color: #64748b; font-size: 13px; margin-bottom: 8px;">Revenue</div>
<div style="font-size: 24px; font-weight: 600; color: #0f172a;">$2.4M</div>
<div style="color: #22c55e; font-size: 13px; margin-top: 4px;">↑ 14.2%</div>
</div>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; padding: 20px; border-radius: 12px; min-width: 140px;">
<div style="color: #64748b; font-size: 13px; margin-bottom: 8px;">Users</div>
<div style="font-size: 24px; font-weight: 600; color: #0f172a;">48.2K</div>
<div style="color: #22c55e; font-size: 13px; margin-top: 4px;">↑ 8.1%</div>
</div>
</div>

Alert box

<div style="font-family: system-ui, -apple-system, sans-serif; background: #fef3c7; border: 1px solid #f59e0b; border-radius: 12px; padding: 16px; display: flex; gap: 12px; align-items: flex-start; max-width: 500px;">
<div style="background: #f59e0b; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-weight: bold;">!</div>
<div>
<div style="font-weight: 600; color: #92400e; margin-bottom: 4px;">Market Alert</div>
<div style="color: #a16207; font-size: 14px; line-height: 1.5;">Unusual trading volume detected in AAPL. Volume is 3.2x higher than the 20-day average.</div>
</div>
</div>

Security considerations

The frontend sanitizes all HTML with DOMPurify before rendering. Keep the following in mind:

Element/AttributeStatus
<script> tagsStripped
<style> tagsStripped
<iframe>, <form>, <object>, <embed>Stripped
on* event handlers (onclick, onload, etc.)Stripped
Inline stylesSupported
Standard HTML elements (div, span, img, etc.)Supported

Best practices

  • Use inline styles for all styling (external stylesheets not supported)
  • Keep HTML self-contained with no external dependencies
  • Use system fonts for consistent cross-platform rendering
  • Test artifacts at different viewport sizes for responsiveness
  • Provide meaningful name and description for widget creation