Skip to content
KONIGI

AI Assistants / Output shape / Structured output

4 of 4

Structured output

The answer is a comparison, and prose is the worst available shape for one.

Updated September 12, 2026

Problem

Someone asks how four options compare across three criteria. The answer arrives as five paragraphs. Every fact is present and the reader has to hold twelve values in their head to do the comparison the question asked for.

Solution

Let the answer take the shape of the question. A comparison is a table. A sequence wants numbers down the left margin. Anything measured over time belongs on a chart. Prose is the right container for an argument and the wrong one for a grid, and a model that writes everything as paragraphs is defaulting rather than choosing.

NN/g’s finding on site chatbots points the same way: people arrive wanting an answer they can scan, not a conversation, and responses should be direct and structured with detail available on demand. The general form of that is to match the output’s structure to the information’s structure.

Tables are where most of the value and most of the failure sits. A generated table is reliable about its shape and much less reliable about its contents, and the visual authority of a grid is considerably higher than that of a paragraph. The same wrong number is questioned in prose and accepted in a cell. Where cells carry facts drawn from sources, per-cell attribution does more work than a citation on the whole answer.

Rendering has to survive contact with reality. Markdown tables break when a cell contains a pipe, wrap badly at narrow widths, and can’t be sorted. Once a table is more than about four columns it needs a real container with horizontal scrolling of its own, so that a wide grid doesn’t force the whole page sideways. Exporting to CSV is a small addition that converts an artifact people screenshot into one they use.

Accessibility is the part skipped most often. A grid rendered with alignment and spacing instead of real table semantics is unreadable to a screen reader, which announces a wall of numbers with no way to associate a value with its row and column. Header cells have to be marked as headers.

The honest limit is that the model chooses the structure, and it chooses badly in both directions. Forcing a three-row table onto a simple answer adds ceremony; answering a genuinely tabular question in prose loses the comparison. Letting the viewer ask for a different shape after the fact is cheaper than getting the routing right every time.

Use when

The information has a natural structure and the reader’s task is comparison, sequence, or lookup.

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

Structure makes an answer scannable and strips the hedging that prose carries naturally, so a table states as fact what a sentence would have qualified. Grids also raise perceived precision without raising accuracy. Rich rendering looks better and complicates copying, since what lands in the clipboard is frequently not what was on screen. Every structured format adds a rendering path that has to survive malformed output from a model only mostly reliable about syntax.

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 is where a large structure goes when the transcript can’t hold it. Code block actions is the same control problem for fenced code. Response collapse handles the length these formats produce. Streaming response determines how a table behaves while it arrives. Citation chip is what makes an individual cell checkable.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.