ComponentsAgent patterns

A2UI Surface

Lets an agent answer with a layout instead of plain text: metrics, charts, tables, tabs and buttons. The agent writes A2UI messages that name components from a catalog. Your app checks them and draws them with real Sprõut components. The agent never sends code, styles or class names.

In chat

You are using 11.2 GB of 16 GB. Chrome uses the most.

Memory used
11.2 GB
Up 0.4 GBin 10 min
Memory in the last 10 minutes

checkSurface: valid

messages.json
[
  {
    "version": "v0.9.1",
    "createSurface": {
      "surfaceId": "memory",
      "catalogId": "sprout/basic@1"
    }
  },
  {
    "version": "v0.9.1",
    "updateComponents": {
      "surfaceId": "memory",
      "components": [
        {
          "id": "root",
          "component": "Column",
          "children": [
            "summary",
            "used",
            "chart"
          ]
        },
        {
          "id": "summary",
          "component": "Text",
          "text": "You are using **11.2 GB** of 16 GB. Chrome uses the most."
        },
        {
          "id": "used",
          "component": "Metric",
          "label": "Memory used",
          "value": "11.2 GB",
          "change": {
            "value": "0.4 GB",
            "direction": "up",
            "tone": "negative",
            "label": "in 10 min"
          },
          "trend": {
            "path": "/trend"
          }
        },
        {
          "id": "chart",
          "component": "Chart",
          "type": "area",
          "title": "Memory in the last 10 minutes",
          "category": "time",
          "series": [
            {
              "key": "used",
              "label": "Used"
            }
          ],
          "rows": {
            "path": "/samples"
          },
          "unit": "GB"
        }
      ]
    }
  },
  {
    "version": "v0.9.1",
    "updateDataModel": {
      "surfaceId": "memory",
      "path": "/",
      "value": {
        "trend": [
          9.8,
          10.1,
          10.4,
          10.2,
          10.8,
          10.9,
          11,
          11.2
        ],
        "samples": [
          {
            "time": "14:22",
            "used": 9.8
          },
          {
            "time": "14:23",
            "used": 10.1
          },
          {
            "time": "14:24",
            "used": 10.4
          },
          {
            "time": "14:25",
            "used": 10.2
          },
          {
            "time": "14:26",
            "used": 10.8
          },
          {
            "time": "14:27",
            "used": 10.9
          },
          {
            "time": "14:28",
            "used": 11
          },
          {
            "time": "14:29",
            "used": 11.2
          }
        ]
      }
    }
  }
]

Two entry points

  • @tulipfarm/sprout/a2ui/catalog has no React. Use it on the server to describe the catalog to a model, check its reply and turn the reply into text. It needs zod 4.
  • @tulipfarm/sprout/a2ui adds A2uiSurface and the adapters that draw each component. It also re-exports everything in the catalog entry.

On the server

Prompt, check and store a replyTSX
import {
  checkSurface,
  promptSnippet,
  sproutCatalog,
  surfaceText,
} from "@tulipfarm/sprout/a2ui/catalog";

// 1. Tell the model what it can use.
const system = `${instructions}\n\n${promptSnippet(sproutCatalog)}`;

// 2. Check what it sent.
const result = checkSurface(messages, { catalog: sproutCatalog });
if (!result.ok) {
  // A2UI errors: { code: "VALIDATION_FAILED", surfaceId, path, message }.
  return retryWith(result.errors);
}

// 3. Keep a text version for copy, search and the model's history.
const text = surfaceText(result.surface, sproutCatalog);

  • checkSurface refuses unknown components and props, missing or repeated IDs, children that do not exist, cycles, parts not reachable from root, and links other than HTTP, HTTPS and mailto.
  • Default limits: 300 components, 256 KB, 1,000 table rows and 2,000 chart points. Passlimits to change them and allowUrl to change which links and images are allowed. Images are refused by default.
  • catalogDefinition(catalog) returns the catalog as an A2UI JSON Schema, for tools that take one.

In the browser

Draw a replyTSX
import { A2uiSurface } from "@tulipfarm/sprout/a2ui";

<A2uiSurface
  messages={messages}
  size="compact" // "full" in a side panel
  streaming={isStreaming}
  onAction={action => {
    if (action.type === "send") sendMessage(action.text);
    if (action.type === "expand") openInCanvas(action.componentId);
  }}
/>

  • size="compact" fits a chat message: charts are 240px tall and tables show 10 rows with a “Show all” button. That button calls onAction with expand, or expands in place without a handler.
  • While streaming, parts that have not arrived show a placeholder. Afterwards, a part that is missing or fails to draw shows “This part couldn’t be displayed” and the rest of the reply still shows.
  • Every chart has a “Show data” button that swaps it for a table of the same numbers.
  • Button calls onAction with send and its message. Without a handler it is disabled.

Add your own components

Extend the catalogTSX
import { z } from "zod";
import { defineCatalog, defineComponent, sproutCatalog } from "@tulipfarm/sprout/a2ui/catalog";
import { sproutAdapters, type A2uiAdapter } from "@tulipfarm/sprout/a2ui";

export const appCatalog = defineCatalog({
  id: "acme/chat@1",
  extends: sproutCatalog,
  components: {
    Weather: defineComponent({
      description: "Today's weather for one city.",
      props: z.strictObject({ city: z.string().max(80) }),
      text: props => `Weather in ${props.city}`,
    }),
  },
});

const Weather: A2uiAdapter<{ city: string }> = ({ props }) => <WeatherCard city={props.city} />;

export const appAdapters = { ...sproutAdapters, Weather };

Give each catalog a versioned ID. Within one ID, only add components and optional props, so stored replies still draw. Change the ID for anything else.

Catalog

sprout/basic@1 has these components:

  • Text: Text (Markdown), Heading, Code, Badge, Callout.
  • Layout: Column, Row, Section, Card, Divider, Tabs, Details, List.
  • Data: Metric, Table, Chart (line, area, bar, pie, donut), Progress.
  • Other: Image (only where the app allows it) and Button.

Values such as table rows can be literal or { path } into the surface’s data model. A child list can be a template, { componentId, path }, that repeats once per item of a list.

Accessibility

Headings start at level 2, so they sit under the page’s own heading. Charts name themselves with their title and can switch to a table. Badges, callouts and metric changes always say their meaning in words; tone only tints them. Placeholders are hidden from screen readers, and the surface is aria-busy while it streams.