# 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 ``` 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:"`), `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": "", "overrides": { … } }`, both optional: `base` is a built-in theme (`dark`, `light`, `high-contrast`, `ember`, `mono`, `slate`; `list_themes` or `get_capabilities` `themes`) or `saved:` (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:` 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 }}¤t=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 = base64url( deflate-raw( UTF-8 JSON { "v": 1, "config": , "data": } ) ) ``` - `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. ## Schema summary (from capabilities.json) ### Page components - `text`: value, style, maxLines; styles: title, body, caption, display - `stat`: value, unit, label, trend, icon; trends: up, down, flat - `list`: items, item, maxRows, header, emptyText; itemSlots: title, subtitle, trailing, badge, icon, action - `status`: state, label, detail; states: ok, warn, error, unknown - `progress`: value, min, max, style, label; styles: ring, bar - `countdown`: target, format, label; formats: relative, clock - `button`: label, icon, action, confirm, style; styles: primary, secondary - `toggle`: label, state, onAction, offAction, icon - `icon`: icon, url, size; sizes: small, medium, large - `stack`: direction, header, children; directions: vertical, horizontal ### Surface limits (Wear OS) - screen: listMaxRows 10, textMaxLines 3 - tile: listMaxRows 3, compactListMaxRows 8, maxButtons 2, maxToggles 1, instances 10, minRefreshSeconds 60 - complication: instances 1, dataSources 1, minRefreshSeconds 300 ### Tile templates - `text`: slots title, icon, text; required text - `single-stat`: slots title, icon, value, unit, label, trend; required value - `list3`: slots title, icon, items, item, emptyText; required items, item - `compact-list`: slots title, icon, items, item, emptyText; required items, item - `status`: slots title, icon, state, label, detail; required state - `progress-ring`: slots title, icon, value, min, max, label; required value - `buttons2`: slots title, icon, buttons; required buttons - `toggle`: slots title, icon, toggle; required toggle ### Complication kinds - `shortText`: slots text, title, icon; required text; at most 7 characters - `longText`: slots text, title, icon; required text; at most 24 characters - `rangedValue`: slots value, min, max, text, title, icon; required value - `image`: slots icon, url ### Action types - `http`: url, method, headers, body, confirm, toast, mode, refresh; required url - `refreshSource`: source, confirm, toast; required source - `openScreen`: screen; required screen - `connector`: connector, params, confirm, toast, refresh; required connector ### Sources - types: template, http, feed - props: type, mode, template, params, url, method, headers, body, map, push, refresh - modes: proxied, direct - push levels: values, signal, sealed, none - mapping directives: $each, $map, $if, $value, $let, $literal ### Filters - `number` (compact, N0-N10, F0-F10 or P0-P10 (optional)): Formats a number (invariant culture). Default: thousands separators and at most 2 decimals (1,234.5). compact gives 1.2k / 3.4M; N2 = 1,234.50; F2 = 1234.50; P0 = 50 %. Custom patterns are not supported. Example: `{{ stats.visitors | number:compact }}` - `round` (decimals 0-10 (optional)): Rounds a number (half away from zero). Default 0 decimals. Example: `{{ weather.temp | round:1 }}` - `date` (date pattern (tokens yyyy yy MMMM MMM MM M dddd ddd dd d HH H hh h mm m ss s tt) or iso/short/long/weekday/day/datetime (optional)): Formats a timestamp in the project time zone (default Europe/Stockholm). Default 'yyyy-MM-dd'. Example: `{{ event.start | date:'d MMM' }}` - `time` (time pattern or clock/seconds (optional)): Formats a timestamp's time of day. Default 'HH:mm'. Example: `{{ train.expectedAt | time }}` - `relative` ('compact' (optional)): Time from now: 'in 5 min', '3 min ago', 'now'. 'compact' gives '5 min' / '-3 min'. Example: `{{ build.finishedAt | relative }}` - `upper`: Upper-cases text. Example: `{{ stop.name | upper }}` - `lower`: Lower-cases text. Example: `{{ state | lower }}` - `default` (fallback text): Used when the value is missing, null, empty text or an empty list. Example: `{{ weather.temp | default:'--' }}` - `take` (count (required)): First n items of a list, or first n characters of text. Example: `{{ odenplan.departures | take:3 }}` - `status` (rules (optional): state=value[/value], state=n, bare state as catch-all): Maps a value to ok / warn / error / unknown. Without rules, common words (success, failed, running, green, red...) are recognised. Example: `{{ ci.conclusion | status:ok=success,warn=running,error }}` - `prefix` (text (required)): Adds text before a non-empty value. Example: `{{ train.platform | prefix:'Platform ' }}` - `suffix` (text (required)): Adds text after a non-empty value. Example: `{{ weather.temp | suffix:'°' }}` - `lookup` (rules KEY=value,...,*=fallback (required)): Maps a value by exact match; * is the fallback. Write a literal = or , as \= or \,. Example: `{{ line.mode | lookup:METRO=train,BUS=bus,*=train }}` - `where` (condition per item (required)): Items of a list for which the condition is true. Names inside are relative to the item. Example: `{{ departures | where: line.mode == 'METRO' }}` - `map` (expression per item (required)): Replaces each item of a list with the expression's value. Example: `{{ runs | map: name }}` - `sortBy` (key per item (required), then 'desc' (optional)): Stable sort by a key: numbers numerically, text ordinally, missing keys last. Example: `{{ departures | sortBy: expected }}` - `find` (condition per item (required)): The first item for which the condition is true, else nothing. Example: `{{ states | find: entity_id == 'lock.front_door' }}` - `count` (condition per item (optional)): Number of items (matching the condition), characters of text, or keys of an object. Example: `{{ builds | count: status == 'failed' }}` - `sum` (value per item (optional)): Sum of the numeric items (or of the expression per item). Empty lists give 0. Example: `{{ orders | sum: total }}` - `indexBy` (key per item (required)): An object keyed by the expression; the first item wins for duplicate keys. Example: `{{ $root | indexBy: entity_id }}` - `first`: The first item of a list. Example: `{{ departures | first }}` - `last`: The last item of a list. Example: `{{ readings | last }}` - `join` (separator expression (optional, default ', ')): Joins the items of a list as text. Example: `{{ tags | join: ' · ' }}` - `toNumber`: Converts text such as "12.5" to a number; anything else gives nothing. Example: `{{ sensor.state | toNumber }}` - `toText`: Converts a value to its display text. Example: `{{ id | toText }}` - `toTime`: Parses a timestamp (ISO 8601, epoch seconds/ms, relative '+5m') to ISO 8601 UTC. Example: `{{ expected | toTime }}` ### 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 ## Examples The example projects the builder offers as templates, with their sample data. ### agent-status What the coding agents are doing, tokens and spend; The latest GitHub Actions run. ```json watch.json { "$schema": "https://quickwatch.app/schema/quickwatch.schema.json", "schemaVersion": 2, "timeZone": "Europe/Stockholm", "params": { "owner": "acme", "repo": "quickwatch" }, "sources": { "agents": { "type": "feed", "push": "values", "refresh": { "push": true } }, "ci": { "type": "http", "mode": "proxied", "url": "https://api.github.com/repos/{{ $params.owner }}/{{ $params.repo }}/actions/runs?per_page=1", "headers": { "Accept": "application/vnd.github+json" }, "refresh": { "poll": "5m", "onOpen": true }, "push": "values", "map": { "runId": "{{ workflow_runs[0].id }}", "workflow": "{{ workflow_runs[0].name }}", "branch": "{{ workflow_runs[0].head_branch }}", "conclusion": "{{ workflow_runs[0].conclusion ?? workflow_runs[0].status }}", "finishedAt": "{{ workflow_runs[0].updated_at | toTime }}" } } }, "actions": { "rerun": { "type": "http", "mode": "proxied", "url": "https://api.github.com/repos/{{ $params.owner }}/{{ $params.repo }}/actions/runs/{{ ci.runId }}/rerun", "method": "POST", "headers": { "Accept": "application/vnd.github+json", "Authorization": "Bearer {{ $secret.githubToken }}" }, "confirm": "Re-run {{ ci.workflow }}?", "toast": { "success": "Re-run started", "failure": "Could not re-run" }, "refresh": ["ci"] }, "nudge": { "type": "http", "url": "https://agents.example.com/api/agents/{{ id }}/nudge", "method": "POST", "body": { "agent": "{{ id }}", "reason": "watch" }, "toast": { "success": "Nudged {{ name }}" } }, "refreshCi": { "type": "refreshSource", "source": "ci", "toast": { "success": "Updated", "failure": "No connection" } } }, "experiences": { "agents": { "theme": { "overrides": { "colors": { "accent": "#B39DDB" } } }, "title": "Agents", "subtitle": "{{ agents.done }}/{{ agents.total }} tasks · {{ agents.tokens | number:compact }} tokens", "icon": "robot", "description": "What the coding agents are doing, tokens and spend", "pages": { "board": { "title": "Agents", "root": { "type": "stack", "children": [ { "type": "status", "state": "{{ ci.conclusion | status:ok=success,warn=in_progress,error=failure }}", "label": "{{ ci.workflow }}", "detail": "{{ ci.branch }} · {{ ci.finishedAt | relative }}" }, { "type": "progress", "value": "{{ agents.done }}", "min": 0, "max": "{{ agents.total }}", "style": "ring", "label": "{{ agents.done }}/{{ agents.total }} tasks" }, { "type": "list", "header": "Feed", "maxRows": 5, "emptyText": "All quiet", "items": { "bind": "agents.feed", "take": 5 }, "item": { "title": "{{ name }}", "subtitle": "{{ task }}", "trailing": "{{ updatedAt | relative }}", "badge": "{{ state | upper }}", "icon": "robot", "action": "nudge" } }, { "type": "stat", "value": "{{ agents.tokens | number:compact }}", "unit": "tokens", "label": "Today", "trend": "{{ agents.tokensTrend }}", "icon": "bolt" }, { "type": "stat", "value": "{{ agents.cost | number:F2 | prefix:'$' }}", "label": "Spend today", "icon": "flag" }, { "type": "button", "label": "Re-run CI", "icon": "build", "action": "rerun" }, { "type": "button", "label": "Refresh", "icon": "refresh", "action": "refreshCi", "style": "secondary" } ] } } }, "tile": { "template": "single-stat", "title": "Tokens", "icon": "bolt", "value": "{{ agents.tokens | number:compact }}", "label": "today", "trend": "{{ agents.tokensTrend }}" }, "complication": { "kind": "rangedValue", "icon": "robot", "value": "{{ agents.done }}", "min": 0, "max": "{{ agents.total }}", "text": "{{ agents.done }}/{{ agents.total }}" } }, "ci": { "theme": { "overrides": { "colors": { "accent": "#B39DDB" } } }, "title": "CI", "subtitle": "{{ ci.workflow }} · {{ ci.conclusion }}", "icon": "git", "description": "The latest GitHub Actions run", "pages": { "ci": { "title": "CI", "root": { "type": "stack", "children": [ { "type": "status", "state": "{{ ci.conclusion | status:ok=success,warn=in_progress,error=failure }}", "label": "{{ ci.workflow }}", "detail": "{{ ci.branch }}" }, { "type": "text", "value": "finished {{ ci.finishedAt | relative }}", "style": "caption", "maxLines": 1 }, { "type": "button", "label": "Re-run", "icon": "build", "action": "rerun" }, { "type": "button", "label": "Refresh", "icon": "refresh", "action": "refreshCi", "style": "secondary" } ] } } }, "tile": { "template": "status", "title": "CI", "icon": "git", "state": "{{ ci.conclusion | status:ok=success,warn=in_progress,error=failure }}", "label": "{{ ci.workflow }}", "detail": "{{ ci.branch }}" } } } } ``` ```json data.json { "agents": { "done": 7, "total": 12, "tokens": 184200, "tokensTrend": "up", "cost": 3.42, "feed": [ { "id": "a1", "name": "Builder", "task": "Implement countdown tile", "state": "running", "updatedAt": "now-2m" }, { "id": "a2", "name": "Reviewer", "task": "Review PR #42", "state": "waiting", "updatedAt": "now-9m" }, { "id": "a3", "name": "Tester", "task": "Fix flaky resolver test", "state": "failed", "updatedAt": "now-21m" }, { "id": "a4", "name": "Docs", "task": "Update README", "state": "done", "updatedAt": "now-1h" } ] }, "ci": { "runId": 9123456, "workflow": "build-and-test", "branch": "main", "conclusion": "success", "finishedAt": "now-14m" } } ``` ### departures Next metro and bus departures; As many departures as fit on one tile. ```json watch.json { "$schema": "https://quickwatch.app/schema/quickwatch.schema.json", "schemaVersion": 2, "timeZone": "Europe/Stockholm", "params": { "siteId": "9117", "stopName": "Odenplan" }, "sources": { "odenplan": { "type": "http", "mode": "proxied", "url": "https://transport.integration.sl.se/v1/sites/{{ $params.siteId }}/departures", "refresh": { "poll": "5m", "onOpen": true }, "push": "values", "map": { "stopName": "{{ $params.stopName }}", "updatedAt": "{{ $now }}", "departures": { "$each": "{{ departures | where: line.transport_mode in ['METRO', 'BUS'] | sortBy: expected ?? scheduled | take: 8 }}", "$map": { "line": "{{ line.designation }}", "destination": "{{ destination }}", "platform": "{{ stop_point.designation }}", "mode": "{{ line.transport_mode | lookup: METRO=train,BUS=bus,TRAM=tram,SHIP=ship,*=train }}", "expectedAt": "{{ expected ?? scheduled | toTime }}", "deviation": "{{ deviations[0].message ?? (expected > scheduled ? 'Late' : null) }}" } } } } }, "actions": { "refresh": { "type": "refreshSource", "source": "odenplan", "toast": { "success": "Updated", "failure": "No connection" } }, "openDepartures": { "type": "openScreen", "screen": "departures" }, "openNext": { "type": "openScreen", "screen": "next" } }, "experiences": { "departures": { "theme": { "overrides": { "colors": { "accent": "#4FC3F7" } } }, "title": "{{ odenplan.stopName }}", "subtitle": "{{ odenplan.departures[0].line }} {{ odenplan.departures[0].destination }} · {{ odenplan.departures[0].expectedAt | relative:compact }}", "icon": "train", "description": "Next metro and bus departures", "main": "departures", "pages": { "departures": { "title": "{{ odenplan.stopName }}", "root": { "type": "stack", "children": [ { "type": "list", "header": "Departures", "maxRows": 6, "emptyText": "No departures", "items": { "bind": "odenplan.departures", "take": 6 }, "item": { "title": "{{ line }} {{ destination }}", "subtitle": "{{ platform | prefix:'Track ' }}", "trailing": { "type": "countdown", "target": "{{ expectedAt }}" }, "badge": "{{ deviation | default:'' }}", "icon": "{{ mode | default:'train' }}", "action": "openNext" } }, { "type": "text", "value": "Updated {{ odenplan.updatedAt | time }}", "style": "caption", "maxLines": 1 }, { "type": "button", "label": "Refresh", "icon": "refresh", "action": "refresh" } ] } }, "next": { "title": "Next departure", "root": { "type": "stack", "children": [ { "type": "text", "value": "{{ odenplan.departures[0].line }} {{ odenplan.departures[0].destination }}", "style": "title", "maxLines": 2 }, { "type": "countdown", "target": "{{ odenplan.departures[0].expectedAt }}", "label": "Leaves in" }, { "type": "countdown", "target": "{{ odenplan.departures[0].expectedAt }}", "format": "clock", "label": "Timer" }, { "type": "text", "value": "{{ odenplan.departures[0].platform | prefix:'Track ' }}", "style": "caption" }, { "type": "button", "label": "All departures", "icon": "train", "action": "openDepartures", "style": "secondary" } ] } } }, "tile": { "template": "list3", "title": "{{ odenplan.stopName }}", "icon": "train", "items": { "bind": "odenplan.departures", "take": 3 }, "item": { "title": "{{ line }} {{ destination }}", "trailing": { "type": "countdown", "target": "{{ expectedAt }}" } }, "emptyText": "No trains" }, "complication": { "kind": "shortText", "opens": "next", "icon": "train", "title": "{{ odenplan.departures[0].line }}", "text": { "type": "countdown", "target": "{{ odenplan.departures[0].expectedAt }}" } } }, "board": { "theme": { "overrides": { "colors": { "accent": "#4FC3F7" } } }, "title": "Departure board", "subtitle": "{{ odenplan.stopName }}", "icon": "train", "description": "As many departures as fit on one tile", "main": "board", "pages": { "board": { "title": "{{ odenplan.stopName }}", "root": { "type": "list", "header": "Departures", "maxRows": 8, "emptyText": "No departures", "items": { "bind": "odenplan.departures", "take": 8 }, "item": { "title": "{{ line }} {{ destination }}", "trailing": { "type": "countdown", "target": "{{ expectedAt }}" }, "icon": "{{ mode | default:'train' }}" } } } }, "tile": { "template": "compact-list", "title": "{{ odenplan.stopName }}", "icon": "train", "items": { "bind": "odenplan.departures", "take": 8 }, "item": { "title": "{{ line }} {{ destination }}", "trailing": { "type": "countdown", "target": "{{ expectedAt }}" } }, "emptyText": "No departures" } } } } ``` ```json data.json { "odenplan": { "stopName": "Odenplan", "updatedAt": "now-1m", "departures": [ { "line": "14", "destination": "Mörby centrum", "platform": "2", "mode": "train", "expectedAt": "+2m30s", "deviation": "Late" }, { "line": "14", "destination": "Fruängen", "platform": "1", "mode": "train", "expectedAt": "+7m" }, { "line": "4", "destination": "Gullmarsplan", "platform": "C", "mode": "bus", "expectedAt": "+9m" }, { "line": "17", "destination": "Åkeshov", "platform": "2", "mode": "train", "expectedAt": "+12m" }, { "line": "19", "destination": "Hässelby strand", "platform": "1", "mode": "train", "expectedAt": "+15m" }, { "line": "18", "destination": "Farsta strand", "platform": "2", "mode": "train", "expectedAt": "+21m" }, { "line": "17", "destination": "Skarpnäck", "platform": "1", "mode": "train", "expectedAt": "+26m" } ] } } ``` ### hello A greeting and today's visitor count. ```json watch.json { "$schema": "https://quickwatch.app/schema/quickwatch.schema.json", "schemaVersion": 2, "timeZone": "Europe/Stockholm", "sources": { "greeting": { "type": "feed", "push": "values" } }, "experiences": { "hello": { "theme": { "overrides": { "colors": { "accent": "#8AB4F8" } } }, "title": "Hello", "subtitle": "{{ greeting.visitors }} visitors today", "icon": "user", "description": "A greeting and today's visitor count", "pages": { "hello": { "title": "Hello", "root": { "type": "stack", "direction": "vertical", "children": [ { "type": "text", "value": "{{ greeting.message }}", "style": "title" }, { "type": "text", "value": "Signed in as {{ greeting.name }}", "style": "caption" }, { "type": "stat", "value": "{{ greeting.visitors }}", "unit": "today", "label": "Visitors", "trend": "{{ greeting.trend }}" } ] } } }, "tile": { "template": "single-stat", "title": "Hello", "icon": "user", "value": "{{ greeting.visitors }}", "label": "visitors", "trend": "{{ greeting.trend }}" } } } } ``` ```json data.json { "greeting": { "message": "Hello, Watch!", "name": "Alex", "visitors": 42, "trend": "up" } } ``` ### home-controls Home Assistant on your home Wi-Fi: door, lights, fan; The living room lamp; One-tap scenes for the house. ```json watch.json { "$schema": "https://quickwatch.app/schema/quickwatch.schema.json", "schemaVersion": 2, "timeZone": "Europe/Stockholm", "params": { "haUrl": "http://192.168.1.10:8123" }, "sources": { "home": { "type": "http", "mode": "direct", "url": "{{ $params.haUrl }}/api/states", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "refresh": { "onOpen": true }, "push": "signal", "map": { "$let": { "e": "{{ $root | indexBy: entity_id }}" }, "indoorTemp": "{{ $e['sensor.indoor_temperature'].state | toNumber }}", "door": "{{ $e['lock.front_door'].state }}", "lamp": { "on": "{{ $e['light.living_room'].state == 'on' }}", "brightness": "{{ ($e['light.living_room'].attributes.brightness ?? 0) * 100 / 255 | round }}" }, "fan": { "on": "{{ $e['fan.bedroom'].state == 'on' }}" } } } }, "actions": { "lampOn": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/light/turn_on", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "light.living_room" }, "refresh": ["home"] }, "lampOff": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/light/turn_off", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "light.living_room" }, "refresh": ["home"] }, "fanOn": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/fan/turn_on", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "fan.bedroom" }, "refresh": ["home"] }, "fanOff": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/fan/turn_off", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "fan.bedroom" }, "refresh": ["home"] }, "lock": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/lock/lock", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "lock.front_door" }, "refresh": ["home"], "confirm": "Lock the front door?", "toast": { "success": "Door locked", "failure": "Lock did not respond" } }, "unlock": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/lock/unlock", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "lock.front_door" }, "refresh": ["home"], "confirm": "Unlock the front door?", "toast": { "success": "Door unlocked" } }, "allOff": { "type": "http", "mode": "direct", "method": "POST", "url": "{{ $params.haUrl }}/api/services/scene/turn_on", "headers": { "Authorization": "Bearer {{ $secret.haToken }}" }, "body": { "entity_id": "scene.all_off" }, "refresh": ["home"], "confirm": "Turn everything off?", "toast": { "success": "Everything off" } } }, "experiences": { "home": { "theme": { "overrides": { "colors": { "accent": "#FFB74D" } } }, "title": "Home", "subtitle": "Door {{ home.door }} · {{ home.indoorTemp | round:1 }} °C", "icon": "home", "description": "Home Assistant on your home Wi-Fi: door, lights, fan", "pages": { "home": { "title": "Home", "root": { "type": "stack", "children": [ { "type": "stack", "direction": "horizontal", "children": [ { "type": "icon", "icon": "home", "size": "medium" }, { "type": "stat", "value": "{{ home.indoorTemp | round:1 }}", "unit": "°C", "label": "Indoor" } ] }, { "type": "status", "state": "{{ home.door | status:ok=locked,warn=unlocked,error=open }}", "label": "Front door", "detail": "{{ home.door }}" }, { "type": "toggle", "label": "Living room", "state": "{{ home.lamp.on }}", "onAction": "lampOn", "offAction": "lampOff", "icon": "light" }, { "type": "toggle", "label": "Bedroom fan", "state": "{{ home.fan.on }}", "onAction": "fanOn", "offAction": "fanOff", "icon": "fan" }, { "type": "progress", "value": "{{ home.lamp.brightness }}", "max": 100, "style": "bar", "label": "Brightness {{ home.lamp.brightness }}%" }, { "type": "button", "label": "Lock door", "icon": "lock", "action": "lock" }, { "type": "button", "label": "Unlock", "icon": "unlock", "action": "unlock", "style": "secondary" }, { "type": "button", "label": "All off", "icon": "power", "action": "allOff", "style": "secondary" } ] } } }, "tile": { "template": "status", "title": "Front door", "icon": "door", "state": "{{ home.door | status:ok=locked,warn=unlocked,error=open }}", "label": "{{ home.door }}" }, "complication": { "kind": "image", "icon": "home" } }, "lamp": { "theme": { "overrides": { "colors": { "accent": "#FFB74D" } } }, "title": "Lamp", "subtitle": "Living room · {{ home.lamp.brightness }}%", "icon": "light", "description": "The living room lamp", "pages": { "lamp": { "title": "Lamp", "root": { "type": "stack", "children": [ { "type": "toggle", "label": "Living room", "state": "{{ home.lamp.on }}", "onAction": "lampOn", "offAction": "lampOff", "icon": "light" }, { "type": "progress", "value": "{{ home.lamp.brightness }}", "max": 100, "style": "bar", "label": "Brightness {{ home.lamp.brightness }}%" } ] } } }, "tile": { "template": "toggle", "title": "Lamp", "icon": "light", "toggle": { "type": "toggle", "label": "Living room", "state": "{{ home.lamp.on }}", "onAction": "lampOn", "offAction": "lampOff" } } }, "quick": { "theme": { "overrides": { "colors": { "accent": "#FFB74D" } } }, "title": "Quick actions", "subtitle": "Lock, unlock, all off", "icon": "power", "description": "One-tap scenes for the house", "pages": { "quick": { "title": "Quick", "root": { "type": "stack", "children": [ { "type": "button", "label": "Lock", "icon": "lock", "action": "lock" }, { "type": "button", "label": "Unlock", "icon": "unlock", "action": "unlock", "style": "secondary" }, { "type": "button", "label": "All off", "icon": "power", "action": "allOff", "style": "secondary" } ] } } }, "tile": { "template": "buttons2", "title": "Quick", "buttons": [ { "type": "button", "label": "Lock", "icon": "lock", "action": "lock" }, { "type": "button", "label": "All off", "icon": "power", "action": "allOff" } ] } } } } ``` ```json data.json { "home": { "indoorTemp": 21.36, "door": "unlocked", "lamp": { "on": true, "brightness": 65 }, "fan": { "on": false } } } ``` ### webhook-status Deliveries to the orders endpoint; Share of webhooks delivered today. ```json watch.json { "$schema": "https://quickwatch.app/schema/quickwatch.schema.json", "schemaVersion": 2, "timeZone": "Europe/Stockholm", "sources": { "hooks": { "type": "feed", "push": "values", "refresh": { "push": true } } }, "actions": { "sendTest": { "type": "http", "url": "https://hooks.example.com/api/endpoints/{{ hooks.endpoint }}/test", "method": "POST", "body": { "event": "ping", "count": 1 }, "toast": { "success": "Test sent", "failure": "Endpoint unreachable" } }, "redeliver": { "type": "http", "url": "https://hooks.example.com/api/deliveries/{{ id }}/redeliver", "method": "POST", "confirm": "Redeliver {{ event }}?", "toast": { "success": "Redelivered" } } }, "experiences": { "webhooks": { "theme": { "base": "light", "overrides": { "colors": { "accent": "#146C2E" } } }, "title": "Webhooks", "subtitle": "{{ hooks.recent[0].event }} · {{ hooks.recent[0].at | relative }}", "icon": "bell", "description": "Deliveries to the orders endpoint", "pages": { "deliveries": { "title": "Webhooks", "root": { "type": "stack", "header": "{{ hooks.endpoint }}", "children": [ { "type": "status", "state": "{{ hooks.health | status:ok=healthy,warn=degraded,error=down }}", "label": "Endpoint", "detail": "{{ hooks.health }}" }, { "type": "progress", "value": "{{ hooks.successRate }}", "max": 100, "style": "bar", "label": "{{ hooks.successRate | round }}% delivered" }, { "type": "stat", "value": "{{ hooks.today }}", "unit": "events", "label": "Today", "trend": "{{ hooks.trend }}", "icon": "bell" }, { "type": "list", "header": "Recent", "maxRows": 5, "emptyText": "No deliveries yet", "items": "hooks.recent", "item": { "title": "{{ event }}", "subtitle": "HTTP {{ code }} · {{ ms }} ms", "trailing": "{{ at | time }}", "badge": "{{ result | upper }}", "icon": "code", "action": "redeliver" } }, { "type": "text", "value": "Last event {{ hooks.recent[0].at | relative }}", "style": "caption", "maxLines": 1 }, { "type": "button", "label": "Send test", "icon": "rocket", "action": "sendTest" } ] } } }, "tile": { "template": "text", "title": "Last webhook", "icon": "bell", "text": "{{ hooks.recent[0].event }} · {{ hooks.recent[0].result }}" }, "complication": { "kind": "longText", "icon": "bell", "text": "{{ hooks.today }} hooks · {{ hooks.health }}" } }, "delivery-rate": { "theme": { "base": "light", "overrides": { "colors": { "accent": "#146C2E" } } }, "title": "Delivery rate", "subtitle": "{{ hooks.successRate | round }}% delivered", "icon": "check", "description": "Share of webhooks delivered today", "pages": { "rate": { "title": "Delivered", "root": { "type": "stack", "children": [ { "type": "progress", "value": "{{ hooks.successRate }}", "max": 100, "style": "bar", "label": "{{ hooks.successRate | round }}% delivered" }, { "type": "stat", "value": "{{ hooks.today }}", "unit": "events", "label": "Today", "trend": "{{ hooks.trend }}", "icon": "bell" }, { "type": "status", "state": "{{ hooks.health | status:ok=healthy,warn=degraded,error=down }}", "label": "Endpoint", "detail": "{{ hooks.health }}" } ] } } }, "tile": { "template": "progress-ring", "title": "Delivered", "value": "{{ hooks.successRate }}", "max": 100, "label": "{{ hooks.successRate | round }}%" } } } } ``` ```json data.json { "hooks": { "endpoint": "orders-prod", "health": "healthy", "successRate": 97.4, "today": 318, "trend": "up", "recent": [ { "id": "d_901", "event": "order.created", "code": 200, "ms": 84, "result": "delivered", "at": "now-3m" }, { "id": "d_900", "event": "order.paid", "code": 200, "ms": 121, "result": "delivered", "at": "now-11m" }, { "id": "d_899", "event": "refund.created", "code": 502, "ms": 3004, "result": "failed", "at": "now-26m" }, { "id": "d_898", "event": "order.created", "code": 200, "ms": 77, "result": "delivered", "at": "now-48m" } ] } } ```