# QuickWatch: agent guide

QuickWatch turns a JSON description into screens, a tile and a complication on a Wear OS watch (Galaxy Watch). This
guide is for AI assistants. It explains how to write a project (`watch.json` plus sample `data.json`) and how to hand
it to the user. Read it once and you can build a watch screen without our connector.

The server answers at `https://quickwatch.app`. Every URL below uses that base.

## Preview it in the chat (start here)

The recommended path without the connector. Put this HTML in an inline HTML widget or artifact (in Claude: the
`show_widget` tool), fill in the two JSON blocks with your `watch.json` and `data.json`, and change nothing else:

```html
<script type="application/json" data-qw-watch>
{ "schemaVersion": 2, "experiences": { … } }
</script>
<script type="application/json" data-qw-data>
{ … }
</script>
<script>
  var s = document.createElement("script");
  s.src = "https://cdn.jsdelivr.net/npm/@quickwatch/preview@0.3.0/dist/quickwatch-preview.min.js";
  s.onerror = function () { document.body.append("Could not load the QuickWatch preview."); };
  document.head.appendChild(s);
</script>
```

The script draws the real watch (QuickWatch's renderer: components, fonts, round screen; taps work, actions are
simulated), a button per screen and per Experience, any problems in red, and an **Open in QuickWatch** button with the
link below. It finds the JSON blocks whenever it finishes loading, so load order does not matter. Don't draw the watch
by hand. Check the project in a shell with `npx @quickwatch/preview check watch.json data.json` (QuickWatch's own
validator; it prints the problems and the link).

Other options, for a layout of your own: `QuickWatch.widget(element, watch, data, options)` draws the same widget
into an element (options: `title`, `origin`, and those of `render`); `QuickWatch.render(element, watch, data,
options)` draws just the watch (options: `surface` (`"overview"`, `"tile"`, `"complication"`, `"page:<id>"`),
`experience`, `device`, `theme`, `data`, `size`, `frame`; it returns `{ surfaces, show(key), update(watch, data),
errors, destroy() }`); `await QuickWatch.link(watch, data)` gives the link. `watch` and `data` are objects or JSON
text. The problems shown do not cover everything the builder checks, so the link's page may still report some.

## Pick a route

1. **MCP connector (best).** If your host can add a remote MCP server, connect to `https://quickwatch.app/mcp`
   (streamable HTTP, OAuth 2.1; the user can continue as a guest in one click). The tools create and edit the project,
   validate it, and show a live preview in the chat. Server metadata:
   `https://quickwatch.app/.well-known/mcp/server.json`. The main tools: `list_experiences`, `list_templates`,
   `create_experience`, `get_experience`, `edit_experience` (patch ops with the `baseRevision` you read) and
   `render_preview`; publishing and pairing stay with the user (the tools return links).
2. **Open in QuickWatch link (no connector).** Write `watch.json` and `data.json`, pack them into a link (see
   [The link](#the-open-in-quickwatch-link)) and give the link to the user. It opens a preview, and
   "Keep it" saves the project to their account (signed out, they continue as a guest in one more click).
   If you can show HTML in the chat (a widget or artifact), show the preview instead: it has the link as an "Open in
   QuickWatch" button (see [Preview it in the chat](#preview-it-in-the-chat-start-here)).
3. **Paste.** If you cannot run code to build the link, give the user the two JSON blocks and ask them to paste them at
   `https://quickwatch.app/new`.

Reference files: [JSON Schema](https://quickwatch.app/schema/quickwatch.schema.json),
[capabilities](https://quickwatch.app/schema/capabilities.json) (components, filters, icons, surface limits), and
examples at `https://quickwatch.app/schema/examples/{name}.json` with sample data at `{name}.data.json`.

## The model in one minute

- A **project** is one `watch.json`. It holds shared `sources` (data), `actions` (what buttons do), optional `params`
  and `timeZone`, and one or more **Experiences**.
- An **Experience** is a small app on the watch: a button on the App home (title, subtitle, icon), one or more
  **pages** (full screens), one **tile** (a fixed template) and an optional **complication** (watch face slot).
- Text props are **bindings**: literal text with `{{ expression }}` parts that read data by source name, for example
  `{{ weather.temp | round }}°`.
- `data.json` holds **sample data** by source name. The preview uses it, and the watch shows it until live data
  arrives. Always include sample data for every source you bind.

## watch.json essentials

```json
{
  "schemaVersion": 2,
  "timeZone": "Europe/Stockholm",
  "params": { "city": "Stockholm" },
  "sources": { "status": { "type": "feed", "push": "values" } },
  "actions": { "open-details": { "type": "openScreen", "screen": "details" } },
  "experiences": {
    "my-thing": {
      "title": "My thing",
      "subtitle": "{{ status.summary }}",
      "icon": "star",
      "theme": { "overrides": { "colors": { "accent": "#8AB4F8" } } },
      "description": "Plain text, at most 140 characters",
      "main": "home",
      "pages": { "home": { "title": "My thing", "root": { "type": "text", "value": "Hi" } } },
      "tile": { "template": "text", "title": "My thing", "text": "{{ status.summary }}" },
      "complication": { "kind": "shortText", "text": "{{ status.count }}" }
    }
  }
}
```

Rules that matter:

- `schemaVersion` is `2`. `experiences` needs at least one entry. Each Experience needs `title`, `pages` (at least one)
  and `tile`. `complication` is optional. Unknown properties are errors.
- **Ids** (Experiences, pages, sources, actions): start with a lowercase letter, then lowercase letters, digits or `-`,
  at most 40 characters (`next-train`, `ci`).
- `main` is the page that the App home button, the tile and the complication open. The default is the first page.
- **Themes.** The user's **App theme** (their account's "App look") styles the watch App and every Experience without a
  theme of its own; leave `theme` out unless the Experience needs its own look. An Experience's `theme` is
  `{ "base": "<theme id>", "overrides": { … } }`, both optional: `base` is a built-in theme (`dark`, `light`,
  `high-contrast`, `ember`, `mono`, `slate`; `list_themes` or `get_capabilities` `themes`) or `saved:<id>` (one of the
  user's saved themes); without `base` the overrides go on the App theme. `overrides` is partial: `colors` (any of
  `background`, `surface`, `onSurface`, `onSurfaceVariant`, `outline`, `accent`, `onAccent`, `timeText`, `scrollThumb`,
  `scrollTrack`, `warningText`, `statusOk`, `statusWarn`, `statusError`, `statusUnknown` as `#RRGGBB`), `tone` (`dark`
  or `light`), `shape.corners` (`sharp`, `rounded`, `pill`), `type.weight` (-200..200) and `type.width` (-30..30).
  Just a brand colour: `"theme": { "overrides": { "colors": { "accent": "#FF8800" } } }`. Keep text at 4.5:1 contrast
  (problems warn below). Tiles follow the colours only. With the tools: `set_experience_theme`, and `set_app_theme`
  for the App look (it reaches the watch without a publish). A `saved:<id>` from another account falls back to the App
  theme with a warning.
- `timeZone` is an IANA name. The default is `Europe/Stockholm`. Dates and times are formatted in it.
- `icon` is a name from the built-in set (below) or a binding that resolves to one.

### Components (pages)

A page has a `title` and one `root` component. Use a `stack` to show several components.

| `type` | Props (required in bold) | Notes |
| --- | --- | --- |
| `text` | **value**, style (`title` `body` `caption` `display`), maxLines (1-3) | |
| `stat` | **value**, unit, label, trend (`up` `down` `flat`), icon | A big number. |
| `list` | **items**, **item**, maxRows (1-10), header, emptyText | `items`: `{ "bind": "src.path", "take": 5 }` or `"src.path"`. |
| `status` | **state** (`ok` `warn` `error` `unknown`), label, detail | Use the `status` filter to compute the state. |
| `progress` | **value**, min (0), max (100), style (`ring` `bar`), label | |
| `countdown` | **target** (a timestamp), format (`relative` `clock`), label | Ticks on the watch without a data refresh. |
| `button` | **action**, label and/or icon, style (`primary` `secondary`), confirm (bool) | |
| `toggle` | **state** (a boolean binding), label, onAction, offAction, icon | |
| `icon` | **icon** or url (https), size (`small` `medium` `large`) | |
| `stack` | **children**, direction (`vertical` `horizontal`), header | Pages scroll vertically. Keep horizontal stacks to 2-3 small items. |

A list `item` has `title` (required), `subtitle`, `trailing` (text, or a `countdown` or `text` component), `badge`,
`icon` and `action`. Inside `item`, bindings read the current item's fields (`{{ title }}`). `$root.src.x` reaches
the source, and `$index` is the item's position.

### Tiles

A tile is one template with fixed slots. Every template also takes `title`, `icon` and `opens` (a page id).

| `template` | Slots (required in bold) |
| --- | --- |
| `text` | **text** (text or a countdown) |
| `single-stat` | **value**, unit, label, trend |
| `list3` | **items** (take at most 3), **item**, emptyText |
| `compact-list` | **items** (take at most 8, one line each, no Open button), **item**, emptyText |
| `status` | **state**, label, detail |
| `progress-ring` | **value**, min, max, label |
| `buttons2` | **buttons**: 1-2 `button` components |
| `toggle` | **toggle**: one `toggle` component |

### Complications

| `kind` | Slots (required in bold) | Limit |
| --- | --- | --- |
| `shortText` | **text** (text or a countdown), title, icon | about 7 characters |
| `longText` | **text**, title, icon | about 24 characters |
| `rangedValue` | **value**, min, max, text, title, icon | |
| `image` | icon or url | |

`opens` (a page id) is allowed on every kind.

### Limits

- Pages: a list shows at most 10 rows, text at most 3 lines.
- Tiles: at most 3 list rows (`list3`) or 8 (`compact-list`), at most 2 buttons and 1 toggle. Tiles refresh at most
  once a minute and complications every 5 minutes, so use `countdown` for anything that ticks.
- One watch shows at most 10 tiles and 1 complication from QuickWatch.
- A link holds at most 32 KB of JSON. Keep sample data short: 3-8 list items are enough.

### Icons

`add alarm arrow-down arrow-up battery bell bike bolt build bus calendar car check chevron-right clock close cloud code
door error fan flag git heart home info light lock mail map-pin music pause phone play plug power rain refresh remove
robot rocket settings ship snow star stop sun temp thermometer timer train tram unlock user walk warning wifi wind`

## Data: sources and sample data

| Source | When to use | Shape |
| --- | --- | --- |
| Feed | The user's script, CI or agent POSTs values. Best for anything custom. | `{ "type": "feed", "push": "values" }` |
| HTTP, direct | A public JSON API. The watch fetches it. | `{ "type": "http", "mode": "direct", "url": "https://…", "refresh": { "poll": "15m", "onOpen": true }, "map": { … } }` |
| HTTP, proxied | Our server fetches (can hold secrets, `{{ $secret.name }}` in `url` or `headers`). Needs an email account. | `{ "type": "http", "url": "https://…", "map": { … } }` |

- `map` turns the API response into the shape your bindings read. Each string is a template evaluated against the
  response. `{ "$each": "{{ list }}", "$map": { … } }` maps an array. Omit `map` to keep the response as it is.
- `refresh.poll` is at least `60s`. `onOpen: true` refreshes when the user opens the page.
- `params` are non-secret settings (`{{ $params.city }}`). Never put tokens in `params`. The user adds secrets in the
  builder, and you never ask for them in chat. Once a secret is stored you cannot send it to a new address (change the
  scheme, host, port or a `$params` value in the url of a request that uses it, or use it in a request elsewhere): that
  edit is refused, and the user makes it in the builder.
- **Sample data** in `data.json` has the shape after `map`. Timestamps may be relative to now: `"+5m"`, `"+1h30m"`,
  `"now-2m"`, `"-90s"`. Use them for anything a countdown or `relative` reads, so the preview always looks current.

## Bindings

`{{ path | filter:arg | filter }}`. Paths: `src.field`, `src.list[0].name`, `src.map['key with space']`. Operators:
`+ - * / %`, `== != < <= > >=`, `and or not`, `in`, `a ?? fallback`, `cond ? a : b`, `~` (join as text).

| Filter | Example |
| --- | --- |
| `number[:compact\|N2\|F1\|P0]` | `{{ s.tokens \| number:compact }}` → `12.3k` |
| `round[:n]` | `{{ w.temp \| round:1 }}` |
| `date[:fmt]`, `time[:fmt]` | `{{ e.start \| date:'d MMM' }}`, `{{ t.at \| time }}` → `14:05` |
| `relative[:compact]` | `{{ b.finishedAt \| relative }}` → `3 min ago` |
| `default:text` | `{{ w.temp \| default:'--' }}` |
| `prefix:text`, `suffix:text` | `{{ t.platform \| prefix:'Track ' }}` |
| `status[:rules]` | `{{ ci.result \| status:ok=success,warn=running,error }}` |
| `lookup:K=v,…,*=fallback` | `{{ mode \| lookup:METRO=train,BUS=bus,*=walk }}` |
| `take:n`, `first`, `last`, `count`, `sum`, `join` | `{{ s.items \| count }}` |
| `where: cond`, `map: expr`, `sortBy: key`, `find: cond` | `{{ s.items \| where: done == false \| count }}` |
| `upper`, `lower`, `toNumber`, `toText`, `toTime` | `{{ r.state \| toNumber }}` |

A template that is exactly one `{{ }}` keeps the value's type (number, boolean, list). Anything else becomes text. A
missing value shows as empty text. Filters never fail.

## Actions

```json
"actions": {
  "open-details": { "type": "openScreen", "screen": "details" },
  "refresh": { "type": "refreshSource", "source": "weather", "toast": { "success": "Updated" } },
  "lights-off": { "type": "http", "method": "POST", "url": "https://example.com/hooks/lights-off",
                 "confirm": "Turn the lights off?", "toast": { "success": "Done", "failure": "No answer" } }
}
```

Buttons (`action`), list rows (`item.action`) and toggles (`onAction`, `offAction`) name an action. In a list row the
action's templates can read the row's fields. `openScreen` needs a page id. HTTP actions run on our server (or on the
watch with `"mode": "direct"`) and need an email account to run.

## Worked example 1: today's meetings (feed + list + countdown)

A feed that the user's calendar script fills. The main page lists the meetings, and a row opens the next meeting. The
tile shows three rows, and the complication counts down to the next meeting.

```json watch.json
{
  "schemaVersion": 2,
  "timeZone": "Europe/Stockholm",
  "sources": {
    "agenda": { "type": "feed", "push": "values" }
  },
  "actions": {
    "open-next": { "type": "openScreen", "screen": "next" },
    "open-agenda": { "type": "openScreen", "screen": "agenda" }
  },
  "experiences": {
    "meetings": {
      "theme": { "overrides": { "colors": { "accent": "#7CB342" } } },
      "title": "Meetings",
      "subtitle": "{{ agenda.meetings[0].title }} {{ agenda.meetings[0].startsAt | relative }}",
      "icon": "calendar",
      "description": "Today's meetings and a countdown to the next one",
      "main": "agenda",
      "pages": {
        "agenda": {
          "title": "Today",
          "root": {
            "type": "list",
            "header": "Today",
            "maxRows": 6,
            "emptyText": "No more meetings",
            "items": { "bind": "agenda.meetings", "take": 6 },
            "item": {
              "title": "{{ title }}",
              "subtitle": "{{ startsAt | time }} · {{ room }}",
              "trailing": { "type": "countdown", "target": "{{ startsAt }}" },
              "icon": "calendar",
              "action": "open-next"
            }
          }
        },
        "next": {
          "title": "Next",
          "root": {
            "type": "stack",
            "children": [
              { "type": "text", "value": "{{ agenda.meetings[0].title }}", "style": "title", "maxLines": 2 },
              { "type": "countdown", "target": "{{ agenda.meetings[0].startsAt }}", "label": "Starts in" },
              { "type": "text", "value": "{{ agenda.meetings[0].room | prefix:'Room ' }}", "style": "caption" },
              { "type": "button", "label": "All meetings", "icon": "calendar", "action": "open-agenda", "style": "secondary" }
            ]
          }
        }
      },
      "tile": {
        "template": "list3",
        "title": "Meetings",
        "icon": "calendar",
        "items": { "bind": "agenda.meetings", "take": 3 },
        "item": { "title": "{{ title }}", "trailing": { "type": "countdown", "target": "{{ startsAt }}" } },
        "emptyText": "All done"
      },
      "complication": {
        "kind": "shortText",
        "opens": "next",
        "icon": "calendar",
        "text": { "type": "countdown", "target": "{{ agenda.meetings[0].startsAt }}" }
      }
    }
  }
}
```

```json data.json
{
  "agenda": {
    "meetings": [
      { "title": "Design review", "room": "Atlas", "startsAt": "+25m" },
      { "title": "1:1 with Sam", "room": "Kiosk", "startsAt": "+1h40m" },
      { "title": "Team retro", "room": "Atlas", "startsAt": "+3h" }
    ]
  }
}
```

The link for this project (built with the snippet below):

```text link
https://quickwatch.app/new#c=nVRNb9swDP0rgnboYVnXJF0H-NZ9nLb10AQ7rOhBkdlYiC0ZMp0PpP5du--XjZTs1GmCpuhNoshH8vGJW7mUyXAgtbMPZi6Trax0BoX6Db4yzspkNJBIBuAnpTVYlIl89_nrl_HlSA5k4VJ6kqnyC9mQqyngj7Ns-l57V8LHCTq9yFxekHPlaq-hClBzsKniE25Kdn8ASMmlrKuMbkuV1-TYEKTSSIWEIMKzHyyssRfHton2AJYTxEMigxMFh4iDXMdjWjfOCesSvAHbFlsAoLHzcEaDOWP86myEUM8663YrIsx5F3N3cX8eXkXTiOPPFSqP1TWKR-EhV2iW7EzARvMEpFY5x3iypEDlmhLDaOTUpWpzVokOTCibCiW0qy2mbmUFOkHDE8yG4KnQvJTp9Up80-n5QNpeAjq5eOf6hOemQrJmoFLwPbdCrW_dirCuiL-ixM00DEreOFE4D7siuS-EIiSdGZvuqjnveaBaUK6rJvr2qyIKOzoPqO8RyUJkxv_9ZdKphSIGoFcmpyS9hnZ0hbx-DvgMjQKbY8OIymz1FHXZsHp2Cm0ru-H7AY8Erxdk1pnJU88KvNu9YYwIv-CkqpgG3IRMMWOYxU9jebCjZnCy0ZckGeBzNYOcfCfBKkhBPdRX1RoG8ChKDw9mnZzd8vVsv3atoqx70LMa0dleAdd5vqejEyPZqbzLUQFFkOdGNvdhVGjysNlIZCX9vE7fY2bo8KMf5nutkscnlfxWYTb7v40ZSvmrN7zTqSmjVaRkKxexxipzHqdxakxT1e3LYw3i_rZ9o4SaJrCdKlT7u-Zps949EfMNKjO3tAuXBlbx4xTcGuYqrNsWmEzvR5-KKJg2dJgMxcpgJiaqeIr8YVy1eBY5zC4v9mOnoApKit69nHOcRfX8Bw
```

Once the project is saved, the user gets a feed URL and key in the builder, and their script POSTs
`{ "meetings": [ … ] }` to it.

## Worked example 2: weather (public API, fetched by the watch)

A direct HTTP source with a mapping, a horizontal stack, and a refresh button. No account and no secret are needed.

```json watch.json
{
  "schemaVersion": 2,
  "timeZone": "Europe/Stockholm",
  "params": { "lat": "59.33", "lon": "18.07", "place": "Stockholm" },
  "sources": {
    "weather": {
      "type": "http",
      "mode": "direct",
      "url": "https://api.open-meteo.com/v1/forecast?latitude={{ $params.lat }}&longitude={{ $params.lon }}&current=temperature_2m,wind_speed_10m,relative_humidity_2m,weather_code",
      "refresh": { "poll": "15m", "onOpen": true },
      "map": {
        "place": "{{ $params.place }}",
        "temp": "{{ current.temperature_2m }}",
        "wind": "{{ current.wind_speed_10m }}",
        "humidity": "{{ current.relative_humidity_2m }}",
        "condition": "{{ current.weather_code | lookup:0=Clear,1=Mostly clear,2=Partly cloudy,3=Overcast,45=Fog,61=Rain,63=Rain,71=Snow,95=Thunder,*=Mixed }}",
        "icon": "{{ current.weather_code | lookup:0=sun,1=sun,61=rain,63=rain,71=snow,*=cloud }}",
        "updatedAt": "{{ $now }}"
      }
    }
  },
  "actions": {
    "refresh": { "type": "refreshSource", "source": "weather", "toast": { "success": "Updated", "failure": "No connection" } }
  },
  "experiences": {
    "weather": {
      "theme": { "base": "ember" },
      "title": "{{ weather.place }}",
      "subtitle": "{{ weather.temp | round }}° · {{ weather.condition }}",
      "icon": "sun",
      "description": "Current weather from Open-Meteo, fetched by the watch",
      "pages": {
        "now": {
          "title": "{{ weather.place }}",
          "root": {
            "type": "stack",
            "children": [
              {
                "type": "stack",
                "direction": "horizontal",
                "children": [
                  { "type": "icon", "icon": "{{ weather.icon }}", "size": "medium" },
                  { "type": "stat", "value": "{{ weather.temp | round:1 }}", "unit": "°C", "label": "{{ weather.condition }}" }
                ]
              },
              { "type": "stat", "value": "{{ weather.wind | round }}", "unit": "km/h", "label": "Wind", "icon": "wind" },
              { "type": "progress", "value": "{{ weather.humidity }}", "max": 100, "style": "bar", "label": "Humidity {{ weather.humidity }}%" },
              { "type": "text", "value": "Updated {{ weather.updatedAt | time }}", "style": "caption", "maxLines": 1 },
              { "type": "button", "label": "Refresh", "icon": "refresh", "action": "refresh", "style": "secondary" }
            ]
          }
        }
      },
      "tile": {
        "template": "single-stat",
        "title": "{{ weather.place }}",
        "icon": "sun",
        "value": "{{ weather.temp | round }}",
        "unit": "°C",
        "label": "{{ weather.condition }}"
      },
      "complication": { "kind": "shortText", "icon": "sun", "text": "{{ weather.temp | round }}°" }
    }
  }
}
```

```json data.json
{
  "weather": {
    "place": "Stockholm",
    "temp": 7.4,
    "wind": 14.2,
    "humidity": 81,
    "condition": "Overcast",
    "icon": "cloud",
    "updatedAt": "now-5m"
  }
}
```

## The Open in QuickWatch link

```text
https://quickwatch.app/new#c=<payload>
payload = base64url( deflate-raw( UTF-8 JSON { "v": 1, "config": <watch.json>, "data": <data.json> } ) )
```

- `deflate-raw` is RFC 1951 without a zlib or gzip header. In Python, use `zlib` with `wbits=-15`. In JavaScript, use
  `CompressionStream("deflate-raw")`.
- base64url uses `-` and `_` and has no `=` padding.
- The JSON may be at most 32 KB. `data` is optional. Leave out `$schema`.
- The payload is in the fragment (`#`), so it never reaches a server log. The page decodes it in the browser, shows the
  preview and any problems, and offers "Keep it".

JavaScript (browser, Node 18+, Deno):

```js
async function quickWatchLink(config, data = {}, base = "https://quickwatch.app") {
  const json = JSON.stringify({ v: 1, config, data });
  const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw"));
  const bytes = new Uint8Array(await new Response(stream).arrayBuffer());
  let binary = "";
  for (const b of bytes) binary += String.fromCharCode(b);
  const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
  return `${base}/new#c=${payload}`;
}
```

Python:

```python
import base64, json, zlib

def quickwatch_link(config, data=None, base="https://quickwatch.app"):
    raw = json.dumps({"v": 1, "config": config, "data": data or {}}, separators=(",", ":")).encode("utf-8")
    deflate = zlib.compressobj(9, zlib.DEFLATED, -15)  # -15: raw deflate, no header
    payload = base64.urlsafe_b64encode(deflate.compress(raw) + deflate.flush()).rstrip(b"=").decode("ascii")
    return f"{base}/new#c={payload}"
```

## Checklist before you hand it over

1. Every binding names a source in `sources`, and `data.json` has sample data for it.
2. Every `action`, `onAction`, `offAction` and `openScreen.screen` names something that exists.
3. Tile and complication props are only the slots listed for that template or kind.
4. Short text for complications (`shortText` about 7 characters). Lists use `take`.
5. Timestamps in sample data are relative (`"+10m"`) so countdowns look right.
6. Tell the user what they will see, and that "Keep it" saves it. Publishing to the watch and pairing need an email
   account, and the user does both in the builder.

## Errors

The builder, the preview page and the MCP tools report problems as `path`, `rule` and message, for example
`$.experiences.weather.tile.value` `unknown-property`. Common rules: `unknown-property`, `unknown-source`,
`unknown-action`, `unknown-page`, `unknown-icon`, `unknown-filter`, `binding-syntax`, `surface-limit`,
`slot-not-allowed`, `missing-data`, `refresh-too-fast`. Fix the named path and try again.
