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:

frontend/src/app/page.tsx
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:

frontend/src/app/hashbrown-renderer.tsx
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.

example agent output (streaming)
{
  "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.