BYOC — JSON Render
Bring your own component library. Have the agent emit a JSON spec and let json-render render it against a Zod-validated catalog of React components.
using System.ClientModel;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using OpenAI;/// <summary>/// Factory for the byoc-json-render demo agent.////// Emits a single JSON object shaped like `@json-render/react`'s flat spec/// format (`{ root, elements }`) so the frontend can feed it directly into/// `<Renderer />` against a Zod-validated catalog of three components —/// MetricCard, BarChart, PieChart.////// Mirrors `src/agents/byoc_json_render_agent.py` in the langgraph-python/// showcase — same system prompt, same component catalog./// </summary>public class ByocJsonRenderAgentFactory{ private const string SystemPrompt = @"You are a sales-dashboard UI generator for a BYOC json-render demo.When the user asks for a UI, respond with **exactly one JSON object** andnothing else — no prose, no markdown fences, no leading explanation. Theobject must match this schema (the ""flat element map"" format consumed by`@json-render/react`):{ ""root"": ""<id of the root element>"", ""elements"": { ""<id>"": { ""type"": ""<component name>"", ""props"": { ... component-specific props ... }, ""children"": [ ""<id>"", ... ] }, ... }}Available components (use each name verbatim as ""type""):- MetricCard props: { ""label"": string, ""value"": string, ""trend"": string | null } Example trend strings: ""+12% vs last quarter"", ""-3% vs last month"", null.- BarChart props: { ""title"": string, ""description"": string | null, ""data"": [ { ""label"": string, ""value"": number }, ... ] }- PieChart props: { ""title"": string, ""description"": string | null, ""data"": [ { ""label"": string, ""value"": number }, ... ] }Rules:1. Output **only** valid JSON. No markdown code fences. No text outside the object.2. Every id referenced in `root` or any `children` array must be a key in `elements`.3. For a multi-component dashboard, use a root MetricCard and list the charts in its `children` array, OR pick any element as root and list the others as its children. Do not emit orphan elements.4. Use realistic sales-domain values (revenue, pipeline, conversion, categories, months) — the demo is a sales dashboard.5. `children` is optional but when present must be an array of strings.6. Never invent component types outside the three listed above.### Worked example — ""Show me the sales dashboard with metrics and a revenue chart""{ ""root"": ""revenue-metric"", ""elements"": { ""revenue-metric"": { ""type"": ""MetricCard"", ""props"": { ""label"": ""Revenue (Q3)"", ""value"": ""$1.24M"", ""trend"": ""+18% vs Q2"" }, ""children"": [""revenue-bar""] }, ""revenue-bar"": { ""type"": ""BarChart"", ""props"": { ""title"": ""Monthly revenue"", ""description"": ""Revenue by month across Q3"", ""data"": [ { ""label"": ""Jul"", ""value"": 380000 }, { ""label"": ""Aug"", ""value"": 410000 }, { ""label"": ""Sep"", ""value"": 450000 } ] } } }}### Worked example — ""Break down revenue by category as a pie chart""{ ""root"": ""category-pie"", ""elements"": { ""category-pie"": { ""type"": ""PieChart"", ""props"": { ""title"": ""Revenue by category"", ""description"": ""Share of total revenue by product category"", ""data"": [ { ""label"": ""Enterprise"", ""value"": 540000 }, { ""label"": ""SMB"", ""value"": 310000 }, { ""label"": ""Self-serve"", ""value"": 220000 }, { ""label"": ""Partner"", ""value"": 170000 } ] } } }}### Worked example — ""Show me monthly expenses as a bar chart""{ ""root"": ""expense-bar"", ""elements"": { ""expense-bar"": { ""type"": ""BarChart"", ""props"": { ""title"": ""Monthly expenses"", ""description"": ""Operating expenses by month"", ""data"": [ { ""label"": ""Jul"", ""value"": 210000 }, { ""label"": ""Aug"", ""value"": 225000 }, { ""label"": ""Sep"", ""value"": 240000 } ] } } }}Respond with the JSON object only."; private readonly OpenAIClient _openAiClient; private readonly ILogger _logger; public ByocJsonRenderAgentFactory(IConfiguration configuration, ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(loggerFactory); _logger = loggerFactory.CreateLogger<ByocJsonRenderAgentFactory>(); var apiKey = ApiKeyResolver.ResolveApiKey(configuration); var endpoint = ApiKeyResolver.ResolveEndpoint(configuration); _logger.LogInformation("ByocJsonRenderAgent using OpenAI endpoint: {Endpoint}", endpoint); _openAiClient = new( new ApiKeyCredential(apiKey), AimockHeaderPolicy.CreateOpenAIClientOptions(endpoint)); } public AIAgent CreateAgent() { var chatClient = _openAiClient.GetChatClient("gpt-4o-mini").AsIChatClient(); // The frontend json-render-renderer.tsx buffers until the assistant // content parses as a complete JSON object, then renders. Force // response_format=json_object (parity with LGP / built-in-agent) so // the model cannot dump prose that falls through to the default // text bubble. Instructions (not Description) carry the system prompt. return new ChatClientAgent( chatClient, new ChatClientAgentOptions { Name = "ByocJsonRenderAgent", Description = "json-render structured UI demo agent", Instructions = SystemPrompt, ChatOptions = new ChatOptions { ResponseFormat = ChatResponseFormat.Json, }, }); }}You have a chat surface and you want the agent to draw a dashboard from a typed JSON spec. By the end of this guide, the agent will emit a { root, elements } object, @json-render/react will validate it against a Zod-described catalog, and the user sees the dashboard render as a single React tree.
When to use this#
- Structured UI with a typed contract where the agent's output is validated against a known schema before it touches the DOM.
- Tolerance for prose preamble + code fences in the agent's output (json-render's parser handles them).
- Cases where you already use json-render elsewhere or prefer Zod-validated catalogs.
If you'd rather have a streaming progressive render rather than a one-shot validated render, see the sibling page BYOC — Hashbrown for the same scenario with @hashbrownai/react.
Frontend#
The integration point is <CopilotChat>'s messageView.assistantMessage slot. Swap the default renderer for a json-render-backed one:
import {
CopilotKit,
CopilotChat,
useConfigureSuggestions,
} from "@copilotkit/react-core/v2";
import { JsonRenderAssistantMessage } from "./json-render-renderer";
export default function ByocJsonRenderDemo() {
useConfigureSuggestions({
suggestions: [
{ title: "Sales dashboard", message: "Show me a sales dashboard." },
{ title: "Region breakdown", message: "Break down sales by region." },
],
available: "always",
});
return (
<CopilotKit runtimeUrl="/api/copilotkit-byoc-json-render" agent="byoc_json_render">
<CopilotChat
messageView={{ assistantMessage: JsonRenderAssistantMessage }}
/>
</CopilotKit>
);
}The custom renderer parses the streaming assistant content (tolerating partial tokens, code fences, and prose preamble), validates each element against a Zod-typed catalog, and feeds the resulting spec into <Renderer />:
import { Renderer } from "@json-render/react";
import { catalog } from "./registry";
export function JsonRenderAssistantMessage({ message }: { message: AssistantMessage }) {
const spec = parseSpec(message.content ?? "");
if (!spec) return null;
return <Renderer spec={spec} catalog={catalog} />;
}
function parseSpec(content: string) {
const cleaned = stripCodeFencesAndPrelude(content);
const partial = tolerantJsonParse(cleaned);
return validateAgainstCatalog(partial);
}The catalog lives next to the renderer and pairs each component with a Zod schema describing its props:
import { z } from "zod";
import { MetricCard } from "./metric-card";
import { BarChart } from "./charts/bar-chart";
import { PieChart } from "./charts/pie-chart";
export const catalog = {
MetricCard: {
component: MetricCard,
propsSchema: z.object({
title: z.string(),
value: z.number(),
delta: z.number().optional(),
}),
},
BarChart: {
component: BarChart,
propsSchema: z.object({
data: z.array(z.object({ label: z.string(), value: z.number() })),
}),
},
PieChart: {
component: PieChart,
propsSchema: z.object({
data: z.array(z.object({ label: z.string(), value: z.number() })),
}),
},
};Validation is the safety net: anything the agent emits that doesn't match a registered schema is rejected before it hits React, so the chat can't render arbitrary garbage.
Backend#
The agent emits a { root, elements } JSON object as the assistant message content. root references a top-level element id; elements maps each id to a { type, props, children } triple matching the catalog.
{
"root": "dashboard",
"elements": {
"dashboard": {
"type": "Stack",
"children": ["revenue-card", "by-region"]
},
"revenue-card": {
"type": "MetricCard",
"props": { "title": "Total revenue", "value": 184302 }
},
"by-region": {
"type": "BarChart",
"props": { "data": [...] }
}
}
}Anything else (free-form text, code fences around the JSON, a "Here's your dashboard:" preamble) is stripped by the renderer's tolerant parser before validation. The agent doesn't need to be perfectly clean.
Comparing the two patterns#
Both byoc-json-render and byoc-hashbrown solve the same problem with two different rendering libraries. The agent contract is similar; the React glue, validation strategy, and rendering behaviour differ.