Templates: the view language

The template language and patterns

Raw Markdown the file as the skill ships it All pages ↓

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).

typeProperties
stackchildren[], spacing (6), align leading|center|trailing — vertical
rowchildren[], spacing (8), align top|center|baseline, distribute start|spaceBetween — horizontal
texttext (interpolated), style title|headline|body|callout|caption|footnote, weight regular|medium|semibold|bold, color primary|secondary|accent|kind|positive|warning|negative, lines, mono
badgetext, tone neutral|positive|warning|negative|accent|kind, or toneMap { "path", "map": {value: tone}, "default" }
moneyvalue (a path), currency (path or ISO code; default the account's), style, strike
imageurl (interpolated; host in imageHosts or an assets/ path), aspect 1:1|4:3|16:9, width, corner
listitems (a path ending [*]), item (a node; @ is the element), max (50), more (text after the cap; {{ count }}), dividers
keyValuerows[] of { "label", "value" (interpolated), "when"? }
divider—
spacerheight
buttonlabel (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'.

FormatterExample → 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
countelements 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

actionEffect
{ "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.