Skip to content
KONIGI Patterns

AI Assistants / Answer formats / Structured output

‹ 4 of 4

Structured output

People need answers in a format that makes the information easy to use.

Updated September 12, 2026

Problem

An answer written as paragraphs can make comparisons or sequences harder to follow than they need to be.

Solution

Choose a format that fits the task. Use a table for comparing values, numbered steps for a sequence, and prose for an explanation. Let people request another format when the choice does not help.

Use proper table headers so screen readers can connect values to their rows and columns. Keep wide tables inside their own scroll area on small screens.

Preserve qualifications and sources alongside factual values. Offer copying or export in a useful format when people need to work with the result elsewhere. Handle malformed or incomplete output without breaking the page.

Use when

The information is easier to compare, follow, or look up in a structured format.

Don’t use when

The answer is an argument or an explanation. A table of an idea’s components communicates less than a paragraph, and chopping reasoning into cells removes the connective tissue that made it reasoning.

Trade-offs

Tables can make uncertain information look more precise than it is. Rich formats add rendering work and can copy poorly into other apps. A simple answer may become harder to read if forced into an unnecessary structure.

Checklist

  • Does the shape of the answer match the shape of the question?
  • Can a wide table scroll inside its own container?
  • Are header cells marked as headers for assistive technology?
  • What happens when a cell contains a pipe, a newline, or a link?
  • Is per-cell sourcing available where cells carry facts?
  • Can the viewer copy the table as text and as data?
  • Is there an export path for anything above a few rows?
  • How does a partially streamed table render?
  • Can the viewer ask for a different shape without re-asking the question?
  • Does the format degrade legibly at a phone width?

Compare

ChatGPT renders markdown tables inline and offers a canvas for output meant to be edited, separating a table read once from one that becomes a working document. Perplexity leans hardest into structured answers, leading with a scannable summary before any prose, which suits a product whose users came for a fact. Claude routes substantial structured output into an artifact where it can be revised across turns rather than regenerated whole. Notion converts the output into native database rows, so a generated table becomes an object with sorting, filtering and permissions instead of formatted text.

Artifact panel gives large outputs more room. Code block actions offers controls for code. Response collapse can hide secondary detail.

Streaming response affects how incomplete structures appear. Citation chip connects facts within the output to sources.

Structured output anatomy The same comparison answered twice: as five paragraphs of prose and as a four-by-three grid, with per-cell sourcing on the grid, a scrolling container for the extra columns, and a control to ask for a different shape. Let the answer take the shape of the question twelve values to hold in your head Vendor Price SLA Acme £4,200 99.9% Borden £3,850 99.5% Curtis £5,010 99.95% as a list export csv a grid states as fact what a sentence would have qualified 1 2 3 4 1 PROSE IS THE DEFAULT A model that writes everything as paragraphs is defaulting, not choosing. 2 SOURCE THE CELL Where cells carry facts, per-cell sourcing does more than one chip on the answer. 3 SCROLL INSIDE ITSELF Past four columns a grid needs its own container, or the page goes sideways. 4 ASK FOR ANOTHER SHAPE The model routes badly in both directions. Cheaper to let the viewer change it after. A grid rendered with spacing instead of table markup is a wall of numbers to a screen reader.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A comparison answered as a grid. Real table markup so a screen reader can associate a value with its row and column, a source chip on any cell that carries a fact, a scrolling container past a few columns, and a control to ask for a different shape without re-asking.

shadcn
npx shadcn@latest add table button
Tokens
--card--card-foreground--muted--muted-foreground--border--accent
VendorPriceSLA
Acme£4,20099.9%
Borden£3,85099.5%
Curtis£5,01099.95%

AnswerTable.tsxHeader cells are headers, each cell may carry its own source, the box scrolls sideways on its own, and export builds real CSV with quoting.

import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";

/**
 * A comparison answered as a grid.
 *
 * Real table markup, because a grid drawn with spacing is a wall of numbers
 * to a screen reader; header cells are headers. Each cell can carry its own
 * source, because a number in a cell is believed in a way the same number in
 * a sentence is not, and one chip on the whole answer does not say which
 * cells it covers. The table scrolls inside its own box past a few columns
 * rather than pushing the page sideways.
 */
export type Source = { id: number; label: string; href?: string };

export type Cell = { value: string; source?: Source };

export type Shape = "table" | "list";

type Props = {
  columns: string[];
  /** One entry per column, in column order. */
  rows: Cell[][];
  onSource: (source: Source) => void;
  /** The model routed this answer to a table. The viewer may disagree without
   *  asking the question again. */
  onReshape: (shape: Shape) => void;
  onExport: (csv: string) => void;
};

/** RFC 4180: quote a field that holds a comma, a quote or a newline. */
const field = (s: string) => (/[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s);

export const toCsv = (columns: string[], rows: Cell[][]) =>
  [columns, ...rows.map((r) => r.map((c) => c.value))].map((r) => r.map(field).join(",")).join("\n");

export function AnswerTable({ columns, rows, onSource, onReshape, onExport }: Props) {
  return (
    <div>
      <div className="overflow-x-auto rounded-lg border bg-muted">
        <Table className="min-w-[320px] text-[11px]">
          <TableHeader>
            <TableRow>
              {columns.map((c) => (
                <TableHead key={c} scope="col" className="h-8 px-3">{c}</TableHead>
              ))}
            </TableRow>
          </TableHeader>
          <TableBody className="tabular-nums text-card-foreground">
            {rows.map((cells, i) => (
              <TableRow key={i} className="border-0">
                {cells.map((cell, j) => (
                  <TableCell key={j} className="px-3 py-1.5 whitespace-nowrap">
                    {cell.value}
                    {cell.source && (
                      <button
                        type="button"
                        onClick={() => onSource(cell.source!)}
                        title={cell.source.label}
                        aria-label={`Source ${cell.source.id}: ${cell.source.label}`}
                        className="ml-1.5 inline-flex h-3.5 min-w-3.5 items-center justify-center rounded-full border px-1 align-middle text-[9px] text-muted-foreground hover:bg-accent"
                      >
                        {cell.source.id}
                      </button>
                    )}
                  </TableCell>
                ))}
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </div>

      <div className="mt-4 flex gap-2">
        <Button variant="outline" size="sm" className="h-7 px-2.5 text-[11px] font-normal" onClick={() => onReshape("list")}>
          as a list
        </Button>
        <Button variant="outline" size="sm" className="h-7 px-2.5 text-[11px] font-normal" onClick={() => onExport(toCsv(columns, rows))}>
          export csv
        </Button>
      </div>
    </div>
  );
}

demo.tsxHow it is called: three vendors across price and SLA, two prices sourced to workspace documents. As a list reshapes the same cells.

import { useState } from "react";
import { AnswerTable, type Cell, type Shape, type Source } from "./AnswerTable";

/**
 * Three vendors across price and SLA, the kind of question that arrives as
 * five paragraphs. Two of the prices are sourced to a document in the
 * workspace; the third came from the vendor's public page and is not.
 * "as a list" reshapes the same cells without re-asking.
 */
const QUOTE: Source = { id: 1, label: "Acme quote, 4 Sep", href: "#acme-quote" };
const PROPOSAL: Source = { id: 2, label: "Borden proposal, p. 3", href: "#borden-proposal" };

const COLUMNS = ["Vendor", "Price", "SLA"];
const ROWS: Cell[][] = [
  [{ value: "Acme" }, { value: "£4,200", source: QUOTE }, { value: "99.9%" }],
  [{ value: "Borden" }, { value: "£3,850", source: PROPOSAL }, { value: "99.5%" }],
  [{ value: "Curtis" }, { value: "£5,010" }, { value: "99.95%" }],
];

export default function Demo() {
  const [shape, setShape] = useState<Shape>("table");

  return (
    <div className="rounded-lg border bg-card p-4">
      {shape === "table" ? (
        <AnswerTable
          columns={COLUMNS}
          rows={ROWS}
          onSource={(s) => { if (s.href) location.hash = s.href; }}
          onReshape={setShape}
          onExport={(csv) => navigator.clipboard.writeText(csv)}
        />
      ) : (
        <div>
          <ol className="space-y-2 text-[11px] text-card-foreground">
            {ROWS.map(([vendor, price, sla]) => (
              <li key={vendor.value}>
                <span className="font-medium">{vendor.value}</span>: {price.value} a month, {sla.value} uptime
              </li>
            ))}
          </ol>
          <button type="button" className="mt-4 rounded-md border px-2.5 py-1 text-[11px]" onClick={() => setShape("table")}>
            as a table
          </button>
        </div>
      )}
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.