Guides
Rendering blocks
Tether ships no UI. What it ships is a contract narrow enough that a complete renderer is an afternoon, and stable enough that you write it once.
The whole renderer
Nine types, one switch:
export function Blocks({ blocks }) {
return blocks.map((block) => {
switch (block.type) {
case 'text': return <Text key={block.id} {...block} />
case 'kpi': return <Kpi key={block.id} {...block} />
case 'table': return <Table key={block.id} {...block} />
case 'ranked_list': return <RankedList key={block.id} {...block} />
case 'stacked_bar': return <StackedBar key={block.id} {...block} />
case 'cards': return <Cards key={block.id} {...block} />
case 'places': return <Places key={block.id} {...block} />
case 'quick_replies': return <QuickReplies key={block.id} {...block} />
case 'footnote': return <Footnote key={block.id} {...block} />
default: return null // forward compatibility, see below
}
})
}
Components
function Text({ variant = 'body', markdown }) {
const Tag = variant === 'title' ? 'h3' : 'p'
return <Tag className={`block-text ${variant}`}>{renderMarkdown(markdown)}</Tag>
}
function Kpi({ title, items }) {
return (
<section className="block-kpi">
{title && <h3>{title}</h3>}
<dl>
{items.map((item) => (
<div key={item.label} data-intent={item.intent ?? 'neutral'}>
<dt>{item.label}</dt>
<dd>{formatNumber(item.value)}</dd>
{item.delta != null && <span>{formatPercent(item.delta)}</span>}
</div>
))}
</dl>
</section>
)
}
function Table({ title, columns, rows }) {
return (
<figure className="block-table">
{title && <figcaption>{title}</figcaption>}
<table>
<thead>
<tr>{columns.map((c) => <th key={c.key} align={c.align ?? 'left'}>{c.label}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={row.id ?? i}>
{columns.map((c) => (
<td key={c.key} align={c.align ?? 'left'}>{formatCell(row[c.key])}</td>
))}
</tr>
))}
</tbody>
</table>
</figure>
)
}
Note what Table does not do: it never iterates the row's own keys. Columns are declared, and
a row may carry fields no column names. Render the declared columns, in the declared order.
Formatting is yours
The contract sends numbers as numbers precisely so that formatting is a decision you make once, in your locale, with your currency, in your typography.
const formatNumber = (n) =>
new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(n)
const formatPercent = (d) =>
new Intl.NumberFormat(locale, { style: 'percent', signDisplay: 'exceptZero' }).format(d)
delta is a fraction, not a percentage: 0.12 renders as +12%.
Intent versus sign
intent is the model's judgement about whether a number is good news; delta is arithmetic.
Costs falling is positive with a negative delta.
<span data-intent={item.intent ?? 'neutral'}> {/* colour from intent */}
{item.delta > 0 ? '▲' : '▼'} {/* direction from delta */}
</span>
Colouring from the sign is the mistake that makes an assistant look like it does not understand the business.
Theme tokens
Colour arrives as a semantic token, never a hex value. Map it once:
:root {
--series-1: #4c6ef5;
--series-2: #12b886;
--series-3: #f59f00;
--series-4: #e8590c;
--series-5: #7048e8;
--series-6: #1098ad;
--section-positive: var(--series-2);
--section-warning: var(--series-3);
--section-action: var(--series-4);
--severity-good: #12b886;
--severity-neutral: #868e96;
--severity-warning: #f59f00;
--severity-critical: #e03131;
}
const tokenVar = (token) => `var(--${token.replace(/_/g, '-')})`
This is why the model never emits colours: your palette changes, dark mode exists, and neither is something a language model should have an opinion about.
Quick replies
A quick_replies block is a list of next prompts. Send the label back as the next prompt
with the same sessionId:
function QuickReplies({ replies }) {
const { ask, sessionId } = useAssistant()
return (
<div className="block-quick-replies">
{replies.map((r) => (
<button key={r.id} onClick={() => ask({ prompt: r.label, sessionId })}>
{r.label}
</button>
))}
</div>
)
}
Degradation is normal
When a generation cannot be repaired into a valid document, the answer arrives as a single
text block containing the raw text. This is a working answer, not an error — a screen that
renders one text block is the fallback path working exactly as designed.
Your renderer needs no special case. Watch the rate in telemetry rather than in the UI.
Titles and empty states
title is optional at the document level. blocks always has at least one entry, so there is no
empty-answer case to handle — a turn with nothing to say returns a text block saying so.
Versioning
Branch on version rather than sniffing shapes:
if (doc.version !== 'tether.blocks.v1') {
return <UnsupportedVersion doc={doc} />
}
The response normalizer also accepts the legacy vellin. version prefix from before the service
was renamed, so an older constant echoed by a model is not treated as malformed.