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.


"""Agents backing the BYOC (Bring Your Own Component-renderer) demos.

Both byoc-hashbrown and byoc-json-render share one ADK `byoc_agent`. The
registry pins both to the same instance (see `registry.py`):

    "byoc_hashbrown": AgentSpec(byoc_agent),
    "byoc_json_render": AgentSpec(byoc_agent),

The LP sibling splits the two into separate graphs with distinct system
prompts (see `showcase/integrations/langgraph-python/src/agents/`):

  - `byoc_hashbrown_agent.py` + `byoc_hashbrown_prompt.py` — emits
    hashbrown-shaped `{ "ui": [{"componentName": {"props": {...}}}, ...] }`
    that `@hashbrownai/react`'s `useJsonParser` streams progressively into
    the catalog kit.
  - `byoc_json_render_agent.py` — emits json-render's flat spec
    `{ "root": "<id>", "elements": { "<id>": { "type": "...", "props": {...} } } }`
    that `@json-render/react`'s `<Renderer />` paints once the JSON parses.

Because the ADK registry routes both demo names to the same `byoc_agent`
instance, the unified prompt below instructs the model to emit a single
JSON object that contains BOTH structures (a `ui` array AND a flat
`root`/`elements` map describing the same dashboard). Each frontend
renderer extracts only the keys it cares about:

  - hashbrown's `useJsonParser` reads `ui[]`
  - json-render's `parseSpec` reads `root` + `elements{}`

Extra top-level keys are tolerated by both parsers, so a single response
drives both demos without per-route divergence. This sidesteps the
registry split that LP uses while keeping the agent's wire output
LP-equivalent on each frontend.

For future maintainers who do want to split into two LlmAgents matching
LP, additional agent modules can be added and `registry.py` updated to
reference them per route — the existing exports below keep this option
open.
"""

from __future__ import annotations

from google.adk.agents import LlmAgent
from google.genai import types

from agents.shared_chat import get_model, stop_on_terminal_text


# ─── System prompt ────────────────────────────────────────────────────────────
# Mirrors LP's `byoc_hashbrown_prompt.BYOC_HASHBROWN_SYSTEM_PROMPT` and
# `byoc_json_render_agent.SYSTEM_PROMPT`, fused into a single instruction that
# emits both wire formats in one JSON envelope.
#
# Why fused: the ADK registry pins both byoc-hashbrown and byoc-json-render
# demo routes to the same `byoc_agent` instance. A single agent cannot
# selectively emit one of two mutually-exclusive top-level shapes without
# knowing which route it serves. Producing both keys at the top level of one
# JSON response is the cleanest LP-aligned way to make BOTH frontends work:
#
#   - `@hashbrownai/react`'s `useJsonParser` only reads `ui[]` — extra
#     sibling keys are ignored.
#   - `@json-render/react`'s `parseSpec` only reads `root` + `elements{}` —
#     extra sibling keys are ignored.
#
# Both arrays/maps must describe the SAME dashboard so the rendered output
# is visually equivalent across the two demo pages.
_BYOC_SYSTEM_PROMPT = """\
You are a sales analytics assistant that replies by emitting a single JSON
object consumed by two streaming UI parsers on the frontend.

ALWAYS respond with a single JSON object containing BOTH of the following
top-level keys describing the SAME dashboard:

1. A "ui" array (hashbrown wire format):

   {
     "ui": [
       { <componentName>: { "props": { ... } } },
       ...
     ]
   }

2. A flat element map ("root" + "elements"; json-render wire format):

   {
     "root": "<id of the root element>",
     "elements": {
       "<id>": {
         "type": "<component name>",
         "props": { ... component-specific props ... },
         "children": [ "<id>", ... ]
       },
       ...
     }
   }

The full response is exactly one JSON object with `ui`, `root`, and
`elements` all at the top level — no code fences, no preface, no
explanation outside the JSON. The response MUST be valid JSON.

Available components for the `ui` array (hashbrown names + 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`.

Available components for `elements` (json-render PascalCase types):

- 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 }, ... ]
  }
  Note: `data` is a real JSON array (NOT a JSON-encoded string).

- PieChart
  props: {
    "title": string,
    "description": string | null,
    "data": [ { "label": string, "value": number }, ... ]
  }

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" in `ui[]` for short headings between visual components.
  Do not emit long prose. The `elements` map does not need a Markdown twin.
- Do not emit components that are not listed above.
- `data` props on hashbrown's `pieChart`/`barChart` MUST be a JSON STRING.
  `data` props on json-render's `BarChart`/`PieChart` MUST be a real JSON
  array of objects.
- The `ui[]` and `elements{}` representations MUST describe the same
  dashboard (same metric values, same chart titles, same segment labels).
  Use realistic sales-domain values (revenue, pipeline, conversion,
  categories, months) — the demo is a sales dashboard.
- Every id referenced by `root` or any `children` array must exist as a
  key in `elements`. For multi-component dashboards, pick any element as
  `root` and list the others as its `children`.

Example response (sales dashboard, both wire formats describing the same
two-tile + chart layout):

{
  "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}]" } } }
  ],
  "root": "revenue-metric",
  "elements": {
    "revenue-metric": {
      "type": "MetricCard",
      "props": {
        "label": "Total Revenue",
        "value": "$1.2M",
        "trend": "+18% vs Q3"
      },
      "children": ["revenue-pie", "revenue-bar"]
    },
    "revenue-pie": {
      "type": "PieChart",
      "props": {
        "title": "Revenue by Segment",
        "description": "Share by customer segment",
        "data": [
          { "label": "Enterprise", "value": 600000 },
          { "label": "SMB", "value": 400000 },
          { "label": "Startup", "value": 200000 }
        ]
      }
    },
    "revenue-bar": {
      "type": "BarChart",
      "props": {
        "title": "Monthly Revenue",
        "description": "Revenue by month across Q4",
        "data": [
          { "label": "Oct", "value": 350000 },
          { "label": "Nov", "value": 400000 },
          { "label": "Dec", "value": 450000 }
        ]
      }
    }
  }
}

If the user asks something off-topic ("tell me a joke", "what is 2+2"),
you may reply with a single JSON object whose `ui` array contains a
single Markdown component answering the question, and an `elements` map
that mirrors that Markdown as a MetricCard with `label` = "Reply" and
`value` = your answer. The renderers will display these as appropriate.
"""


# ─── Agents ───────────────────────────────────────────────────────────────────
# `byoc_agent` is the registry-bound instance (both demo routes pin to it).
# `byoc_hashbrown_agent` / `byoc_json_render_agent` are LP-parity aliases
# for future split, but registry.py currently uses only `byoc_agent`.
_BYOC_INSTRUCTION = _BYOC_SYSTEM_PROMPT


byoc_agent = LlmAgent(
    name="ByocAgent",
    model=get_model(),
    instruction=_BYOC_INSTRUCTION,
    # No backend tools — the prompt produces all dashboard data inline so
    # the streaming JSON parsers on the frontend can rebuild the UI
    # progressively. LP's sibling agents (byoc_hashbrown_agent,
    # byoc_json_render_agent) follow the same `tools=[]` pattern.
    tools=[],
    # Force Gemini's JSON-object output mode. LP's sibling agents pass
    # `response_format={"type": "json_object"}` to OpenAI for the same
    # reason: the streaming frontend parsers (`@hashbrownai/react`'s
    # `useJsonParser` and `@json-render/react`'s `parseSpec`) bail to
    # `null` on any non-JSON prefix (code fences, prose preamble, etc.),
    # so leaving the model free to wander out of JSON leaves the renderer
    # empty in practice. `response_mime_type="application/json"` is
    # Gemini's equivalent — it constrains output to a single JSON value.
    # `temperature=0.2` matches LP's `byoc_json_render_agent` and keeps
    # the schema-adherence tight while still allowing some variation in
    # sample data.
    generate_content_config=types.GenerateContentConfig(
        response_mime_type="application/json",
        temperature=0.2,
    ),
    after_model_callback=stop_on_terminal_text,
)


# LP-parity exports. These mirror the split in
# `showcase/integrations/langgraph-python/src/agents/` so that future ADK
# registry updates can wire each demo route to its own LlmAgent.
byoc_hashbrown_agent = byoc_agent
byoc_json_render_agent = byoc_agent

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:

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

frontend/src/app/json-render-renderer.tsx
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:

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

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