The template language and patterns
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
{ "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
{ "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> })
{ "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.