Reference
Block types
Nine types, one version. Every block carries a type and a stable id; everything else
depends on the type.
The document
{
"content_type": "application/vnd.tether.blocks.v1+json",
"version": "tether.blocks.v1",
"title": "Optional heading",
"blocks": [ ],
"meta": { "time_range": { "start": "…", "end": "…" } }
}
content_type and version are constants. blocks has at least one entry. meta is always an
object. title is optional.
Unknown top-level keys and unknown block types are rejected — the document is validated strictly, then repaired or degraded rather than passed through. See The output contract.
text
Prose. The most common block by a wide margin.
{ "type": "text", "id": "summary", "variant": "body",
"markdown": "Seven tickets are open. **T-4417** has been open 41 minutes." }
| Field | Required | Notes |
|---|---|---|
markdown | yes | Non-empty. Inline markdown — bold, italic, links, code |
variant | no | body | caption | title |
kpi
One or more headline numbers.
{ "type": "kpi", "id": "headline", "title": "Today",
"items": [
{ "label": "Open tickets", "value": 7 },
{ "label": "Average wait", "value": 12.5, "delta": -0.18, "intent": "positive" }
] }
| Field | Required | Notes |
|---|---|---|
items | yes | At least one |
items[].label | yes | Non-empty string |
items[].value | yes | Number, never a formatted string |
items[].delta | no | Number. A fraction, not a percentage — 0.12 is +12% |
items[].intent | no | positive | neutral | negative |
title | no | String |
intent is a judgement, not a sign. Costs falling is positive even though the delta is
negative — render the colour from intent and the arrow from delta.
table
Columnar data with declared columns.
{ "type": "table", "id": "by_vendor", "title": "Spend by vendor",
"columns": [
{ "key": "vendor", "label": "Vendor", "align": "left" },
{ "key": "amount", "label": "Amount", "align": "right" }
],
"rows": [
{ "label": "Northside Produce", "amount": 3120.4 },
{ "label": "Harbor Meats", "amount": 2870 }
],
"ui_hints": { "sortable": true } }
| Field | Required | Notes |
|---|---|---|
columns | yes | At least one |
columns[].key | yes | Matches a key on each row |
columns[].label | yes | Header text |
columns[].align | no | left | center | right |
rows | yes | At least one object |
title, ui_hints | no |
Render only the declared columns, in the declared order. A row may carry keys no column names — ignore them rather than rendering a ragged table.
ranked_list
An ordered list where position carries meaning.
{ "type": "ranked_list", "id": "movers", "title": "Biggest movers",
"rows": [
{ "label": "Produce", "value": 3120.4, "rank": 1, "trend": "up", "note": "up 31% on last month" },
{ "label": "Dairy", "value": 1980, "rank": 2, "trend": "flat" }
] }
| Field | Required | Notes |
|---|---|---|
rows | yes | At least one object |
title, ui_hints | no |
stacked_bar
A single bar split into parts. A composition, not a time series.
{ "type": "stacked_bar", "id": "spend_split", "title": "Where it went",
"value_label": { "prefix": "$", "text": "8,420", "unit": "USD" },
"segments": [
{ "key": "produce", "label": "Produce", "value": 3120.4, "token": "series_1" },
{ "key": "meat", "label": "Meat", "value": 2870, "token": "series_2" },
{ "key": "dry", "label": "Dry goods", "value": 2429.6, "token": "series_3" }
],
"ui_hints": { "show_legend": true } }
| Field | Required | Notes |
|---|---|---|
segments | yes | At least two. One segment is not a composition |
segments[].key | yes | Stable identifier |
segments[].label | yes | Display text |
segments[].value | yes | Number, zero or greater |
segments[].token | yes | One of the theme tokens below |
value_label | no | text, prefix, suffix, unit |
title, ui_hints | no |
cards
Items a visitor chooses between: products, offers, listings, services. Drawn as a carousel, with
filter chips when there are groups.
{ "type": "cards", "id": "options", "title": "Children's fever relief",
"groups": [ { "key": "paracetamol", "label": "Paracetamol" }, { "key": "ibuprofen", "label": "Ibuprofen" } ],
"cards": [
{ "id": "sku-118", "title": "Paracetamol syrup 120mg/5ml 125ml", "eyebrow": "Paracetamol",
"group": "paracetamol", "image": "https://cdn.example.com/sku-118.png",
"price": { "amount": 7.92, "was": 10.15, "currency": "GEL" },
"availability": { "state": "in_stock", "label": "In stock" },
"ask": { "label": "Details", "prompt": "Tell me more about sku-118" } }
] }
| Field | Required | Notes |
|---|---|---|
cards | yes | At least one; Tether keeps the first 12 |
cards[].id, cards[].title | yes | |
cards[].eyebrow, subtitle | no | Brand or category above the title; one line below it |
cards[].image | no | https only, and only an address a tool result of this turn contained |
cards[].price | no | { amount, was?, currency }, numbers from a tool result. A discount is computed from was |
cards[].availability | no | { state, label? }, state one of in_stock, low_stock, out_of_stock, unknown |
cards[].facts | no | Short strings; Tether keeps the first 6 |
cards[].group | no | The key of one of the block's groups |
cards[].ask | no | { label, prompt }: a button that sends prompt as the next question |
cards[].phone, cards[].location | no | A call button; { lat, lng } for a directions button |
groups | no | { key, label } filter chips, counted and filtered on the page |
title | no |
places
Locations a visitor goes to or contacts: branches, shops, offices, service points. A list with hours, distance and buttons, and a map when the page provides tiles.
{ "type": "places", "id": "branches", "title": "Tbilisi: all closed now", "total": 40,
"timezone": "Asia/Tbilisi",
"places": [
{ "id": "n109", "name": "N109 (Tavdadebuli St)", "address": "Tavdadebuli St 40a",
"location": { "lat": 41.7841, "lng": 44.7712 }, "phone": "577 23 16 09",
"distance_km": 2.4, "duration_min": 9,
"hours": [ { "days": "mon-sun", "open": "08:00", "close": "22:00" } ] }
] }
| Field | Required | Notes |
|---|---|---|
places | yes | At least one, nearest or most relevant first; Tether keeps the first 30 |
places[].id, places[].name | yes | |
places[].address, note | no | |
places[].location | no | { lat, lng } from a tool result: a directions button and a map pin |
places[].phone | no | From a tool result: a call button |
places[].distance_km, duration_min | no | From a tool result only, never from a guess about where the visitor is |
places[].hours | no | [{ days, open, close }], days a day or range of mon…sun, times HH:MM. A close earlier than the open runs past midnight |
places[].ask | no | As in cards |
timezone | no | The IANA zone the hours are in. The renderer, not the model, says whether a place is open now |
total | no | How many exist when fewer are shown |
title, subtitle | no |
Provenance
An image, a price, a phone number, a coordinate, a distance or a travel time in a cards or
places block must appear in a tool result of the same turn. Tether removes any that does not
before the answer leaves, and the answer still renders without it. The model chooses and
arranges; it does not supply the facts a visitor acts on. There are no link fields: a button
asks a question, dials digits, or opens directions built from coordinates.
quick_replies
Suggested follow-up questions.
{ "type": "quick_replies", "id": "followups",
"replies": [
{ "id": "r1", "label": "Show the oldest" },
{ "id": "r2", "label": "Group by server" }
] }
| Field | Required | Notes |
|---|---|---|
replies | yes | At least one |
replies[].id | yes | Stable within the block |
replies[].label | yes | The text to send back as the next prompt |
footnote
A caveat about the answer above it — a truncated result, a stale figure, an assumption.
{ "type": "footnote", "id": "caveat", "text": "Excludes tickets closed before 06:00." }
| Field | Required | Notes |
|---|---|---|
text | yes | Plain text. No markdown, no other fields |
Theme tokens
Colour is chosen by the model from a fixed set of semantic tokens, never as a hex value. Your renderer maps each token to your palette, so an answer looks like your product and adapts to dark mode without the model knowing either exists.
| Token | Intended use |
|---|---|
series_1 … series_6 | Categorical series in a chart |
section_positive | A favourable grouping |
section_warning | A grouping needing attention |
section_action | A grouping requiring action |
severity_good | Healthy |
severity_neutral | Informational |
severity_warning | Degraded |
severity_critical | Urgent |
Any other value fails validation. There is no eighth series token — a chart needing more than six categories should aggregate a tail.
ui_hints
Advisory. Ignoring any of them is valid.
| Hint | Type |
|---|---|
compact | boolean |
sortable | boolean |
show_legend | boolean |
x_label | string |
y_label | string |
meta
{
"time_range": { "start": "2026-07-21T00:00:00Z", "end": "2026-08-20T00:00:00Z" },
"sources": [{ "id": "chunk_41", "label": "Refund policy", "type": "knowledge" }]
}
time_range is server-owned: Tether writes it from the request's period and overwrites
whatever the model produced. It is not validated against the model's output for exactly that
reason — an otherwise perfect answer should not be thrown away over one field the model cannot
know better than the server does.
sources appears when retrieval contributed and IncludeSourcesInMeta is on. Each entry needs
id, label and type.
Numbers are numbers
Any field named value, amount, amount_estimate, confidence, rank, currentQuantity,
alertPoint, deficit, reorderQuantity or shortagePercent must be a JSON number.
"84.50" fails validation wherever it appears in the document.
Formatting — currency symbols, thousands separators, locale — is your renderer's job, which is half the point of the contract.
Row fields
Rows in table and ranked_list are constrained by the schema the model generates against.
Alongside the general fields — id, label, value, rank, amount, note, secondary,
trend, category, unit, status, severity, confidence, token — the schema carries a
handful of narrower names such as itemName, storageName, alertPoint, deficit,
reorderQuantity, shortagePercent and recommendedAction.