BYOC — Hashbrown
Bring your own component library. Have the agent stream structured output and let Hashbrown's progressive JSON parser render it as React components in real time.
"""LlamaIndex agent backing the byoc-hashbrown demo.Emits hashbrown-shaped structured output that the ported HashBrownDashboardrenderer (`src/app/demos/byoc-hashbrown/hashbrown-renderer.tsx`) progressivelyparses via `@hashbrownai/react`'s `useJsonParser` + `useUiKit`. Mirrors`langgraph-python/src/agents/byoc_hashbrown_agent.py`.Wire format-----------`@hashbrownai/react`'s `useJsonParser(content, kit.schema)` expects the agentto stream a JSON object literal matching `kit.schema` — NOT the `<ui>...</ui>`XML-style examples shown inside `useUiKit({ examples })`. Those XML examplesare the hashbrown prompt DSL that hashbrown compiles into a schema descriptionwhen driving the LLM directly (e.g. `useUiChat`/`useUiCompletion`). Becausethis demo drives the LLM via the llamaindex workflow router instead, we mustmirror what hashbrown's own schema wire format looks like: { "ui": [ { "metric": { "props": { "label": "...", "value": "..." } } }, { "pieChart": { "props": { "title": "...", "data": "[{...}]" } } }, { "barChart": { "props": { "title": "...", "data": "[{...}]" } } }, { "dealCard": { "props": { "title": "...", "stage": "prospect", "value": 100000 } } }, { "Markdown": { "props": { "children": "## heading\\nbody" } } } ] }Every node is a single-key object `{tagName: {props: {...}}}`. The tag namesand prop schemas match `useSalesDashboardKit()` in`hashbrown-renderer.tsx`. `pieChart` and `barChart` receive `data` as aJSON-encoded string (this was intentional in PR #4252 to keep the schemastable under partial streaming)."""from __future__ import annotationsimport osfrom llama_index.llms.openai import OpenAIfrom llama_index.protocols.ag_ui.router import get_ag_ui_workflow_routerBYOC_HASHBROWN_SYSTEM_PROMPT = """\You are a sales analytics assistant that replies by emitting a single JSONobject consumed by a streaming JSON parser on the frontend.ALWAYS respond with a single JSON object of the form:{ "ui": [ { <componentName>: { "props": { ... } } }, ... ]}Do NOT wrap the response in code fences. Do NOT include any preface orexplanation outside the JSON object. The response MUST be valid JSON.Available components and their prop schemas:- "metric": { "props": { "label": string, "value": string } } A KPI card. `value` is a pre-formatted string like "$1.2M" or "248".- "pieChart": { "props": { "title": string, "data": string } } A donut chart. `data` is a JSON-encoded STRING (embedded JSON) of an array of {label, value} objects with at least 3 segments, e.g. "data": "[{\\"label\\":\\"Enterprise\\",\\"value\\":600000}]".- "barChart": { "props": { "title": string, "data": string } } A vertical bar chart. `data` is a JSON-encoded STRING of an array of {label, value} objects with at least 3 bars, typically time-ordered.- "dealCard": { "props": { "title": string, "stage": string, "value": number } } A single sales deal. `stage` MUST be one of: "prospect", "qualified", "proposal", "negotiation", "closed-won", "closed-lost". `value` is a raw number (no currency symbol or comma).- "Markdown": { "props": { "children": string } } Short explanatory text. Use for section headings and brief summaries. Standard markdown is supported in `children`.Rules:- Always produce plausible sample data when the user asks for a dashboard or chart — do not refuse for lack of data.- Prefer 3-6 rows of data in charts; keep labels short.- Use "Markdown" for short headings or linking sentences between visual components. Do not emit long prose.- Do not emit components that are not listed above.- `data` props on charts MUST be a JSON STRING — escape inner quotes.Example response (sales dashboard):{"ui":[{"Markdown":{"props":{"children":"## Q4 Sales Summary"}}},{"metric":{"props":{"label":"Total Revenue","value":"$1.2M"}}},{"metric":{"props":{"label":"New Customers","value":"248"}}},{"pieChart":{"props":{"title":"Revenue by Segment","data":"[{\\"label\\":\\"Enterprise\\",\\"value\\":600000},{\\"label\\":\\"SMB\\",\\"value\\":400000},{\\"label\\":\\"Startup\\",\\"value\\":200000}]"}}},{"barChart":{"props":{"title":"Monthly Revenue","data":"[{\\"label\\":\\"Oct\\",\\"value\\":350000},{\\"label\\":\\"Nov\\",\\"value\\":400000},{\\"label\\":\\"Dec\\",\\"value\\":450000}]"}}}]}"""_openai_kwargs = {}if os.environ.get("OPENAI_BASE_URL"): _openai_kwargs["api_base"] = os.environ["OPENAI_BASE_URL"]byoc_hashbrown_router = get_ag_ui_workflow_router( llm=OpenAI(model="gpt-4o-mini", **_openai_kwargs), frontend_tools=[], backend_tools=[], system_prompt=BYOC_HASHBROWN_SYSTEM_PROMPT, initial_state={},)You have a chat surface and you want the agent to draw a dashboard, not just describe one in prose. By the end of this guide, the agent will stream a structured output object, @hashbrownai/react's progressive JSON parser will hand each finished slice to your renderer as it arrives, and the user sees the dashboard fill in live.
When to use this#
- Streaming dashboards where partial state should render before the full payload arrives.
- Agents authoring structured UI where the output is JSON-shaped, not free text.
- Cases where you already use Hashbrown for UI generation elsewhere in your stack.
If you'd rather work with an explicit catalog of registered components rather than streaming a JSON tree, see the sibling page BYOC — JSON Render for the same scenario implemented with @json-render/react.
Frontend#
The integration point is <CopilotChat>'s messageView.assistantMessage slot. Replace the default assistant-message renderer with a Hashbrown-backed one, and the chat takes care of everything else:
import {
CopilotKit,
CopilotChat,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { HashBrownAssistantMessage } from "./hashbrown-renderer";
export default function ByocHashbrownDemo() {
useConfigureSuggestions({
suggestions: [
{ title: "Sales overview", message: "Show me a sales dashboard." },
{ title: "Region split", message: "Break down sales by region." },
],
available: "always",
});
return (
<CopilotKit runtimeUrl="/api/copilotkit-byoc-hashbrown" agent="byoc_hashbrown">
<CopilotChat
messageView={{ assistantMessage: HashBrownAssistantMessage }}
/>
</CopilotKit>
);
}The custom renderer is where Hashbrown earns its keep. useJsonParser consumes the streaming text content of an assistant message and emits typed JSON values as they parse; useUiKit resolves component names against your catalog and renders them with their props:
import { useJsonParser, useUiKit } from "@hashbrownai/react";
import { MetricCard } from "./metric-card";
import { PieChart, BarChart } from "./charts";
const catalog = {
MetricCard,
PieChart,
BarChart,
};
export function HashBrownAssistantMessage({ message }: { message: AssistantMessage }) {
const parsed = useJsonParser(message.content ?? "");
const ui = useUiKit({ catalog, value: parsed });
return <div className="space-y-3">{ui}</div>;
}Each component in the catalog is just a regular React component. The catalog acts as a typed allowlist: anything the agent emits that isn't in the catalog won't render, so the agent can't draw arbitrary HTML into the page.
Backend#
The agent's job is to stream structured output, not text. How you do that depends on your framework, but the shape is always the same: emit a JSON object at the top level of the assistant message, where each child references a component name and props matching the catalog.
{
"type": "MetricCard",
"title": "Total revenue",
"value": 184302,
"delta": 0.07
}Or a tree:
{
"type": "Stack",
"children": [
{ "type": "MetricCard", "title": "Total revenue", "value": 184302 },
{ "type": "BarChart", "data": [...] }
]
}Hashbrown's parser tolerates partial JSON, so the user sees MetricCard resolve before BarChart even starts streaming.
Comparing the two BYOC patterns#
Both byoc-hashbrown and byoc-json-render solve the same problem (agent-authored structured UI, rendered through a typed catalog), with two different rendering libraries. Pick whichever you already use elsewhere — the agent contract is the same shape; the React glue is what changes.
