# Templates: the view language

A template is a small tree of native views bound to a JSON object: a tool's `structuredContent` (or `data` of an
`{ok, data}` result), the overview object, or `{ args, preview }` for a confirmation card. Display only: a template
can never send data, run code or load anything but allowlisted images. Organization file: `templates/<id>.json`
`{ "id", "schema": "streamline.template/1", "root": node }`. Personal file: `templates: { "<id>": { "root": node } }`.

## Contents
- Nodes
- Paths
- Interpolation and formatters
- when
- Button actions
- Limits
- Spoken summary
- Patterns (list, money, status, confirm card, overview)
- Checking a template against a real result

## Nodes

Every node has `type`; any node may have `when` and `padding` (0–24 pt).

| `type` | Properties |
|---|---|
| `stack` | `children[]`, `spacing` (6), `align` `leading`\|`center`\|`trailing` — vertical |
| `row` | `children[]`, `spacing` (8), `align` `top`\|`center`\|`baseline`, `distribute` `start`\|`spaceBetween` — horizontal |
| `text` | `text` (interpolated), `style` `title`\|`headline`\|`body`\|`callout`\|`caption`\|`footnote`, `weight` `regular`\|`medium`\|`semibold`\|`bold`, `color` `primary`\|`secondary`\|`accent`\|`kind`\|`positive`\|`warning`\|`negative`, `lines`, `mono` |
| `badge` | `text`, `tone` `neutral`\|`positive`\|`warning`\|`negative`\|`accent`\|`kind`, or `toneMap` `{ "path", "map": {value: tone}, "default" }` |
| `money` | `value` (a path), `currency` (path or ISO code; default the account's), `style`, `strike` |
| `image` | `url` (interpolated; host in `imageHosts` or an `assets/` path), `aspect` `1:1`\|`4:3`\|`16:9`, `width`, `corner` |
| `list` | `items` (a path ending `[*]`), `item` (a node; `@` is the element), `max` (50), `more` (text after the cap; `{{ count }}`), `dividers` |
| `keyValue` | `rows[]` of `{ "label", "value" (interpolated), "when"? }` |
| `divider` | — |
| `spacer` | `height` |
| `button` | `label` (interpolated), `style` `primary`\|`secondary`\|`destructive`, `action` (below) |

## Paths

`$` the root object; `@` the current list element; `.key`, `["key with spaces"]`, `[0]`; `[*]` only at the end of a
list's `items`. No filters, slices or recursion. An unresolved path is `null`, never an error. Outside the bound
object: `user.name`, `user.firstName`, `user.store`, `tenant.shortName`, `chat.<key>`, `context.<key>` (forms), `now`.

## Interpolation and formatters

`"{{ path | formatter:arg | … }}"`; text outside holes is literal; arguments are numbers or 'single-quoted'.

| Formatter | Example → result |
|---|---|
| `money[:'CUR']` | `{{ $.total \| money }}` → `$4,250.00` |
| `number[:decimals]` | `{{ $.carat \| number:2 }}` → `1.52` |
| `percent[:decimals]` | `{{ $.margin \| percent:0 }}` → `38%` |
| `date[:'short'\|'medium'\|'long'\|'relative']` | `{{ $.created \| date:'relative' }}` → `5 min ago` |
| `time`, `datetime` | `{{ $.at \| time }}` → `2:41 PM`; a date-time with no zone (`2026-11-20T16:10:00`) keeps its wall-clock time |
| `phone` | `(619) 555-0142` |
| `mask[:keep]` | `{{ $.account \| mask:4 }}` → `•••• 4821` |
| `upper`, `lower`, `title` | |
| `default:'text'` | when null or empty |
| `prefix:'text'`, `suffix:'text'` | added only when the value is present |
| `count` | elements in an array |
| `plural:'one':'many'` | `{{ $.items \| count \| plural:'item':'items' }}` → `3 items` |

Any other formatter name is a check error.

## `when`

`"when": "$.offer"` (truthy: not null, false, 0, "" or []), or `{ "path": "$.status", "eq": "pending" }` with `eq`,
`ne`, `in` (array), `gt`, `lt`; `{ "not": when }`, `{ "all": [when…] }`, `{ "any": [when…] }`. That is all.

## Button actions

| `action` | Effect |
|---|---|
| `{ "tool": "cart_add_item", "args": { "cartId": "{{ chat.cartId }}", "sku": "{{ @.sku }}" }, "fallback": "Add {{ @.sku }}" }` | Calls a `direct` tool with no model; otherwise sends `fallback` as the person's message. Gates and roles still apply. |
| `{ "send": "Add {{ @.sku }} to the ticket" }` | Sends text as the person's own message. |
| `{ "insert": "Counter at {{amount}}" }` | Puts text in the composer (slots selected). |
| `{ "openView": "financing.account", "session": "{{ $.viewSession }}" }` | Opens a sealed view the streamline lists. |
| `{ "openURL": "https://…" }` | Opens in the system browser after a tap (https only); host must be in `imageHosts` or the support URL's host (personal: its MCP hosts too). |
| `{ "download": "{{ $.fileUrl }}" }` | Fetches the file into the chat after a tap: same hosts and https rule as `openURL`, no cookies or credentials sent, at most 25 MB. The button becomes *Downloaded · <name>*; a second tap opens the saved copy (safe for one-time links). The model never sees the URL or the file, so keep the link in `structuredContent`, out of the result's text. |
| `{ "copy": "{{ $.saleNumber }}" }` | Copies non-sensitive text. |

A literal `openURL`/`download` URL (no `{{ }}`) to a host that isn't allowed fails the check (`link.host`); one filled from a result is checked at the tap.

## Limits

Depth ≤ 12; ≤ 200 nodes after list expansion; ≤ 50 items per list. A template that breaks a limit or fails to parse
renders as a plain card with the tool's text; it never blanks a stream.

## Spoken summary

`"speech": { "summary": "Customer card: {{ $.name }}." }` beside `root` (≤ 200 characters, interpolation and
formatters only) is what *Speak replies* says for the card; without it the card is "A card is on screen: <activity>".
Never on a template a sealed view uses (`speech.sealed`): sealed data is never spoken.

## Patterns

**A search result list with a fast-path button**

```json
{ "type": "list", "items": "$.results[*]", "max": 5, "more": "and {{ count }} more", "dividers": true, "item":
  { "type": "row", "spacing": 10, "children": [
    { "type": "image", "url": "{{ @.photo }}", "aspect": "1:1", "width": 56, "corner": 8 },
    { "type": "stack", "spacing": 2, "children": [
      { "type": "text", "text": "{{ @.title }}", "style": "body", "weight": "medium", "lines": 2 },
      { "type": "text", "text": "{{ @.sku }} · {{ @.onHand }} at this store", "style": "caption", "color": "secondary" },
      { "type": "money", "value": "@.tagPrice", "style": "callout" } ] },
    { "type": "button", "label": "Add", "style": "secondary",
      "action": { "tool": "cart_add_item", "args": { "cartId": "{{ chat.cartId }}", "sku": "{{ @.sku }}" }, "fallback": "Add {{ @.sku }}" } } ] } }
```

**Money with a struck tag price, and a status badge**

```json
{ "type": "row", "distribute": "spaceBetween", "children": [
  { "type": "stack", "align": "trailing", "spacing": 1, "children": [
    { "type": "money", "value": "$.price" },
    { "type": "money", "value": "$.tagPrice", "style": "caption", "strike": true, "when": { "path": "$.discounted", "eq": true } } ] },
  { "type": "badge", "text": "{{ $.status | title }}",
    "toneMap": { "path": "$.status", "map": { "approved": "positive", "pending": "warning", "declined": "negative" }, "default": "neutral" } } ] }
```

**A confirmation card** (root is `{ "args": <the call's arguments>, "preview": <the preview tool's result> }`)

```json
{ "id": "confirm.sale", "schema": "streamline.template/1", "root": { "type": "stack", "spacing": 8, "padding": 14, "children": [
  { "type": "text", "text": "Submit this sale?", "style": "headline" },
  { "type": "keyValue", "rows": [
    { "label": "Customer", "value": "{{ $.preview.customer.name }}" },
    { "label": "Items", "value": "{{ $.preview.items | count }}" },
    { "label": "Total", "value": "{{ $.preview.total | money }}" } ] },
  { "type": "text", "when": { "path": "$.preview.balance", "gt": 0 }, "text": "Balance due {{ $.preview.balance | money }}", "style": "callout", "color": "warning" } ] } }
```

**An overview and its compact strip**: one `stack` with the job's headline facts, a `list` of lines, a `keyValue` of
totals and a status badge; the compact template is a single `row` with `distribute: spaceBetween`.

## Checking a template against a real result

Bind against a real `structuredContent` (from the server, with fake data), not a guess at its shape. Check every path
exists in the result, every formatter is in the table, every `when` path is right, and buttons' tools are `direct`.
`configcheck --render <template> <fixture.json>` (when available) prints the resolved tree as text.
