Page actions

React AG-UI JSON Render preview

Let the agent choose a dashboard layout while React supplies the components and data bindings. A later state update can refresh the dashboard without replacing the layout already displayed.

The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.

Keep layout, data, and execution separate

RenderSpec receives a specification, a registry, and optional state. The AG-UI session provides the observed backend data; the application validates the supported layout and passes those values to the renderer. The airline operations example uses fixed data to make the distinction visible: filtering disruptions changes the data, while choosing another layout changes the specification.

The conversation walkthrough (Angular guide) connects this capability to requests, tools, UI state, and saved history.

Render the authored dashboard

Choose Run and send Show an airline operations dashboard with KPI cards, an on-time trend, flights by airline and recent disruptions. The agent calls render_spec and the data tools. Six authored read-only views display the layout: a dashboard grid, a container, a statistic card, a line chart, a bar chart and a data grid. Charts include accessible data tables.

The native AG-UI session owns requests, conversation identity, complete message history and shared state. useAgent observes the application, TextTranscript displays literal native text, ToolObservation exposes server-tool details, and ChatInput admits a new question. RenderSpec resolves validated bindings against the latest dashboard state.

app.tsx
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
  ChatInput,
  MessageList,
  ToolObservation,
} from '@threadplane/react/chat';
import { RenderSpec } from '@threadplane/react/render';
import {
  createConnectedApplication,
  type dashboardConnection,
} from './connection';
import { dashboardRegistry } from './dashboard-views';
 
export function DashboardDemo({
  connection,
  onReady,
}: {
  readonly connection: ReturnType<typeof dashboardConnection>;
  readonly onReady: () => void;
}) {
  const [application] = useState(() => createConnectedApplication(connection));
  const [draft, setDraft] = useState('');
  const snapshot = useAgent(application);
  useEffect(() => {
    onReady();
  }, [onReady]);
  useEffect(
    () => () => {
      void application.dispose();
    },
    [application]
  );
  useEffect(() => {
    setDraft('');
  }, [snapshot.viewGeneration]);
  const transcript = snapshot.native?.transcript ?? [];
  const status = snapshot.busy
    ? snapshot.activity === 'replacing'
      ? 'Starting new conversation…'
      : 'Updating the dashboard…'
    : !snapshot.canSubmit
    ? 'Start a new conversation to continue.'
    : snapshot.outcome === 'success'
    ? 'Response complete.'
    : 'Ready.';
  return (
    <ExampleLayout
      className="dashboard-demo"
      about={
        <section aria-label="Runtime connection" className="runtime-details">
          <details>
            <summary>Runtime connection</summary>
            <p>{connection.label}</p>
          </details>
        </section>
      }
    >
      <header>
        <div>
          <h1>AG-UI airline dashboard</h1>
        </div>
        <button
          type="button"
          disabled={snapshot.busy}
          onClick={() => void application.newConversation()}
        >
          New conversation
        </button>
      </header>
      <p>
        Ask for an airline dashboard, then filter the disruptions or change its
        layout. This example uses fixed demo data.
      </p>
      <MessageList rows={[]} label="Conversation" key={snapshot.viewGeneration}>
        <ol className="dashboard-native-rows" role="list">
          {transcript.map((message) => {
            const surface = snapshot.surfaces.find(
              (item) => item.ownerId === message.id
            );
            const calls =
              message.role === 'assistant' ? message.toolCalls ?? [] : [];
            const text =
              (message.role === 'user' ||
                message.role === 'assistant' ||
                message.role === 'tool') &&
              typeof message.content === 'string' ? (
                <p className="dashboard-native-text">{message.content}</p>
              ) : null;
            return (
              <li
                key={message.id}
                data-dashboard-message={message.id}
                className={`tp-chat-message tp-chat-message--${message.role}`}
              >
                <p className="tp-chat-message__role">
                  {message.role === 'user' ? 'You' : message.role}
                </p>
                {surface &&
                typeof message.content === 'string' &&
                message.content.trim().startsWith('{') ? (
                  <details>
                    <summary>Layout source</summary>
                    {text}
                  </details>
                ) : message.role === 'tool' ? (
                  <details>
                    <summary>Server tool result</summary>
                    {text}
                  </details>
                ) : (
                  text
                )}
                {surface && (
                  <div data-dashboard-spec-owner={message.id}>
                    <RenderSpec
                      spec={surface.spec}
                      registry={dashboardRegistry}
                      state={{ ...snapshot.projection?.state }}
                      loading={snapshot.busy}
                    />
                  </div>
                )}
                {calls.map((call) => {
                  const result = transcript.find(
                    (row) => row.role === 'tool' && row.toolCallId === call.id
                  );
                  return (
                    <details key={call.id} data-dashboard-tool={call.id}>
                      <summary>Tool details: {call.function.name}</summary>
                      <ToolObservation
                        name={call.function.name}
                        argumentsText={call.function.arguments}
                        resultText={
                          result?.role === 'tool' &&
                          typeof result.content === 'string'
                            ? result.content
                            : undefined
                        }
                      />
                    </details>
                  );
                })}
              </li>
            );
          })}
        </ol>
      </MessageList>
      <ExampleFooter>
        {snapshot.error && <p role="alert">{snapshot.error}</p>}
        <ChatInput
          label="Message"
          hint="Enter to send. Shift+Enter adds a new line."
          placeholder="Ask for an airline dashboard…"
          value={draft}
          onValueChange={setDraft}
          disabled={!snapshot.canSubmit}
          busy={snapshot.busy}
          onStop={snapshot.busy ? () => void application.stop() : undefined}
          onSubmit={(text) => {
            if (!application.getSnapshot().canSubmit) return false;
            void application.submit(text);
            return application.getSnapshot().busy;
          }}
        />
        <p role="status">{status}</p>
      </ExampleFooter>
    </ExampleLayout>
  );
}
dashboard-views.tsx
import { Children, useId, type CSSProperties } from 'react';
import type {
  ReactRenderRegistry,
  RenderViewProps,
  RenderValue,
} from '@threadplane/react/render';
 
const text = (value: RenderValue) =>
  typeof value === 'string' || typeof value === 'number' ? String(value) : '';
function Waiting() {
  return <p className="dashboard-empty">Waiting for dashboard data…</p>;
}
function DashboardGrid({ children }: RenderViewProps) {
  return (
    <div className="dashboard-grid" data-dashboard-view="dashboard_grid">
      {children}
    </div>
  );
}
function Container({ props, children }: RenderViewProps) {
  return (
    <div
      className="dashboard-container"
      data-dashboard-view="container"
      data-direction={props.direction === 'row' ? 'row' : 'column'}
      style={
        {
          '--dashboard-cols': Math.max(1, Children.count(children)),
        } as CSSProperties
      }
    >
      {children}
    </div>
  );
}
function StatCard({ props }: RenderViewProps) {
  return (
    <section
      className="dashboard-card"
      data-dashboard-view="stat_card"
      aria-label={text(props.label)}
    >
      <h3>{text(props.label)}</h3>
      {props.value === undefined || props.value === null ? (
        <Waiting />
      ) : (
        <p className="dashboard-metric">{text(props.value)}</p>
      )}
      {props.delta !== null && props.delta !== undefined && (
        <p
          className="dashboard-delta"
          data-trend={
            text(props.delta).startsWith('+')
              ? 'up'
              : /^[-−]/.test(text(props.delta))
              ? 'down'
              : 'flat'
          }
        >
          {text(props.delta)}
        </p>
      )}
    </section>
  );
}
type Point = Readonly<{ label: string; value: number }>;
function chartPoints(
  value: RenderValue,
  labelKey: string,
  valueKey: string
): readonly Point[] | undefined {
  if (!Array.isArray(value)) return;
  return value.map((row) => {
    const record = row as Readonly<Record<string, RenderValue>>;
    return { label: text(record[labelKey]), value: Number(record[valueKey]) };
  });
}
function Chart({
  props,
  kind,
}: RenderViewProps & { readonly kind: 'line' | 'bar' }) {
  const gradientId = useId();
  const title =
    text(props.title) ||
    (kind === 'line' ? 'On-time trend' : 'Flights by airline');
  const points = chartPoints(
    props.data,
    kind === 'line' ? 'month' : 'airline',
    kind === 'line' ? 'on_time_pct' : 'count'
  );
  const values = points?.map((point) => point.value) ?? [];
  const rawMinimum = kind === 'line' && values.length ? Math.min(...values) : 0;
  const rawMaximum =
    kind === 'line' && values.length
      ? Math.max(...values)
      : Math.max(1, ...values);
  const domainPadding =
    kind === 'line' && rawMinimum === rawMaximum
      ? Math.max(1, Math.abs(rawMinimum) * 0.05)
      : 0;
  const minimum = rawMinimum - domainPadding;
  const maximum = rawMaximum + domainPadding;
  const range = maximum - minimum || 1;
  const padding =
    kind === 'line'
      ? { top: 16, right: 32, bottom: 28, left: 64 }
      : { top: 28, right: 16, bottom: 28, left: 16 };
  const plotWidth = 400 - padding.left - padding.right;
  const plotHeight = 220 - padding.top - padding.bottom;
  const baseline = 220 - padding.bottom;
  const x = (index: number) =>
    (points?.length ?? 0) === 1
      ? padding.left + plotWidth / 2
      : padding.left + (index * plotWidth) / ((points?.length ?? 0) - 1);
  const y = (value: number) =>
    padding.top + plotHeight - ((value - minimum) / range) * plotHeight;
  const labelStep = Math.max(
    1,
    Math.ceil((((points?.length ?? 1) - 1) * 64) / plotWidth)
  );
  const labelCount =
    kind === 'line'
      ? Math.min(4, Math.floor(((points?.length ?? 1) - 1) / labelStep) + 1)
      : 4;
  const labelIndices =
    (points?.length ?? 0) <= labelCount
      ? points?.map((_, index) => index) ?? []
      : Array.from({ length: labelCount }, (_, index) =>
          Math.round((index * ((points?.length ?? 0) - 1)) / (labelCount - 1))
        );
  const numberLabel = (value: number) => {
    const scale = Math.abs(value) >= 1000 && range >= 1000 ? 1000 : 1;
    const precision = Math.min(
      10,
      Math.max(0, -Math.floor(Math.log10(range / (2 * scale))))
    );
    return `${(value / scale).toFixed(precision)}${scale === 1000 ? 'k' : ''}`;
  };
  const barGap = Math.min(12, plotWidth / (2 * (points?.length || 1)));
  const barWidth =
    (plotWidth - barGap * ((points?.length || 1) - 1)) / (points?.length || 1);
  const barX = (index: number) => padding.left + index * (barWidth + barGap);
  return (
    <section
      className="dashboard-card dashboard-chart"
      data-dashboard-view={kind === 'line' ? 'line_chart' : 'bar_chart'}
    >
      <h3>{title}</h3>
      {!points ? (
        <Waiting />
      ) : points.length === 0 ? (
        <p className="dashboard-empty">No chart data.</p>
      ) : (
        <>
          <svg viewBox="0 0 400 220" role="img" aria-label={title}>
            {kind === 'line' ? (
              <>
                {[maximum, (minimum + maximum) / 2, minimum].map(
                  (value, index) => {
                    const tickY = padding.top + (index * plotHeight) / 2;
                    return (
                      <g key={index}>
                        <line
                          x1={padding.left}
                          y1={tickY}
                          x2={400 - padding.right}
                          y2={tickY}
                          className="chart-axis"
                        />
                        <text
                          x={padding.left - 8}
                          y={tickY + 4}
                          textAnchor="end"
                        >
                          {numberLabel(value)}
                        </text>
                      </g>
                    );
                  }
                )}
                <path
                  d={`${points
                    .map(
                      (point, index) =>
                        `${index === 0 ? 'M' : 'L'} ${x(index)} ${y(
                          point.value
                        )}`
                    )
                    .join(' ')} L ${x(points.length - 1)} ${baseline} L ${x(
                    0
                  )} ${baseline} Z`}
                  fill={`url(#${gradientId})`}
                />
                <polyline
                  points={points
                    .map((point, index) => `${x(index)},${y(point.value)}`)
                    .join(' ')}
                  className="chart-line"
                />
                {labelIndices.map((index) => (
                  <text key={index} x={x(index)} y={214} textAnchor="middle">
                    {points[index].label}
                  </text>
                ))}
              </>
            ) : (
              <line
                x1={padding.left}
                y1={baseline}
                x2={400 - padding.right}
                y2={baseline}
                className="chart-axis"
              />
            )}
            {points.map((point, index) =>
              kind === 'line' ? (
                <circle
                  key={index}
                  cx={x(index)}
                  cy={y(point.value)}
                  r="3"
                  className="chart-point"
                >
                  <title>
                    {point.label}: {point.value}%
                  </title>
                </circle>
              ) : (
                <g key={index}>
                  <rect
                    x={barX(index)}
                    y={y(point.value)}
                    width={barWidth}
                    height={baseline - y(point.value)}
                    rx="3"
                    fill={`url(#${gradientId})`}
                    className="chart-bar"
                  >
                    <title>
                      {point.label}: {point.value}
                    </title>
                  </rect>
                  {barWidth >= 36 && (
                    <text
                      x={barX(index) + barWidth / 2}
                      y={y(point.value) - 6}
                      textAnchor="middle"
                    >
                      {point.value.toLocaleString()}
                    </text>
                  )}
                  {labelIndices.includes(index) && (
                    <text
                      x={barX(index) + barWidth / 2}
                      y={baseline + 16}
                      textAnchor="middle"
                    >
                      {point.label}
                    </text>
                  )}
                </g>
              )
            )}
            <defs>
              <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
                <stop
                  offset="0%"
                  stopColor="var(--dashboard-chart-color)"
                  stopOpacity={kind === 'line' ? 0.28 : 1}
                />
                <stop
                  offset="100%"
                  stopColor="var(--dashboard-chart-color)"
                  stopOpacity={kind === 'line' ? 0 : 0.75}
                />
              </linearGradient>
            </defs>
          </svg>
          <details className="dashboard-chart-data">
            <summary>View chart data</summary>
            <table aria-label={`${title} data`}>
              <thead>
                <tr>
                  <th scope="col">{kind === 'line' ? 'Month' : 'Airline'}</th>
                  <th scope="col">
                    {kind === 'line' ? 'On time (%)' : 'Flights'}
                  </th>
                </tr>
              </thead>
              <tbody>
                {points.map((point, index) => (
                  <tr key={index}>
                    <th scope="row">{point.label}</th>
                    <td>{point.value}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </details>
        </>
      )}
    </section>
  );
}
function LineChart(props: RenderViewProps) {
  return <Chart {...props} kind="line" />;
}
function BarChart(props: RenderViewProps) {
  return <Chart {...props} kind="bar" />;
}
const columnLabels: Readonly<Record<string, string>> = {
  flight_number: 'Flight',
  type: 'Disruption',
  minutes: 'Minutes',
  route: 'Route',
  date: 'Date',
};
function DataGrid({ props }: RenderViewProps) {
  const title = text(props.title) || 'Recent disruptions';
  const rows = Array.isArray(props.rows) ? props.rows : undefined;
  const columns = (props.columns ?? []) as readonly string[];
  return (
    <section className="dashboard-card" data-dashboard-view="data_grid">
      <h3>{title}</h3>
      {!rows ? (
        <Waiting />
      ) : rows.length === 0 ? (
        <p className="dashboard-empty">No disruptions.</p>
      ) : (
        <div
          className="dashboard-table-scroll"
          tabIndex={0}
          role="region"
          aria-label={`${title} table`}
        >
          <table aria-label={title}>
            <thead>
              <tr>
                {columns.map((column) => (
                  <th key={column} scope="col">
                    {columnLabels[column]}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {rows.map((row, index) => (
                <tr key={index}>
                  {columns.map((column) => (
                    <td key={column}>
                      {text(
                        (row as Readonly<Record<string, RenderValue>>)[column]
                      )}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </section>
  );
}
 
/** Only the six validated, read-only authored dashboard views are registered. */
export const dashboardRegistry: ReactRenderRegistry = Object.freeze({
  dashboard_grid: DashboardGrid,
  container: Container,
  stat_card: StatCard,
  line_chart: LineChart,
  bar_chart: BarChart,
  data_grid: DataGrid,
});

Bind live data to a retained layout

Send Filter the recent disruptions to cancelled flights without changing the layout. The data tool updates shared state. The dashboard already displayed under its original assistant reads that latest state, including while the current request is still working. Missing fields show a waiting message; empty lists and zero values remain valid data.

For a structural change, ask Replace the layout with a single Flights today card. Each accepted layout stays associated with the assistant that requested it. Earlier layouts continue reading the latest shared data, as in the Angular example.

The application owns a bounded copy of seven supported data fields and the existing six-type catalog. It validates scalar values, supported state pointers, child references and tool associations before rendering. Labels and raw protocol text stay literal.

dashboard-data.ts
export type MetricData = {
  readonly value: string | number;
  readonly delta: string | null;
};
export interface DashboardState {
  readonly on_time?: MetricData | null;
  readonly flights_today?: MetricData | null;
  readonly avg_delay?: MetricData | null;
  readonly load_factor?: MetricData | null;
  readonly on_time_trend?:
    | readonly Readonly<{ month: string; on_time_pct: number }>[]
    | null;
  readonly flights_by_airline?:
    | readonly Readonly<{ airline: string; count: number }>[]
    | null;
  readonly recent_disruptions?:
    | readonly Readonly<{
        flight_number: string;
        type: string;
        minutes: number;
        route: string;
        date: string;
      }>[]
    | null;
}
 
export function plainRecord(value: unknown): value is Record<string, unknown> {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
  const prototype = Object.getPrototypeOf(value);
  return (
    (prototype === Object.prototype || prototype === null) &&
    Object.values(Object.getOwnPropertyDescriptors(value)).every((descriptor) =>
      Object.hasOwn(descriptor, 'value')
    )
  );
}
 
export function dashboardText(value: unknown): value is string {
  return typeof value === 'string' && value.length > 0 && value.length <= 512;
}
 
/** Capture dense ordinary arrays without invoking caller-owned iteration. */
export function arrayData(value: unknown): readonly unknown[] | undefined {
  if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype)
    return;
  const descriptors = Object.getOwnPropertyDescriptors(
    value
  ) as unknown as Record<string, PropertyDescriptor>;
  const length = descriptors.length.value;
  if (
    !Number.isInteger(length) ||
    length < 0 ||
    length > 100 ||
    Reflect.ownKeys(descriptors).length !== length + 1
  )
    return;
  const items: unknown[] = [];
  for (let index = 0; index < length; index++) {
    const descriptor = descriptors[String(index)];
    if (!descriptor || !Object.hasOwn(descriptor, 'value')) return;
    items.push(descriptor.value);
  }
  return Object.freeze(items);
}
 
function finite(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value);
}
 
function exact(value: Record<string, unknown>, keys: readonly string[]) {
  return (
    Object.keys(value).length === keys.length &&
    keys.every((key) => Object.hasOwn(value, key))
  );
}
 
function metric(value: unknown): MetricData | undefined {
  if (
    !plainRecord(value) ||
    !exact(value, ['value', 'delta']) ||
    (!dashboardText(value.value) && !finite(value.value)) ||
    (value.delta !== null && !dashboardText(value.delta))
  )
    return;
  return Object.freeze({ value: value.value, delta: value.delta });
}
 
type FieldSchema = Readonly<Record<string, (value: unknown) => boolean>>;
function rows(
  value: unknown,
  schema: FieldSchema
): readonly Readonly<Record<string, unknown>>[] | undefined {
  const items = arrayData(value);
  if (!items) return;
  const keys = Object.keys(schema),
    result: Readonly<Record<string, unknown>>[] = [];
  for (const item of items) {
    if (
      !plainRecord(item) ||
      !exact(item, keys) ||
      !keys.every((key) => schema[key](item[key]))
    )
      return;
    result.push(
      Object.freeze(Object.fromEntries(keys.map((key) => [key, item[key]])))
    );
  }
  return Object.freeze(result);
}
 
const nonnegative = (value: unknown) => finite(value) && value >= 0;
const percentage = (value: unknown) =>
  finite(value) && value >= 0 && value <= 100;
const fields = Object.freeze({
  on_time: metric,
  flights_today: metric,
  avg_delay: metric,
  load_factor: metric,
  on_time_trend: (value: unknown) =>
    rows(value, { month: dashboardText, on_time_pct: percentage }),
  flights_by_airline: (value: unknown) =>
    rows(value, { airline: dashboardText, count: nonnegative }),
  recent_disruptions: (value: unknown) =>
    rows(value, {
      flight_number: dashboardText,
      type: dashboardText,
      minutes: nonnegative,
      route: dashboardText,
      date: dashboardText,
    }),
});
 
/** Captures supported native state fields; the session keeps the complete state. */
export function dashboardState(state: unknown): DashboardState | undefined {
  try {
    if (!plainRecord(state)) return;
    const selected: Record<string, unknown> = {};
    for (const [key, capture] of Object.entries(fields)) {
      if (!Object.hasOwn(state, key)) continue;
      const value = state[key] === null ? null : capture(state[key]);
      if (value === undefined) return;
      selected[key] = value;
    }
    return Object.freeze(selected) as DashboardState;
  } catch {
    return undefined;
  }
}
dashboard-spec.ts
import type {
  RenderElementData,
  RenderSpecData,
} from '@threadplane/react/render';
import type { PlainValue } from '@threadplane/core';
import {
  arrayData,
  dashboardState,
  dashboardText,
  plainRecord,
} from './dashboard-data';
 
const metricKeys = ['on_time', 'flights_today', 'avg_delay', 'load_factor'];
const disruptionKeys = ['flight_number', 'type', 'minutes', 'route', 'date'];
const safeKey = (value: unknown): value is string =>
  dashboardText(value) &&
  value.length <= 80 &&
  !['__proto__', 'prototype', 'constructor'].includes(value);
const finite = (value: unknown) =>
  typeof value === 'number' && Number.isFinite(value);
 
function binding(value: unknown, paths: readonly string[]) {
  return (
    plainRecord(value) &&
    Object.keys(value).length === 1 &&
    typeof value.$state === 'string' &&
    paths.includes(value.$state)
  );
}
function scalar(value: unknown, suffix: 'value' | 'delta') {
  return (
    value === null ||
    dashboardText(value) ||
    (suffix === 'value' && finite(value)) ||
    binding(
      value,
      metricKeys.map((key) => `/${key}/${suffix}`)
    )
  );
}
function data(
  value: unknown,
  key: 'on_time_trend' | 'flights_by_airline' | 'recent_disruptions'
) {
  return (
    value === null ||
    binding(value, [`/${key}`]) ||
    (Array.isArray(value) && dashboardState({ [key]: value }) !== undefined)
  );
}
function columns(value: unknown) {
  const items = arrayData(value);
  return (
    !!items &&
    items.length > 0 &&
    items.length <= disruptionKeys.length &&
    items.every(
      (key) => typeof key === 'string' && disruptionKeys.includes(key)
    ) &&
    new Set(items).size === items.length
  );
}
 
type PropRule = (value: unknown) => boolean;
interface ViewSchema {
  readonly required: readonly string[];
  readonly props: Readonly<Record<string, PropRule>>;
  readonly children: boolean;
}
const catalog: Readonly<Record<string, ViewSchema>> = Object.freeze({
  dashboard_grid: { required: [], props: {}, children: true },
  container: {
    required: [],
    props: { direction: (value) => value === 'row' || value === 'column' },
    children: true,
  },
  stat_card: {
    required: ['label'],
    props: {
      label: dashboardText,
      value: (value) => scalar(value, 'value'),
      delta: (value) => scalar(value, 'delta'),
    },
    children: false,
  },
  line_chart: {
    required: ['data', 'xKey', 'yKey'],
    props: {
      title: dashboardText,
      data: (value) => data(value, 'on_time_trend'),
      xKey: (value) => value === 'month',
      yKey: (value) => value === 'on_time_pct',
    },
    children: false,
  },
  bar_chart: {
    required: ['data', 'labelKey', 'valueKey'],
    props: {
      title: dashboardText,
      data: (value) => data(value, 'flights_by_airline'),
      labelKey: (value) => value === 'airline',
      valueKey: (value) => value === 'count',
    },
    children: false,
  },
  data_grid: {
    required: ['rows', 'columns'],
    props: {
      title: dashboardText,
      rows: (value) => data(value, 'recent_disruptions'),
      columns,
    },
    children: false,
  },
});
 
function own(value: unknown): PlainValue {
  if (Array.isArray(value)) {
    const items = arrayData(value);
    if (!items) throw new Error('Unsupported dashboard array');
    return Object.freeze(items.map(own));
  }
  if (plainRecord(value))
    return Object.freeze(
      Object.fromEntries(
        Object.entries(value).map(([key, item]) => [key, own(item)])
      )
    );
  return value as PlainValue;
}
 
function children(value: unknown): readonly string[] | undefined {
  const items = arrayData(value);
  if (!items || !items.every(safeKey) || new Set(items).size !== items.length)
    return;
  return Object.freeze([...items]);
}
 
/** Validate and own the existing authored catalog before presenting it. */
export function dashboardSpec(value: unknown): RenderSpecData | undefined {
  try {
    if (
      !plainRecord(value) ||
      Object.keys(value).length !== 2 ||
      !safeKey(value.root) ||
      !plainRecord(value.elements) ||
      !Object.hasOwn(value.elements, value.root)
    )
      return;
    const entries = Object.entries(value.elements);
    if (entries.length === 0 || entries.length > 100) return;
    const elements: Record<string, RenderElementData> = {};
    for (const [key, element] of entries) {
      if (
        !safeKey(key) ||
        !plainRecord(element) ||
        Object.keys(element).some(
          (name) => !['type', 'props', 'children'].includes(name)
        ) ||
        typeof element.type !== 'string' ||
        !Object.hasOwn(catalog, element.type)
      )
        return;
      const schema = catalog[element.type],
        props = element.props === undefined ? {} : element.props;
      if (
        !plainRecord(props) ||
        !schema.required.every((name) => Object.hasOwn(props, name)) ||
        Object.keys(props).some(
          (name) =>
            !Object.hasOwn(schema.props, name) ||
            !schema.props[name](props[name])
        )
      )
        return;
      const childKeys =
        element.children === undefined ? undefined : children(element.children);
      if (
        element.children !== undefined &&
        (!childKeys || (!schema.children && childKeys.length))
      )
        return;
      elements[key] = Object.freeze({
        type: element.type,
        props: own(props) as Readonly<Record<string, PlainValue>>,
        ...(childKeys ? { children: childKeys } : {}),
      });
    }
    const visited = new Set<string>(),
      visiting = new Set<string>();
    function visit(key: string): boolean {
      if (!Object.hasOwn(elements, key) || visiting.has(key)) return false;
      if (visited.has(key)) return true;
      visiting.add(key);
      for (const child of elements[key].children ?? [])
        if (!visit(child)) return false;
      visiting.delete(key);
      visited.add(key);
      return true;
    }
    if (!Object.keys(elements).every(visit)) return;
    return Object.freeze({
      root: value.root,
      elements: Object.freeze(elements),
    });
  } catch {
    return undefined;
  }
}

Confirm the native turn

Partial tool arguments, data updates and final message snapshots do not authorize another Send. The current root run must succeed with matching thread and run IDs, the submitted human message, its complete native prefix, associated results and a final assistant reply. The same confirmation supports ordinary text, batches and multiple tool rounds.

dashboard-policy.ts
import type { Session, TextTranscriptRow } from '@threadplane/ag-ui';
import type { RenderSpecData } from '@threadplane/react/render';
import {
  dashboardState,
  dashboardText,
  plainRecord,
  type DashboardState,
} from './dashboard-data';
import { dashboardSpec } from './dashboard-spec';
 
export type NativeSnapshot = ReturnType<Session['getSnapshot']>;
export interface OwnedTurn {
  readonly threadId: string;
  readonly runId: string;
  readonly human: TextTranscriptRow;
  readonly prefix: NativeSnapshot['transcript'];
}
export interface DashboardProjection {
  readonly ownerId: string | null;
  readonly spec: RenderSpecData | null;
  readonly state: DashboardState;
}
type Transcript = NativeSnapshot['transcript'];
type Call = Extract<Transcript[number], { role: 'assistant' }>['toolCalls'];
const names = [
  'render_spec',
  'query_airline_kpis',
  'query_on_time_trend',
  'query_flights_by_airline',
  'query_recent_disruptions',
];
function parsed(text: string): unknown {
  try {
    return text.length <= 100_000 ? JSON.parse(text) : undefined;
  } catch {
    return undefined;
  }
}
function same(
  left: unknown,
  right: unknown,
  valid: () => boolean,
  depth = 0
): boolean {
  if (!valid() || depth > 100) return false;
  if (Object.is(left, right)) return true;
  if (Array.isArray(left) && Array.isArray(right))
    return (
      left.length === right.length &&
      left.every((item, index) => same(item, right[index], valid, depth + 1)) &&
      valid()
    );
  if (!plainRecord(left) || !plainRecord(right)) return false;
  const keys = Object.keys(left);
  return (
    keys.length === Object.keys(right).length &&
    keys.every(
      (key) =>
        Object.hasOwn(right, key) &&
        same(left[key], right[key], valid, depth + 1)
    ) &&
    valid()
  );
}
function structural(transcript: Transcript, valid: () => boolean) {
  const ids = new Set<string>(),
    calls = new Set<string>(),
    results = new Set<string>();
  for (const message of transcript) {
    if (
      !valid() ||
      typeof message.id !== 'string' ||
      !message.id ||
      ids.has(message.id) ||
      typeof message.subagentRunId === 'string' ||
      !['user', 'assistant', 'tool'].includes(message.role)
    )
      return false;
    ids.add(message.id);
    if (
      message.role === 'user' &&
      (typeof message.content !== 'string' || !message.content)
    )
      return false;
    if (message.role === 'assistant') {
      if (
        message.content !== undefined &&
        message.content !== null &&
        typeof message.content !== 'string'
      )
        return false;
      for (const call of message.toolCalls ?? []) {
        if (
          !call.id ||
          calls.has(call.id) ||
          call.type !== 'function' ||
          !names.includes(call.function.name) ||
          typeof call.function.arguments !== 'string'
        )
          return false;
        calls.add(call.id);
      }
    }
    if (message.role === 'tool') {
      if (
        typeof message.content !== 'string' ||
        !calls.has(message.toolCallId) ||
        results.has(message.toolCallId)
      )
        return false;
      results.add(message.toolCallId);
    }
  }
  return valid();
}
function read(snapshot: NativeSnapshot, valid: () => boolean) {
  if (!valid()) return;
  const { status, run, transcript, decision, subagents } = snapshot;
  if (
    !valid() ||
    decision ||
    subagents.length ||
    run?.legacyInterrupt ||
    !structural(transcript, valid) ||
    status === 'error' ||
    (run?.outcome !== undefined && run.outcome !== 'success') ||
    run?.terminal?.type === 'RUN_ERROR' ||
    run?.terminal?.type === 'CUSTOM' ||
    (run?.terminal?.type === 'RUN_FINISHED' &&
      (run.terminal.runId !== run.id ||
        (run.terminal.outcome !== undefined &&
          (run.terminal.outcome.type !== 'success' ||
            run.terminal.outcome.pendingToolCallIds?.length))))
  )
    return;
  return valid() ? { status, run, transcript } : undefined;
}
export function unsupportedSnapshot(
  snapshot: NativeSnapshot,
  valid: () => boolean = () => true
): boolean {
  return !read(snapshot, valid);
}
export function captureTurn(
  snapshot: NativeSnapshot,
  thread: string,
  previous: string | undefined,
  prefix: Transcript,
  valid: () => boolean = () => true
): OwnedTurn | undefined {
  const state = read(snapshot, valid);
  if (
    !state ||
    state.status !== 'running' ||
    !thread ||
    !state.run?.id ||
    state.run.id === previous ||
    state.run.outcome !== undefined ||
    state.transcript.length !== prefix.length + 1 ||
    !prefix.every((row, index) => same(row, state.transcript[index], valid)) ||
    !valid()
  )
    return;
  const human = state.transcript.at(-1);
  if (human?.role !== 'user' || typeof human.content !== 'string' || !valid())
    return;
  return Object.freeze({
    threadId: thread,
    runId: state.run.id,
    prefix,
    human: Object.freeze({
      id: human.id,
      role: 'user',
      content: human.content,
    }),
  });
}
function argumentsFor(
  call: NonNullable<Call>[number]
): Record<string, unknown> | undefined {
  const args = parsed(call.function.arguments),
    name = call.function.name;
  if (!plainRecord(args)) return;
  if (name === 'render_spec') return dashboardSpec(args) ? args : undefined;
  const allowed =
    name === 'query_airline_kpis'
      ? []
      : name === 'query_on_time_trend'
      ? ['months']
      : name === 'query_flights_by_airline'
      ? ['airlines']
      : ['limit', 'type'];
  if (Object.keys(args).some((key) => !allowed.includes(key))) return;
  for (const key of ['months', 'limit'])
    if (
      Object.hasOwn(args, key) &&
      (typeof args[key] !== 'number' ||
        !Number.isInteger(args[key]) ||
        args[key] < 1 ||
        args[key] > 100)
    )
      return;
  if (
    Object.hasOwn(args, 'airlines') &&
    args.airlines !== null &&
    (!Array.isArray(args.airlines) ||
      args.airlines.length > 100 ||
      !args.airlines.every(dashboardText))
  )
    return;
  if (
    Object.hasOwn(args, 'type') &&
    args.type !== null &&
    !dashboardText(args.type)
  )
    return;
  return args;
}
function validResult(
  owner: Extract<Transcript[number], { role: 'assistant' }>,
  call: NonNullable<Call>[number],
  args: Record<string, unknown>,
  text: string,
  valid: () => boolean
) {
  const name = call.function.name;
  if (name === 'render_spec') {
    if (text === 'rendered')
      return same(
        parsed(typeof owner.content === 'string' ? owner.content : ''),
        args,
        valid
      );
    return (
      typeof owner.content === 'string' &&
      owner.content.trim().length > 0 &&
      same(parsed(text), args, valid)
    );
  }
  const value = parsed(text);
  if (name === 'query_airline_kpis') {
    const keys = ['on_time', 'flights_today', 'avg_delay', 'load_factor'];
    return (
      plainRecord(value) &&
      Object.keys(value).length === keys.length &&
      keys.every((key) => Object.hasOwn(value, key) && value[key] !== null) &&
      dashboardState(value) !== undefined
    );
  }
  const key =
    name === 'query_on_time_trend'
      ? 'on_time_trend'
      : name === 'query_flights_by_airline'
      ? 'flights_by_airline'
      : 'recent_disruptions';
  if (!Array.isArray(value) || dashboardState({ [key]: value }) === undefined)
    return false;
  if (
    key === 'on_time_trend' &&
    typeof args.months === 'number' &&
    value.length > args.months
  )
    return false;
  const airlines = args.airlines;
  if (
    key === 'flights_by_airline' &&
    Array.isArray(airlines) &&
    airlines.length > 0 &&
    value.some((row) => !airlines.includes(row.airline))
  )
    return false;
  if (
    key === 'recent_disruptions' &&
    ((typeof args.limit === 'number' && value.length > args.limit) ||
      (typeof args.type === 'string' &&
        value.some((row) => row.type !== args.type)))
  )
    return false;
  return valid();
}
function settled(messages: Transcript, valid: () => boolean) {
  if (messages.length < 2 || messages[0].role !== 'user') return false;
  let index = 1;
  while (index < messages.length) {
    if (!valid()) return false;
    const owner = messages[index++];
    if (owner.role !== 'assistant') return false;
    const calls = owner.toolCalls ?? [];
    if (!calls.length) {
      if (index === messages.length)
        return (
          typeof owner.content === 'string' &&
          owner.content.length > 0 &&
          valid()
        );
      continue;
    }
    const pending = new Map(
      calls.map((call) => [call.id, { call, args: argumentsFor(call) }])
    );
    if ([...pending.values()].some((item) => !item.args)) return false;
    while (index < messages.length && messages[index].role === 'tool') {
      const result = messages[index++];
      if (result.role !== 'tool' || typeof result.content !== 'string')
        return false;
      const match = pending.get(result.toolCallId);
      if (
        !match?.args ||
        result.id !== result.toolCallId ||
        !validResult(owner, match.call, match.args, result.content, valid)
      )
        return false;
      pending.delete(result.toolCallId);
    }
    if (pending.size || !valid()) return false;
  }
  return false;
}
export function confirmTranscript(
  snapshot: NativeSnapshot,
  turn: OwnedTurn,
  valid: () => boolean = () => true
): Transcript | undefined {
  const state = read(snapshot, valid),
    terminal = state?.run?.terminal;
  if (
    !state ||
    state.status !== 'idle' ||
    state.run?.id !== turn.runId ||
    state.run.outcome !== 'success' ||
    terminal?.type !== 'RUN_FINISHED' ||
    terminal.threadId !== turn.threadId ||
    terminal.runId !== turn.runId ||
    !turn.prefix.every((row, index) =>
      same(row, state.transcript[index], valid)
    ) ||
    !same(state.transcript[turn.prefix.length], turn.human, valid) ||
    !settled(state.transcript.slice(turn.prefix.length), valid) ||
    !valid()
  )
    return;
  return state.transcript;
}
export function selectDashboard(
  snapshot: NativeSnapshot,
  turn: OwnedTurn,
  previous?: DashboardProjection,
  valid: () => boolean = () => true
): DashboardProjection | undefined {
  const transcript = confirmTranscript(snapshot, turn, valid);
  if (!transcript || !valid()) return;
  const state = dashboardState(snapshot.state);
  if (!state || !valid()) return;
  let spec = previous?.spec ?? null,
    ownerId = previous?.ownerId ?? null;
  for (const owner of transcript.slice(turn.prefix.length)) {
    if (!valid()) return;
    if (owner.role !== 'assistant') continue;
    for (const call of owner.toolCalls ?? [])
      if (call.function.name === 'render_spec') {
        const selected = dashboardSpec(parsed(call.function.arguments));
        if (!selected || !valid()) return;
        spec = selected;
        ownerId = owner.id;
      }
  }
  return valid() ? Object.freeze({ spec, ownerId, state }) : undefined;
}

The first Send creates a local UUID lazily. Confirmed follow-ups retain full native history on that UUID. New conversation clears the display and draft; its next Send creates a fresh UUID. Stop and page exit revoke active work. Unsupported layouts, malformed state, unknown tools, child agents, pauses, errors and uncertain outcomes require New. The example has no automatic replay, reconnect or saved-history restoration; a local UUID does not claim server persistence.

application.ts
import type { Session } from '@threadplane/ag-ui';
import type { CompleteOutcome } from '@threadplane/core';
import type { RenderSpecData } from '@threadplane/react/render';
import {
  captureTurn,
  confirmTranscript,
  selectDashboard,
  unsupportedSnapshot,
  type DashboardProjection,
  type NativeSnapshot,
  type OwnedTurn,
} from './dashboard-policy';
import { dashboardState } from './dashboard-data';
 
export interface DashboardSurface {
  readonly ownerId: string;
  readonly spec: RenderSpecData;
}
export interface DashboardSnapshot {
  readonly threadId: string | null;
  readonly native: NativeSnapshot | undefined;
  readonly projection: DashboardProjection | null;
  readonly surfaces: readonly DashboardSurface[];
  readonly busy: boolean;
  readonly canSubmit: boolean;
  readonly activity: 'idle' | 'running' | 'replacing';
  readonly outcome: CompleteOutcome | null;
  readonly error: string | null;
  readonly viewGeneration: number;
}
 
/** Owns lifetime and admission; the native session owns all protocol execution. */
export function createDashboardApplication(options: {
  readonly sessionFactory: (threadId: string) => Session;
}) {
  type Primary = {
    session: Session;
    threadId: string;
    closed: boolean;
    release?: () => void;
    unsafe: boolean;
    confirmed: NativeSnapshot['transcript'];
    projection: DashboardProjection | null;
    surfaces: readonly DashboardSurface[];
    previousRunId?: string;
    turn?: OwnedTurn;
  };
  const listeners = new Set<() => void>(),
    cleanups = new Set<Promise<void>>();
  let primary: Primary | undefined;
  let operation: AbortController | undefined;
  let disposed = false,
    disposal: Promise<void> | undefined;
  let snapshot: DashboardSnapshot = Object.freeze({
    threadId: null,
    native: undefined,
    projection: null,
    surfaces: Object.freeze([]),
    busy: false,
    canSubmit: true,
    activity: 'idle',
    outcome: null,
    error: null,
    viewGeneration: 0,
  });
  function publish(update: Partial<DashboardSnapshot>) {
    if (disposed) return;
    snapshot = Object.freeze({ ...snapshot, ...update });
    for (const notify of listeners) {
      try {
        notify();
      } catch {
        /* A view cannot interrupt native execution or cleanup. */
      }
    }
  }
  function current(selected: Primary) {
    return (
      !disposed &&
      primary === selected &&
      !selected.closed &&
      !operation?.signal.aborted
    );
  }
  function close(selected: Primary) {
    if (selected.closed) return Promise.resolve();
    selected.closed = true;
    try {
      selected.release?.();
    } catch {
      /* Revoked observation cannot prevent native cleanup. */
    }
    const cleanup = Promise.resolve()
      .then(() => selected.session.dispose())
      .then(
        () => undefined,
        () => undefined
      );
    cleanups.add(cleanup);
    void cleanup.then(() => cleanups.delete(cleanup));
    return cleanup;
  }
  function detach() {
    const previous = primary;
    primary = undefined;
    return previous ? close(previous) : Promise.resolve();
  }
  function read(selected: Primary, valid: () => boolean) {
    if (!valid()) return;
    const root = selected.session.getSnapshot();
    if (!valid()) return;
    const blocked = unsupportedSnapshot(root, valid);
    return valid() ? { root, blocked } : undefined;
  }
  function capture(selected: Primary, valid: () => boolean) {
    const state = read(selected, valid);
    if (!state || !valid()) return;
    selected.unsafe ||= state.blocked;
    if (!selected.turn && snapshot.activity === 'running') {
      const turn = captureTurn(
        state.root,
        selected.threadId,
        selected.previousRunId,
        selected.confirmed,
        valid
      );
      if (!valid()) return;
      if (turn) selected.turn = turn;
    }
    const confirmed =
      selected.turn && !selected.unsafe
        ? confirmTranscript(state.root, selected.turn, valid)
        : undefined;
    if (!valid()) return;
    const accepted =
      confirmed && selected.turn
        ? selectDashboard(
            state.root,
            selected.turn,
            selected.projection ?? undefined,
            valid
          )
        : undefined;
    if (!valid()) return;
    const displayState = dashboardState(state.root.state);
    const projection =
      selected.projection && displayState
        ? Object.freeze({ ...selected.projection, state: displayState })
        : selected.projection;
    return valid()
      ? { native: state.root, projection, confirmed, accepted }
      : undefined;
  }
  function observe(selected: Primary) {
    const valid = () => current(selected);
    try {
      const result = capture(selected, valid);
      if (!result || !valid()) return;
      publish({
        native: result.native,
        projection: result.projection,
        surfaces: selected.surfaces,
        ...(selected.unsafe ? { canSubmit: false } : {}),
      });
    } catch {
      if (valid()) {
        selected.unsafe = true;
        publish({
          canSubmit: false,
          error:
            'The dashboard result could not be confirmed. Start a new conversation.',
        });
      }
    }
  }
  function install(valid: () => boolean) {
    const threadId = crypto.randomUUID();
    const session = options.sessionFactory(threadId);
    const selected: Primary = {
      session,
      threadId,
      closed: false,
      unsafe: false,
      confirmed: [],
      projection: null,
      surfaces: Object.freeze([]),
    };
    if (!valid()) {
      void close(selected);
      return;
    }
    primary = selected;
    const release = session.subscribe(() => observe(selected));
    selected.release = release;
    if (selected.closed) release();
    if (!valid() || primary !== selected) {
      void close(selected);
      return;
    }
    return selected;
  }
  function admit(activity: DashboardSnapshot['activity'], execution: boolean) {
    const admitted = new AbortController();
    operation = admitted;
    const valid = () =>
      !disposed && operation === admitted && !admitted.signal.aborted;
    publish({
      busy: true,
      canSubmit: false,
      activity,
      ...(execution ? { outcome: null, error: null } : {}),
    });
    return { admitted, valid };
  }
  function finish(admitted: AbortController) {
    if (!disposed && operation === admitted) {
      operation = undefined;
      publish({ busy: false, activity: 'idle' });
    }
  }
  async function execute(text: string) {
    if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
      return;
    const before = primary;
    const initialValid = () =>
      !disposed &&
      !snapshot.busy &&
      snapshot.canSubmit &&
      primary === before &&
      !before?.closed;
    let state: ReturnType<typeof read>;
    try {
      state = before && read(before, initialValid);
    } catch {
      if (initialValid())
        publish({
          canSubmit: false,
          error:
            'The dashboard session is unavailable. Start a new conversation.',
        });
      return;
    }
    if (!initialValid()) return;
    if (before && (before.unsafe || state?.blocked)) {
      publish({ canSubmit: false });
      return;
    }
    const { admitted, valid } = admit('running', true);
    try {
      if (!valid()) return;
      const selected = primary ?? install(valid);
      if (!selected || !valid()) return;
      publish({ threadId: selected.threadId });
      const ownedValid = () => valid() && current(selected);
      const root = read(selected, ownedValid);
      if (!root || !ownedValid()) return;
      if (root.blocked || selected.unsafe) {
        selected.unsafe = true;
        publish({ canSubmit: false });
        return;
      }
      selected.previousRunId = root.root.run?.id;
      selected.turn = undefined;
      if (!ownedValid()) return;
      const pending = selected.session.submit(text, {
        signal: admitted.signal,
      });
      void pending.catch(() => undefined);
      if (!ownedValid()) return;
      observe(selected);
      const outcome = await pending;
      if (!ownedValid()) return;
      const result = capture(selected, ownedValid);
      if (!result || !ownedValid()) return;
      const safe =
        outcome === 'success' &&
        !!result.confirmed &&
        !!result.accepted &&
        !selected.unsafe;
      if (safe) {
        selected.confirmed = result.confirmed!;
        selected.projection = result.accepted!;
        const { ownerId, spec } = selected.projection;
        if (
          ownerId &&
          spec &&
          !selected.surfaces.some((surface) => surface.ownerId === ownerId)
        )
          selected.surfaces = Object.freeze([
            ...selected.surfaces,
            Object.freeze({ ownerId, spec }),
          ]);
      }
      publish({
        native: result.native,
        projection: safe ? selected.projection : result.projection,
        surfaces: selected.surfaces,
        outcome,
        canSubmit: safe,
        error: safe
          ? null
          : 'The current dashboard outcome could not be confirmed. Start a new conversation.',
      });
    } catch {
      if (valid())
        publish({
          canSubmit: false,
          outcome: 'error',
          error: 'The AG-UI request failed. Start a new conversation.',
        });
    } finally {
      finish(admitted);
    }
  }
  async function newConversation() {
    if (disposed || snapshot.busy) return;
    const { admitted, valid } = admit('replacing', false);
    const cleanup = detach();
    try {
      if (!valid()) return;
      publish({
        threadId: null,
        native: undefined,
        projection: null,
        surfaces: Object.freeze([]),
        outcome: null,
        error: null,
        viewGeneration: snapshot.viewGeneration + 1,
      });
      if (!valid()) return;
      await cleanup;
      if (valid()) publish({ canSubmit: true });
    } finally {
      finish(admitted);
    }
  }
  async function stop() {
    if (disposed || !operation) return;
    operation.abort();
    operation = undefined;
    const selected = primary,
      cleanup = detach();
    publish({
      busy: false,
      activity: 'idle',
      canSubmit: false,
      outcome: 'aborted',
      error: 'The operation stopped. Start a new conversation.',
    });
    try {
      await selected?.session.stop();
    } catch {
      /* Detached work has no publication authority. */
    }
    await cleanup;
  }
  function dispose(): Promise<void> {
    if (disposal) return disposal;
    disposed = true;
    operation?.abort();
    operation = undefined;
    void detach();
    listeners.clear();
    disposal = (async () => {
      do {
        await Promise.resolve();
        await Promise.all([...cleanups]);
      } while (cleanups.size);
    })();
    return disposal;
  }
  return {
    getSnapshot: () => snapshot,
    subscribe(notify: () => void) {
      if (disposed) return () => undefined;
      listeners.add(notify);
      return () => {
        listeners.delete(notify);
      };
    },
    submit: execute,
    newConversation,
    stop,
    dispose,
  };
}

Use the existing server tools

Shared runtime uses the examples site's existing JSON Render agent. Runtime settings can supply an authored Developer runtime AG-UI endpoint. Its address stays outside the visible preview. The browser supplies no tool executor.

connection.ts
import { createSession } from '@threadplane/ag-ui';
import { createDashboardApplication } from './application';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
export function dashboardConnection(
  configuration: RuntimeBridgeConfiguration
): {
  url: string;
  label: 'Shared runtime' | 'Developer runtime';
} {
  if (
    configuration.status === 'error' ||
    (configuration.status === 'configured' &&
      configuration.target.kind === 'langsmith')
  )
    throw new Error('Runtime configuration failed.');
  const target =
    configuration.status === 'configured' &&
    configuration.target.kind === 'ag-ui'
      ? configuration.target
      : null;
  return {
    url:
      target?.endpoint ??
      new URL('/ag-ui/json-render/agent', window.location.origin).href,
    label: target ? 'Developer runtime' : 'Shared runtime',
  };
}
export function createConnectedApplication(
  connection: ReturnType<typeof dashboardConnection>
) {
  return createDashboardApplication({
    sessionFactory(threadId) {
      const session = createSession({ threadId, url: connection.url });
      try {
        const snapshot = session.getSnapshot();
        if (
          !snapshot ||
          !Array.isArray(snapshot.transcript) ||
          !Array.isArray(snapshot.subagents) ||
          ![
            'getSnapshot',
            'subscribe',
            'submit',
            'resume',
            'stop',
            'dispose',
          ].every(
            (method) =>
              typeof session[method as keyof typeof session] === 'function'
          )
        )
          throw new Error('Runtime session is unavailable.');
      } catch {
        if (typeof session?.dispose === 'function') {
          try {
            void Promise.resolve(session.dispose()).catch(() => undefined);
          } catch {
            /* Rejected candidates have no authority. */
          }
        }
        throw new Error('Runtime session is unavailable.');
      }
      return session;
    },
  });
}

The graph retains its model, prompt, catalog, reducers and loop. Each tool's injected call ID also identifies its result message, so streamed results and final snapshots retain one causal identity. The injected ID is absent from model-facing arguments. The layout is serialized once; the existing post-processing moves it into an empty assistant message or preserves an assistant's prose. Angular uses the same endpoint and data.

graph.py
"""Full airline-operations KPI dashboard graph for the ag-ui/json-render example.
 
Port of cockpit/chat/generative-ui/python/src/graph.py, adapted so dashboard
data lands in graph state (which ag-ui-langgraph auto-emits as STATE_SNAPSHOT)
instead of get_stream_writer (which does NOT survive the ag-ui-langgraph
runtime).
 
Flow:
  START → agent ↔ tools → wrap_spec_into_ai → agent (loop) → emit_state → respond → generate_title → END
 
`agent` is a single LLM call with all 5 tools bound (render_spec + 4 data
tools). After tools run, `wrap_spec_into_ai` post-processes — if the LLM
called render_spec, it replaces the parent AI message's content with the
spec JSON (in place via add_messages' id-match reducer) so the chat-lib's
content-classifier mounts <chat-generative-ui>. Then loops back to agent
until the LLM returns no tool_calls (cap _MAX_TOOL_ITERATIONS per turn).
 
emit_state walks the message history for this turn and returns the tool
results as top-level state fields — ag-ui-langgraph emits them as
STATE_SNAPSHOT; the Angular chat-lib effect syncs them into the explicit
[store] the app passes to <chat>, where the spec's $state bindings
resolve them.
"""
 
import json
import os
from pathlib import Path
from typing import Literal, Optional
 
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool, InjectedToolCallId
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from langgraph_sdk import get_client
from typing_extensions import Annotated, TypedDict
 
from .dashboard_tools import ALL_TOOLS as _DATA_TOOLS
 
_PROMPT = (Path(__file__).parent.parent / "prompts" / "json-render.md").read_text()
 
_MAX_TOOL_ITERATIONS = 6
 
 
# region dashboard-state
class DashboardState(TypedDict):
    """Graph state for the airline KPI dashboard.
 
    Data fields are declared here so ag-ui-langgraph includes them in the
    STATE_SNAPSHOT emitted to the client. The Angular sync effect maps each
    top-level key k → /k in the render store, so spec $state bindings like
    /on_time/value resolve correctly.
    """
    messages: Annotated[list, add_messages]
    on_time: Optional[dict]
    flights_today: Optional[dict]
    avg_delay: Optional[dict]
    load_factor: Optional[dict]
    on_time_trend: Optional[list]
    flights_by_airline: Optional[list]
    recent_disruptions: Optional[list]
# endregion
 
# region render-spec-tool
@tool
async def render_spec(elements: dict, root: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
    """Render an interactive dashboard layout.
 
    Use this tool to author or update the dashboard layout. See the system
    prompt for the full component catalog and state binding conventions.
 
    Call this tool AT MOST ONCE per turn — only when the layout needs to
    be created (first turn) or restructured (follow-up structural change).
    Do NOT call it again to refresh data; the data tools handle that.
 
    Args:
        elements: Dict keyed by component id. Each value has `type`, optional
            `props`, and optional `children` (list of component ids).
        root: The id of the top-level component (must be a key in `elements`).
 
    Returns:
        A stable tool message containing the spec serialized as JSON.
        A post-process node (wrap_spec_into_ai)
        wraps this payload into the AI message content where the
        chat-lib's content-classifier picks it up.
    """
    return ToolMessage(
        id=tool_call_id,
        tool_call_id=tool_call_id,
        name="render_spec",
        content=json.dumps({"elements": elements, "root": root}),
    )
# endregion
 
 
_ALL_TOOLS = [render_spec, *_DATA_TOOLS]
 
_llm_with_tools = ChatOpenAI(
    model="gpt-5",
    temperature=0,
    streaming=True,
    reasoning_effort="minimal",
).bind_tools(_ALL_TOOLS)
 
_respond_llm = ChatOpenAI(model="gpt-5-mini", temperature=0, streaming=True)
 
# ── generate_title node (inline; matches Pattern D from spec
#     2026-05-19-llm-generated-labels-design.md) ──────────────────────────────
 
_TITLE_PROMPT = (
    "In 3-5 words, summarize what the user is asking about. "
    "Output ONLY the title — no quotes, no period, no prefix."
)
_TITLE_MODEL = "gpt-5-mini"
 
 
async def generate_title(state: DashboardState, config) -> dict:
    """Background title generation: on the first turn, summarize the user's
    intent into 3-5 words and persist to LangGraph thread metadata.
 
    Idempotent — skips when metadata.title already exists. Errors are
    swallowed because the title is a UX nicety, never a blocker.
    """
    thread_id = (config.get("configurable") or {}).get("thread_id")
    if not thread_id:
        return {}
    sdk_url = os.environ.get("LANGGRAPH_API_URL")
    try:
        client = get_client(url=sdk_url)
        thread = await client.threads.get(thread_id)
        if (thread.get("metadata") or {}).get("title"):
            return {}
        first_user = next(
            (m for m in state["messages"] if getattr(m, "type", None) == "human"),
            None,
        )
        if not first_user or not isinstance(first_user.content, str):
            return {}
        if first_user.content.lstrip().startswith("{"):
            return {}
        llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0)
        response = await llm.ainvoke([
            SystemMessage(content=_TITLE_PROMPT),
            HumanMessage(content=first_user.content),
        ])
        title = (response.content or "").strip().strip('"').strip("'")[:80]
        if title:
            await client.threads.update(thread_id, metadata={"title": title})
    except Exception as e:  # noqa: BLE001 — title is a UX nicety; never block
        print(
            f"[generate_title] failed for thread {thread_id}: "
            f"{type(e).__name__}: {e}",
            flush=True,
        )
    return {}
 
 
async def agent(state: DashboardState) -> dict:
    """Single agentic node: LLM bound with all 5 tools."""
    messages = [SystemMessage(content=_PROMPT)] + state["messages"]
    response = await _llm_with_tools.ainvoke(messages)
    return {"messages": [response]}
 
 
def should_continue(state: DashboardState) -> Literal["tools", "finalize", "emit_state"]:
    """Loop while the agent emits tool_calls, up to _MAX_TOOL_ITERATIONS
    this turn. After the cap, route through `finalize` (strips orphan
    tool_calls from the last AI message) before emit_state — otherwise
    `respond`'s LLM call rejects the message history because the AI
    message had tool_calls without matching ToolMessages."""
    last = state["messages"][-1]
    if not (hasattr(last, "tool_calls") and last.tool_calls):
        return "emit_state"
 
    iter_count = 0
    for msg in reversed(state["messages"]):
        if msg.type == "human":
            break
        if msg.type == "ai" and getattr(msg, "tool_calls", None):
            iter_count += 1
    if iter_count >= _MAX_TOOL_ITERATIONS:
        return "finalize"
    return "tools"
 
 
async def finalize(state: DashboardState) -> dict:
    """Strip tool_calls from the last AI message when the iteration cap
    is hit, so respond's LLM call doesn't fail with 'tool_calls without
    matching ToolMessage' errors. Replaces in place via add_messages
    id-match reducer."""
    last = state["messages"][-1]
    if not (isinstance(last, AIMessage) and last.tool_calls):
        return {}
    replacement_kwargs: dict = {
        "content": last.content or "(iteration cap reached)",
        "additional_kwargs": last.additional_kwargs or {},
        "response_metadata": last.response_metadata or {},
    }
    if getattr(last, "id", None):
        replacement_kwargs["id"] = last.id
    return {"messages": [AIMessage(**replacement_kwargs)]}
 
 
# region wrap-spec-into-ai
async def wrap_spec_into_ai(state: DashboardState) -> dict:
    """Post-process that wraps the most recent render_spec ToolMessage
    payload into the parent AI tool-call message's content (in place via
    LangGraph's add_messages reducer matching by id). The chat-lib's
    content-classifier then sees content starting with `{` and mounts
    <chat-generative-ui>.
 
    Idempotent: if the parent AI message already has non-empty content
    (already wrapped on a prior iteration), no-op. Also no-op if there
    is no render_spec ToolMessage to process.
 
    Mirrors emit_generated_surface from examples/chat/python/src/graph.py,
    adapted to loop back to `agent` instead of going to END.
    """
    msgs = state["messages"]
 
    render_tool_msg: ToolMessage | None = None
    parent_ai: AIMessage | None = None
    for m in reversed(msgs):
        if isinstance(m, ToolMessage) and m.name == "render_spec":
            render_tool_msg = m
            for prior in reversed(msgs):
                if isinstance(prior, AIMessage) and prior.tool_calls:
                    if any(tc.get("id") == render_tool_msg.tool_call_id for tc in prior.tool_calls):
                        parent_ai = prior
                        break
            break
 
    if render_tool_msg is None or parent_ai is None:
        return {}
 
    existing = parent_ai.content
    if isinstance(existing, str) and existing.strip():
        return {}
 
    payload = render_tool_msg.content if isinstance(render_tool_msg.content, str) else ""
    if not payload:
        return {}
# endregion
 
    # region rewrite-assistant-content
    stripped = payload.strip()
    if stripped.startswith("```"):
        lines = stripped.split("\n")
        stripped = "\n".join(line for line in lines if not line.startswith("```")).strip()
 
    out: list = []
 
    placeholder_kwargs: dict = {
        "content": "rendered",
        "tool_call_id": render_tool_msg.tool_call_id,
        "name": "render_spec",
    }
    if getattr(render_tool_msg, "id", None):
        placeholder_kwargs["id"] = render_tool_msg.id
    out.append(ToolMessage(**placeholder_kwargs))
 
    replacement_kwargs: dict = {
        "content": stripped,
        "tool_calls": parent_ai.tool_calls,
        "additional_kwargs": parent_ai.additional_kwargs or {},
        "response_metadata": parent_ai.response_metadata or {},
    }
    if getattr(parent_ai, "id", None):
        replacement_kwargs["id"] = parent_ai.id
    out.append(AIMessage(**replacement_kwargs))
 
    return {"messages": out}
    # endregion
 
 
# region emit-state
async def emit_state(state: DashboardState) -> dict:
    """Accumulate this turn's tool results into graph state so ag-ui-langgraph
    emits them as STATE_SNAPSHOT. Walk messages in reverse to the most recent
    human turn; map each known data tool to its state field(s).
    query_airline_kpis returns the four nested *_card sections; merge directly."""
    updates: dict = {}
    for msg in reversed(state["messages"]):
        if getattr(msg, "type", None) == "tool":
            try:
                data = json.loads(msg.content) if isinstance(msg.content, str) else msg.content
            except (json.JSONDecodeError, TypeError):
                continue
            if msg.name == "query_airline_kpis" and isinstance(data, dict):
                updates.update(data)  # {on_time:{...}, flights_today:{...}, avg_delay:{...}, load_factor:{...}}
            elif msg.name == "query_on_time_trend":
                updates["on_time_trend"] = data
            elif msg.name == "query_flights_by_airline":
                updates["flights_by_airline"] = data
            elif msg.name == "query_recent_disruptions":
                updates["recent_disruptions"] = data
        elif getattr(msg, "type", None) == "human":
            break
    return updates
# endregion
 
 
async def respond(state: DashboardState) -> dict:
    """Generate a brief conversational summary of what just happened on
    this turn. ALWAYS runs (no early-exit)."""
    messages = [
        SystemMessage(content=(
            "Provide a brief (1-2 sentence) conversational summary of what "
            "you just did this turn. If you generated a dashboard, say so. "
            "If you filtered data, say what you filtered. "
            "Do NOT output JSON. Do NOT ask follow-up questions."
        ))
    ] + state["messages"]
    response = await _respond_llm.ainvoke(messages)
    return {"messages": [response]}
 
 
# region graph-wiring
_builder = StateGraph(DashboardState)
_builder.add_node("agent", agent)
_builder.add_node("tools", ToolNode(_ALL_TOOLS))
_builder.add_node("wrap_spec_into_ai", wrap_spec_into_ai)
_builder.add_node("finalize", finalize)
_builder.add_node("emit_state", emit_state)
_builder.add_node("respond", respond)
_builder.add_node("generate_title", generate_title)
 
_builder.set_entry_point("agent")
_builder.add_conditional_edges("agent", should_continue)
_builder.add_edge("tools", "wrap_spec_into_ai")
_builder.add_edge("wrap_spec_into_ai", "agent")
_builder.add_edge("finalize", "emit_state")
_builder.add_edge("emit_state", "respond")
_builder.add_edge("respond", "generate_title")
_builder.add_edge("generate_title", END)
 
# ag-ui-langgraph requires a checkpointer to manage thread state.
# The chat example omits it because LangGraph Cloud provides one at runtime,
# but the ag-ui-langgraph/uvicorn runtime needs it explicitly.
graph = _builder.compile(checkpointer=MemorySaver())
# endregion
dashboard_tools.py
"""Aviation KPI tools for the c-generative-ui standalone demo.
 
Standalone copy — analytics constants are inlined here because this
backend has no shared aviation_data.py module. Mirrors the umbrella
backend at cockpit/langgraph/streaming/python/src/dashboard_tools.py.
"""
 
import json
from typing import Annotated
 
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool, InjectedToolCallId
 
 
def _result(name: str, tool_call_id: str, payload: dict | list) -> ToolMessage:
    """Keep streamed and finalized results at the same causal message ID."""
    return ToolMessage(
        id=tool_call_id,
        tool_call_id=tool_call_id,
        name=name,
        content=json.dumps(payload),
    )
 
# ── Analytics fixtures (inlined; no aviation_data.py in standalone) ─────────
 
KPI_SNAPSHOT = {
    "on_time_pct": 84.2,
    "on_time_delta": "+1.4%",
    "flights_today": 312,
    "flights_today_delta": "+8",
    "avg_delay_min": 12,
    "avg_delay_delta": "-2 min",
    "load_factor_pct": 78.5,
    "load_factor_delta": "+0.6%",
}
 
ON_TIME_TREND = [
    {"month": "2025-05", "on_time_pct": 82.4},
    {"month": "2025-06", "on_time_pct": 81.1},
    {"month": "2025-07", "on_time_pct": 79.8},
    {"month": "2025-08", "on_time_pct": 80.5},
    {"month": "2025-09", "on_time_pct": 83.2},
    {"month": "2025-10", "on_time_pct": 84.0},
    {"month": "2025-11", "on_time_pct": 82.6},
    {"month": "2025-12", "on_time_pct": 78.9},
    {"month": "2026-01", "on_time_pct": 80.2},
    {"month": "2026-02", "on_time_pct": 81.7},
    {"month": "2026-03", "on_time_pct": 82.8},
    {"month": "2026-04", "on_time_pct": 84.2},
]
 
FLIGHTS_BY_AIRLINE = [
    {"airline": "American", "count": 87},
    {"airline": "United",   "count": 92},
    {"airline": "Delta",    "count": 78},
    {"airline": "JetBlue",  "count": 55},
]
 
RECENT_DISRUPTIONS = [
    {"flight_number": "UA123", "type": "delayed",   "minutes": 45, "route": "LAX→JFK", "date": "2026-05-14"},
    {"flight_number": "AA456", "type": "cancelled", "minutes": 0,  "route": "JFK→LAX", "date": "2026-05-14"},
    {"flight_number": "DL789", "type": "delayed",   "minutes": 22, "route": "ATL→ORD", "date": "2026-05-13"},
    {"flight_number": "B6101", "type": "delayed",   "minutes": 68, "route": "BOS→MIA", "date": "2026-05-13"},
    {"flight_number": "UA204", "type": "cancelled", "minutes": 0,  "route": "SFO→SEA", "date": "2026-05-12"},
    {"flight_number": "AA318", "type": "delayed",   "minutes": 15, "route": "DFW→DEN", "date": "2026-05-12"},
    {"flight_number": "DL552", "type": "delayed",   "minutes": 35, "route": "ATL→MIA", "date": "2026-05-11"},
    {"flight_number": "B6217", "type": "delayed",   "minutes": 80, "route": "JFK→BOS", "date": "2026-05-11"},
    {"flight_number": "UA640", "type": "cancelled", "minutes": 0,  "route": "ORD→DEN", "date": "2026-05-10"},
    {"flight_number": "AA871", "type": "delayed",   "minutes": 25, "route": "LAX→DFW", "date": "2026-05-10"},
]
 
 
# ── Tools ───────────────────────────────────────────────────────────────────
 
@tool
def query_airline_kpis(tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
    """Snapshot of operational KPIs across the fleet: on-time %, flights today,
    average delay (minutes), and load factor."""
    snap = KPI_SNAPSHOT
    return _result("query_airline_kpis", tool_call_id, {
        "on_time":       {"value": f"{snap['on_time_pct']}%",      "delta": snap["on_time_delta"]},
        "flights_today": {"value": snap["flights_today"],          "delta": snap["flights_today_delta"]},
        "avg_delay":     {"value": f"{snap['avg_delay_min']} min", "delta": snap["avg_delay_delta"]},
        "load_factor":   {"value": f"{snap['load_factor_pct']}%",  "delta": snap["load_factor_delta"]},
    })
 
 
@tool
def query_on_time_trend(months: int = 12, *, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
    """On-time performance over time, as percentage by month.
 
    Args:
        months: Number of months to return (default 12). Valid: 3, 6, 12, 24.
    """
    months = min(months, len(ON_TIME_TREND))
    return _result("query_on_time_trend", tool_call_id, ON_TIME_TREND[-months:])
 
 
@tool
def query_flights_by_airline(airlines: list[str] | None = None, *, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
    """Daily flight counts per airline.
 
    Args:
        airlines: Optional filter list, e.g. ["American", "United"]. All four
                  returned if omitted.
    """
    if airlines:
        return _result("query_flights_by_airline", tool_call_id, [a for a in FLIGHTS_BY_AIRLINE if a["airline"] in airlines])
    return _result("query_flights_by_airline", tool_call_id, FLIGHTS_BY_AIRLINE)
 
 
@tool
def query_recent_disruptions(limit: int = 5, type: str | None = None, *, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
    """Recent flight delays or cancellations.
 
    Args:
        limit: Maximum entries to return (default 5).
        type: Optional filter, "delayed" or "cancelled".
    """
    filtered = RECENT_DISRUPTIONS
    if type:
        filtered = [d for d in filtered if d["type"] == type]
    return _result("query_recent_disruptions", tool_call_id, filtered[:limit])
 
 
ALL_TOOLS = [
    query_airline_kpis,
    query_on_time_trend,
    query_flights_by_airline,
    query_recent_disruptions,
]
server.py
from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
 
agent = LangGraphAgent(name="json-render", graph=graph)
app = FastAPI(title="cockpit-ag-ui-json-render")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
 
 
@app.get("/ok")
def ok() -> dict:
    return {"ok": True}

Source build and verification

From a Threadplane checkout with dependencies installed:

npx nx build cockpit-ag-ui-json-render-react
npx nx e2e cockpit-ag-ui-json-render-react

The isolated build installs locked private artifacts with strict types. Browser checks run the actual compiled graph and AG-UI bridge with frozen providers, covering layout changes, shared-state updates, tool rounds, retained native history, cancellation and protected outcomes. Docs, Code and Run select this same implementation. AG-UI Streaming, Interrupts and Tool Views and Subagents and nine LangGraph topics also have React previews; other topics remain pending.

Looking for something specific?