Elements

Elements · Structured output

Score breakdown

A verdict with its arithmetic shown: criteria, weights, and what pulled it down.

4.1/ 5approve
Fixes the root cause×34.5
Clears the slot at the source rather than at the call site.
fig. 01 · plays once, replay from the corner

Installation

npx shadcn@latest add "@assistant-ui/elements-score-breakdown"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A verdict backed by its own arithmetic: a total out of a max, a colored verdict pill, and the weighted criteria that produced it, each with its own bar and an optional note on why it scored the way it did. With a runtime the score comes from a tool result; standalone you pass the criteria and the total yourself.

Getting started

This element has no assistant-ui primitive of its own, so the runtime wiring is a tool renderer rather than a primitive composition. A score is usually the product of real grading work: running tests, checking a diff, comparing against a rubric, so the criteria belong on the tool's result rather than on its streamed args.

Register the render function

app/toolkit.tsx
"use client";

import { defineToolkit } from "@assistant-ui/react";
import { ScoreBreakdown } from "@/components/assistant-ui/elements/score-breakdown";

export const toolkit = defineToolkit({
  review_change: {
    type: "backend",
    render: ({ args, result }) => {
      if (!result) return <p>Reviewing {args.target}</p>;
      return (
        <ScoreBreakdown
          verdict={result.verdict}
          total={result.total}
          outOf={result.outOf}
          criteria={result.criteria}
          visibleCount={result.criteria.length}
        />
      );
    },
  },
});

Because the whole breakdown lands as one result rather than as streamed steps, visibleCount is simply result.criteria.length; there is no partial state to size it against.

Let the message list render it

app/MyRuntimeProvider.tsx
import {
  AssistantRuntimeProvider,
  AuiConfig,
  Tools,
} from "@assistant-ui/react";
import { useChatRuntime } from "@assistant-ui/ai-sdk";
import { toolkit } from "./toolkit";

export function MyRuntimeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const runtime = useChatRuntime();
  const config = AuiConfig({ tools: Tools({ toolkit }) });
  return (
    <AssistantRuntimeProvider runtime={runtime} config={config}>
      {children}
    </AssistantRuntimeProvider>
  );
}

Once the toolkit is registered, Thread and any custom message list built on assistant-ui's message part primitives (MessagePrimitive.Parts or MessagePrimitive.GroupedParts) render the registered UI automatically wherever the review_change call appears in the message, so nothing needs to be placed by hand.

Anatomy

<div data-slot="score-breakdown">
  <div>
    <span>{/* total.toFixed(1) */}</span>
    <span>{/* "/ outOf" */}</span>
    <span>{/* verdict pill, colored by the total/outOf ratio */}</span>
  </div>

  <div>
    {/* one row per visible criterion */}
    <div>
      <span>{/* criterion.label */}</span>
      <span>{/* "×" + weight */}</span>
      <span>{/* criterion.score.toFixed(1) */}</span>
    </div>
    <span role="meter">{/* per-criterion bar, named from the criterion */}</span>
    <span>{/* optional criterion.note */}</span>
  </div>
</div>

The header (the total, the / outOf, and the verdict pill) always renders regardless of visibleCount; only the criteria list is sliced, floored and clamped into 0…criteria.length the same way it is on the other structured output elements. The verdict pill's color is a plain threshold on total / outOf, treated as 0 when outOf is 0: emerald at 0.75 and above, amber from 0.5 up to just under 0.75, red below 0.5. Each criterion's own bar is scaled against the overall outOf, not against that criterion's own weight or any per-criterion maximum, so two criteria with the same score always draw identical bar widths no matter how differently they are weighted; weight only ever shows up as the ×N label next to the score. Each criterion bar's track is a named meter with a 0…100 value matching its score width and value text reading the same score of outOf the row prints; the value sits on the track rather than the fill, which collapses to nothing at zero.

Examples

An empty denominator

outOf: 0 is a real edge case the component handles rather than dividing by zero: the ratio reads as 0, so the verdict pill always falls into the red band regardless of what total says.

<ScoreBreakdown
  verdict="not scored"
  total={0}
  outOf={0}
  criteria={[]}
  visibleCount={0}
/>

Where the score comes from

The backend entry declares only what is being reviewed. The model supplies target, your server runs the actual grading, and the client renderer never sees anything but the finished verdict.

app/api/chat/route.ts
review_change: tool({
  description: "Grade a proposed change against a rubric.",
  inputSchema: z.object({ target: z.string() }),
  execute: async ({ target }) => gradeChange(target),
}),

Restyle the breakdown

Both lanes take className on the root. The weight and score labels use the shared mono surface from surfaces.tsx, so restyling that token restyles every element that uses it.

<ScoreBreakdown className="max-w-none gap-4" /* ... */ />

API reference

Render props

PropTypeDescription
args{ target: string }The tool's arguments, naming what is being reviewed.
result{ verdict: string; total: number; outOf: number; criteria: ScoreCriterion[] } | undefinedThe finished verdict, undefined until the backend executor resolves.

review_change result

FieldTypeDescription
verdictstringShort label shown in the pill.
totalnumberThe overall score, shown to one decimal place.
outOfnumberThe maximum possible score, also the denominator every criterion's bar is scaled against.
criteria[].labelstringThe criterion's name.
criteria[].scorenumberThat criterion's own score, shown to one decimal place.
criteria[].weightnumberShown as ×weight, has no effect on the bar width.
criteria[].notestringOptional caption under the bar.