Tether
DocsGitHub

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.