diff --git a/apps/agent/main.py b/apps/agent/main.py
index 35aadda..62245c1 100644
--- a/apps/agent/main.py
+++ b/apps/agent/main.py
@@ -10,6 +10,7 @@
from src.query import query_data
from src.todos import AgentState, todo_tools
from src.form import generate_form
+from src.templates import template_tools
from skills import load_all_skills
# Load all visualization skills
@@ -17,7 +18,7 @@
agent = create_agent(
model=ChatOpenAI(model="gpt-5.4-2026-03-05"),
- tools=[query_data, *todo_tools, generate_form],
+ tools=[query_data, *todo_tools, generate_form, *template_tools],
middleware=[CopilotKitMiddleware()],
state_schema=AgentState,
system_prompt=f"""
@@ -47,6 +48,28 @@
Follow the skills below for how to produce high-quality visuals:
{_skills_text}
+
+ ## UI Templates
+
+ Users can save generated UIs as reusable templates and apply them later.
+ You have backend tools: `save_template`, `list_templates`, `apply_template`, `delete_template`.
+
+ **When a user asks to apply/recreate a template with new data:**
+ Check `pending_template` in state — the frontend sets this when the user picks a template.
+ If `pending_template` is present (has `id` and `name`):
+ 1. Call `apply_template(template_id=pending_template["id"])` to retrieve the HTML
+ 2. Take the returned HTML and COPY IT EXACTLY, only replacing the data values
+ (names, numbers, dates, labels, amounts) to match the user's message
+ 3. Render the modified HTML using `widgetRenderer`
+ 4. Call `clear_pending_template` to reset the pending state
+
+ If no `pending_template` is set but the user mentions a template by name, use
+ `apply_template(name="...")` instead.
+
+ CRITICAL: Do NOT rewrite or generate HTML from scratch. Take the original HTML string,
+ find-and-replace ONLY the data values, and pass the result to widgetRenderer.
+ This preserves the exact layout and styling of the original template.
+ For bar/pie chart templates, use `barChart` or `pieChart` component instead.
""",
)
diff --git a/apps/agent/src/templates.py b/apps/agent/src/templates.py
new file mode 100644
index 0000000..7eb1605
--- /dev/null
+++ b/apps/agent/src/templates.py
@@ -0,0 +1,186 @@
+from langchain.tools import ToolRuntime, tool
+from langchain.messages import ToolMessage
+from langgraph.types import Command
+from typing import Any, Optional, TypedDict
+import uuid
+from datetime import datetime
+
+
+class UITemplate(TypedDict, total=False):
+ id: str
+ name: str
+ description: str
+ html: str
+ data_description: str
+ created_at: str
+ version: int
+ component_type: Optional[str]
+ component_data: Optional[dict[str, Any]]
+
+
+@tool
+def save_template(
+ name: str,
+ description: str,
+ html: str,
+ data_description: str,
+ runtime: ToolRuntime,
+) -> Command:
+ """
+ Save a generated UI as a reusable template.
+ Call this when the user wants to save a widget/visualization they liked for reuse later.
+
+ Args:
+ name: Short name for the template (e.g. "Invoice", "Dashboard")
+ description: What the template displays or does
+ html: The raw HTML string of the widget to save as a template
+ data_description: Description of the data shape this template expects
+ """
+ templates = list(runtime.state.get("templates", []))
+
+ template: UITemplate = {
+ "id": str(uuid.uuid4()),
+ "name": name,
+ "description": description,
+ "html": html,
+ "data_description": data_description,
+ "created_at": datetime.now().isoformat(),
+ "version": 1,
+ }
+ templates.append(template)
+
+ return Command(update={
+ "templates": templates,
+ "messages": [
+ ToolMessage(
+ content=f"Template '{name}' saved successfully (id: {template['id']})",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+
+@tool
+def list_templates(runtime: ToolRuntime):
+ """
+ List all saved UI templates. Returns template summaries (id, name, description, data_description).
+ """
+ templates = runtime.state.get("templates", [])
+ return [
+ {
+ "id": t["id"],
+ "name": t["name"],
+ "description": t["description"],
+ "data_description": t["data_description"],
+ "version": t["version"],
+ }
+ for t in templates
+ ]
+
+
+@tool
+def apply_template(name: str = "", template_id: str = "", runtime: ToolRuntime = None):
+ """
+ Retrieve a saved template's HTML so you can adapt it with new data.
+ After calling this, generate a NEW widget in the same style and render via widgetRenderer.
+
+ This tool automatically checks for a pending_template in state (set by the
+ frontend when the user picks a template from the library). If pending_template
+ is present, it takes priority over name/template_id arguments.
+
+ Args:
+ name: The name of the template to apply (fallback if no pending_template)
+ template_id: The ID of the template to apply (fallback if no pending_template)
+ """
+ templates = runtime.state.get("templates", [])
+
+ # Check pending_template from frontend first — this is the most reliable source
+ pending = runtime.state.get("pending_template")
+ if pending and pending.get("id"):
+ template_id = pending["id"]
+
+ # Look up by ID first
+ if template_id:
+ for t in templates:
+ if t["id"] == template_id:
+ return {
+ "name": t["name"],
+ "description": t["description"],
+ "html": t["html"],
+ "data_description": t.get("data_description", ""),
+ }
+ return {"error": f"Template with id '{template_id}' not found"}
+
+ # Look up by name (most recent match)
+ if name:
+ matches = [t for t in templates if t["name"].lower() == name.lower()]
+ if matches:
+ t = max(matches, key=lambda x: x.get("created_at", ""))
+ return {
+ "name": t["name"],
+ "description": t["description"],
+ "html": t["html"],
+ "data_description": t.get("data_description", ""),
+ }
+ return {"error": f"No template named '{name}' found"}
+
+ return {"error": "Provide either a name or template_id"}
+
+
+@tool
+def delete_template(template_id: str, runtime: ToolRuntime) -> Command:
+ """
+ Delete a saved UI template.
+
+ Args:
+ template_id: The ID of the template to delete
+ """
+ templates = list(runtime.state.get("templates", []))
+ original_len = len(templates)
+ templates = [t for t in templates if t["id"] != template_id]
+
+ if len(templates) == original_len:
+ return Command(update={
+ "messages": [
+ ToolMessage(
+ content=f"Template with id '{template_id}' not found",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+ return Command(update={
+ "templates": templates,
+ "messages": [
+ ToolMessage(
+ content=f"Template deleted successfully",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+
+@tool
+def clear_pending_template(runtime: ToolRuntime) -> Command:
+ """
+ Clear the pending_template from state after applying it.
+ Call this after you have finished applying a template.
+ """
+ return Command(update={
+ "pending_template": None,
+ "messages": [
+ ToolMessage(
+ content="Pending template cleared",
+ tool_call_id=runtime.tool_call_id,
+ )
+ ],
+ })
+
+
+template_tools = [
+ save_template,
+ list_templates,
+ apply_template,
+ delete_template,
+ clear_pending_template,
+]
diff --git a/apps/agent/src/todos.py b/apps/agent/src/todos.py
index b647fee..2d934b5 100644
--- a/apps/agent/src/todos.py
+++ b/apps/agent/src/todos.py
@@ -2,9 +2,11 @@
from langchain.tools import ToolRuntime, tool
from langchain.messages import ToolMessage
from langgraph.types import Command
-from typing import TypedDict, Literal
+from typing import Optional, TypedDict, Literal
import uuid
+from src.templates import UITemplate
+
class Todo(TypedDict):
id: str
title: str
@@ -12,8 +14,14 @@ class Todo(TypedDict):
emoji: str
status: Literal["pending", "completed"]
+class PendingTemplate(TypedDict, total=False):
+ id: str
+ name: str
+
class AgentState(BaseAgentState):
todos: list[Todo]
+ templates: list[UITemplate]
+ pending_template: Optional[PendingTemplate]
@tool
def manage_todos(todos: list[Todo], runtime: ToolRuntime) -> Command:
diff --git a/apps/app/src/app/globals.css b/apps/app/src/app/globals.css
index 0c98827..7c6d573 100644
--- a/apps/app/src/app/globals.css
+++ b/apps/app/src/app/globals.css
@@ -621,3 +621,24 @@ body, html {
@keyframes spin {
to { transform: rotate(360deg); }
}
+
+/* Template save animations */
+@keyframes tmpl-pop {
+ 0% { transform: scale(0.8); opacity: 0; }
+ 50% { transform: scale(1.05); }
+ 100% { transform: scale(1); opacity: 1; }
+}
+
+@keyframes tmpl-check {
+ to { stroke-dashoffset: 0; }
+}
+
+@keyframes tmpl-slideIn {
+ from { transform: translateY(-4px) scale(0.97); opacity: 0; }
+ to { transform: translateY(0) scale(1); opacity: 1; }
+}
+
+@keyframes chipIn {
+ from { transform: scale(0.9); opacity: 0; }
+ to { transform: scale(1); opacity: 1; }
+}
diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx
index 7994182..5f52618 100644
--- a/apps/app/src/app/page.tsx
+++ b/apps/app/src/app/page.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { ExampleLayout } from "@/components/example-layout";
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
import { ExplainerCardsPortal } from "@/components/explainer-cards";
+import { TemplateLibrary } from "@/components/template-library";
+import { TemplateChip } from "@/components/template-library/template-chip";
import { CopilotChat } from "@copilotkit/react-core/v2";
@@ -11,7 +13,9 @@ export default function HomePage() {
useGenerativeUIExamples();
useExampleSuggestions();
- // Widget bridge: handle openLink from widget iframes
+ const [templateDrawerOpen, setTemplateDrawerOpen] = useState(false);
+
+ // Widget bridge: handle messages from widget iframes
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type === "open-link" && typeof e.data.url === "string") {
@@ -22,6 +26,7 @@ export default function HomePage() {
return () => window.removeEventListener("message", handler);
}, []);
+
return (
<>
{/* Animated background */}
@@ -58,19 +63,38 @@ export default function HomePage() {
— powered by CopilotKit
-
- Get started
-
+
+ {/* Template Library toggle */}
+
+
+ Get started
+
+
@@ -84,6 +108,15 @@ export default function HomePage() {
+
+ {/* Template chip — portal renders above chat input */}
+
+
+ {/* Template Library Drawer */}
+ setTemplateDrawerOpen(false)}
+ />
>
);
}
diff --git a/apps/app/src/components/generative-ui/charts/bar-chart.tsx b/apps/app/src/components/generative-ui/charts/bar-chart.tsx
index 69102c8..aad11bc 100644
--- a/apps/app/src/components/generative-ui/charts/bar-chart.tsx
+++ b/apps/app/src/components/generative-ui/charts/bar-chart.tsx
@@ -1,6 +1,7 @@
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
import { z } from 'zod';
import { CHART_COLORS, CHART_CONFIG } from './config';
+import { SaveTemplateOverlay } from '../save-template-overlay';
export const BarChartProps = z.object({
title: z.string().describe("Chart title"),
@@ -16,7 +17,9 @@ export const BarChartProps = z.object({
type BarChartProps = z.infer;
export function BarChart({ title, description, data }: BarChartProps) {
- if (!data || !Array.isArray(data) || data.length === 0) {
+ const hasData = data && Array.isArray(data) && data.length > 0;
+
+ if (!hasData) {
return (
@@ -35,27 +38,34 @@ export function BarChart({ title, description, data }: BarChartProps) {
}));
return (
-
-
-
{title}
-
{description}
-
+
+
+
+
{title}
+
{description}
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/app/src/components/generative-ui/charts/pie-chart.tsx b/apps/app/src/components/generative-ui/charts/pie-chart.tsx
index 68f8fc6..8701b84 100644
--- a/apps/app/src/components/generative-ui/charts/pie-chart.tsx
+++ b/apps/app/src/components/generative-ui/charts/pie-chart.tsx
@@ -6,6 +6,7 @@ import {
} from "recharts";
import { z } from "zod";
import { CHART_COLORS, CHART_CONFIG } from "./config";
+import { SaveTemplateOverlay } from "../save-template-overlay";
export const PieChartProps = z.object({
title: z.string().describe("Chart title"),
@@ -21,7 +22,9 @@ export const PieChartProps = z.object({
type PieChartProps = z.infer
;
export function PieChart({ title, description, data }: PieChartProps) {
- if (!data || !Array.isArray(data) || data.length === 0) {
+ const hasData = data && Array.isArray(data) && data.length > 0;
+
+ if (!hasData) {
return (
@@ -44,43 +47,50 @@ export function PieChart({ title, description, data }: PieChartProps) {
}));
return (
-
-
-
{title}
-
- {description}
-
-
-
-
-
-
-
-
-
+
+
+
+
{title}
+
+ {description}
+
+
- {/* Legend */}
-
- {data.map((item, index) => (
-
-
- ))}
+
+
+
+
+ {/* Legend */}
+
+ {data.map((item, index) => (
+
+ ))}
+
-
+
);
}
diff --git a/apps/app/src/components/generative-ui/save-template-overlay.tsx b/apps/app/src/components/generative-ui/save-template-overlay.tsx
new file mode 100644
index 0000000..e071bc9
--- /dev/null
+++ b/apps/app/src/components/generative-ui/save-template-overlay.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import { useState, useCallback, useMemo, useRef, type ReactNode } from "react";
+import { useAgent } from "@copilotkit/react-core/v2";
+import { SEED_TEMPLATES } from "@/components/template-library/seed-templates";
+
+type SaveState = "idle" | "input" | "saving" | "saved";
+
+interface SaveTemplateOverlayProps {
+ /** Title used as default template name */
+ title: string;
+ /** Description stored with the template */
+ description: string;
+ /** Raw HTML to save (for widget renderer templates) */
+ html?: string;
+ /** Structured data to save (for chart templates) */
+ componentData?: Record
;
+ /** The component type that produced this (e.g. "barChart", "pieChart", "widgetRenderer") */
+ componentType: string;
+ /** Whether content has finished rendering — button hidden until true */
+ ready?: boolean;
+ children: ReactNode;
+}
+
+export function SaveTemplateOverlay({
+ title,
+ description,
+ html,
+ componentData,
+ componentType,
+ ready = true,
+ children,
+}: SaveTemplateOverlayProps) {
+ const { agent } = useAgent();
+ const [saveState, setSaveState] = useState("idle");
+ const [templateName, setTemplateName] = useState("");
+
+ // Capture pending_template at mount time — it may be cleared by the agent later
+ const pending = agent.state?.pending_template as { id: string; name: string } | null | undefined;
+ const sourceRef = useRef<{ id: string; name: string } | null>(null);
+ if (pending?.id && !sourceRef.current) {
+ sourceRef.current = pending;
+ }
+
+ // Check if this content matches an existing template:
+ // 1. Exact HTML match (seed templates rendered as-is)
+ // 2. Source template captured from pending_template (applied templates with modified data)
+ const matchedTemplate = useMemo(() => {
+ // First check source template from apply flow
+ if (sourceRef.current) {
+ const allTemplates = [
+ ...SEED_TEMPLATES,
+ ...((agent.state?.templates as { id: string; name: string }[]) || []),
+ ];
+ const source = allTemplates.find((t) => t.id === sourceRef.current!.id);
+ if (source) return source;
+ }
+ // Then check exact HTML match
+ if (!html) return null;
+ const normalise = (s: string) => s.replace(/\s+/g, " ").trim();
+ const norm = normalise(html);
+ const allTemplates = [
+ ...SEED_TEMPLATES,
+ ...((agent.state?.templates as { id: string; name: string; html: string }[]) || []),
+ ];
+ return allTemplates.find((t) => t.html && normalise(t.html) === norm) ?? null;
+ }, [html, agent.state?.templates]);
+
+ const handleSave = useCallback(() => {
+ const name = templateName.trim() || title || "Untitled Template";
+ setSaveState("saving");
+
+ const templates = agent.state?.templates || [];
+ const newTemplate = {
+ id: crypto.randomUUID(),
+ name,
+ description: description || title || "",
+ html: html || "",
+ component_type: componentType,
+ component_data: componentData || null,
+ data_description: "",
+ created_at: new Date().toISOString(),
+ version: 1,
+ };
+ agent.setState({ ...agent.state, templates: [...templates, newTemplate] });
+
+ setTemplateName("");
+ setTimeout(() => {
+ setSaveState("saved");
+ setTimeout(() => setSaveState("idle"), 1800);
+ }, 400);
+ }, [agent, templateName, title, description, html, componentData, componentType]);
+
+ return (
+
+ {/* Save as Template button — hidden until content is ready */}
+
+ {/* Saved confirmation */}
+ {saveState === "saved" && (
+
+ )}
+
+ {/* Saving spinner */}
+ {saveState === "saving" && (
+
+ )}
+
+ {/* Name input */}
+ {saveState === "input" && (
+
+ setTemplateName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSave();
+ if (e.key === "Escape") {
+ setSaveState("idle");
+ setTemplateName("");
+ }
+ }}
+ autoFocus
+ className="text-xs px-2 py-1 rounded-md outline-none"
+ style={{
+ width: 140,
+ background: "var(--color-background-secondary, #f5f5f5)",
+ color: "var(--text-primary, #1a1a1a)",
+ border: "1px solid var(--color-border-tertiary, rgba(0,0,0,0.1))",
+ }}
+ />
+
+
+
+ )}
+
+ {/* Idle: show save button (badge moved outside this container) */}
+ {saveState === "idle" && !matchedTemplate && (
+
+ )}
+
+
+ {/* Template name badge — shown above widget when matched */}
+ {saveState === "idle" && matchedTemplate && ready && (
+
+
+
+ {matchedTemplate.name}
+
+
+ )}
+
+ {children}
+
+ );
+}
diff --git a/apps/app/src/components/generative-ui/widget-renderer.tsx b/apps/app/src/components/generative-ui/widget-renderer.tsx
index f978385..3aa17fd 100644
--- a/apps/app/src/components/generative-ui/widget-renderer.tsx
+++ b/apps/app/src/components/generative-ui/widget-renderer.tsx
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { z } from "zod";
+import { SaveTemplateOverlay } from "./save-template-overlay";
// ─── Zod Schema (CopilotKit parameter contract) ─────────────────────
export const WidgetRendererProps = z.object({
@@ -24,7 +25,7 @@ export const WidgetRendererProps = z.object({
type WidgetRendererProps = z.infer;
// ─── Injected CSS: Theme Variables (Layer 3) ─────────────────────────
-const THEME_CSS = `
+export const THEME_CSS = `
:root {
--color-background-primary: #ffffff;
--color-background-secondary: #f7f6f3;
@@ -346,6 +347,40 @@ document.addEventListener('click', function(e) {
}
});
+// Listen for streaming content updates from parent
+window.addEventListener('message', function(e) {
+ if (e.source !== window.parent) return;
+ if (e.data && e.data.type === 'update-content') {
+ var content = document.getElementById('content');
+ if (content) {
+ // Strip script tags from HTML before inserting — scripts are handled separately below
+ var tmp = document.createElement('div');
+ tmp.innerHTML = e.data.html;
+ var incomingScripts = [];
+ tmp.querySelectorAll('script').forEach(function(s) {
+ incomingScripts.push({ src: s.src, text: s.textContent });
+ s.remove();
+ });
+ content.innerHTML = tmp.innerHTML;
+
+ // Execute only new scripts (not previously executed)
+ incomingScripts.forEach(function(scriptInfo) {
+ var key = scriptInfo.src || scriptInfo.text;
+ if (content.getAttribute('data-exec-' + btoa(key).slice(0, 16))) return;
+ content.setAttribute('data-exec-' + btoa(key).slice(0, 16), '1');
+ var newScript = document.createElement('script');
+ if (scriptInfo.src) {
+ newScript.src = scriptInfo.src;
+ } else {
+ newScript.textContent = scriptInfo.text;
+ }
+ content.appendChild(newScript);
+ });
+ reportHeight();
+ }
+ }
+});
+
// Auto-resize: report content height to host
function reportHeight() {
var content = document.getElementById('content');
@@ -361,7 +396,8 @@ setTimeout(function() { clearInterval(_resizeInterval); }, 15000);
`;
// ─── Document Assembly ───────────────────────────────────────────────
-function assembleDocument(html: string): string {
+/** Empty shell or shell with initial content — iframe loads once, content streamed via postMessage */
+function assembleShell(initialHtml: string = ""): string {
return `
@@ -387,7 +423,7 @@ function assembleDocument(html: string): string {
- ${html}
+ ${initialHtml}