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.
/** * LangGraph TypeScript agent backing the declarative-hashbrown demo. * * Port of `langgraph-python/src/agents/byoc_hashbrown_agent.py`. * * Emits hashbrown-shaped structured output (`<ui>...</ui>`) that the frontend * renderer (`src/app/demos/declarative-hashbrown/hashbrown-renderer.tsx`) parses * progressively via `@hashbrownai/react`. * * A minimal single-node StateGraph (no tools) — the system prompt teaches * the small component catalog exposed by the frontend kit. */import { RunnableConfig } from "@langchain/core/runnables";import { SystemMessage } from "@langchain/core/messages";import { MemorySaver, START, END, StateGraph, Annotation,} from "@langchain/langgraph";import { ChatOpenAI } from "@langchain/openai";import { makeChatOpenAI } from "./openai-headers";import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";// `@hashbrownai/react`'s `useJsonParser(content, kit.schema)` expects the// agent to stream a JSON object literal matching `kit.schema` — NOT the// `<ui>...</ui>` XML-style examples shown inside `useUiKit({ examples })`.// Those XML examples are the hashbrown prompt DSL that hashbrown compiles// into a schema description when driving the LLM directly. Since this demo// drives the LLM via langgraph, we mirror hashbrown's wire format — a// `{ ui: [...] }` envelope — instead.//// Every node is a single-key object `{ tagName: { props: {...} } }`. Tag// names and prop schemas match `useSalesDashboardKit()` in// `hashbrown-renderer.tsx`. `pieChart`/`barChart` receive `data` as a// JSON-encoded string to keep the schema stable under partial streaming.const BYOC_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}]"}}}]}`;const AgentStateAnnotation = Annotation.Root({ ...CopilotKitStateAnnotation.spec,});type AgentState = typeof AgentStateAnnotation.State;async function chatNode(state: AgentState, config: RunnableConfig) { // Force JSON-object output mode. The frontend's `useJsonParser` bails // to `null` on any non-JSON prefix (code fences, prose preamble, etc.), // so locking the model to JSON at the API layer keeps the wire // contract honest. Passed via `modelKwargs` so it survives the // LangChain → OpenAI chat-completions mapping. const model = makeChatOpenAI(config, { model: "gpt-4o-mini", modelKwargs: { response_format: { type: "json_object" } }, }); const response = await model.invoke( [ new SystemMessage({ content: BYOC_HASHBROWN_SYSTEM_PROMPT }), ...state.messages, ], config, ); return { messages: response };}const workflow = new StateGraph(AgentStateAnnotation) .addNode("chat_node", chatNode) .addEdge(START, "chat_node") .addEdge("chat_node", END);const memory = new MemorySaver();export const graph = workflow.compile({ checkpointer: memory });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.