# Making a good pane

Generated from the pane registry — this file and the schemas can't disagree.

## House rules

- One idea per pane. A pane is a glanceable card on a phone, not a report. If you're writing a second sentence of context, you have a second pane — or the wrong type.
- Pre-format every display string. The client renders them verbatim: "$14,892" not 14892.31, "+12.4%" not 0.124, "3 min read" not 180.
- Lead with the subject. The first field is what gets read at a glance — the number, the headline, the person's words.
- Use `label` as a kicker, not a sentence: 2–5 words, no trailing period. "Yesterday", "Markets", "Match · 94%".
- Say it once. `label` renders directly above the card's own first line — a `kpi`'s metric, a `list`'s first item — so a label that repeats it prints the same words twice, stacked. Spend it on what the card doesn't already say: the timeframe, the source, the occasion.
- Always set `publisherInit` and `publisherColor`. The avatar is the card's only identity mark, and an unset one reads as unbranded.
- Leave `readable` off unless the card genuinely reads well aloud. It's an optional envelope boolean, default `false`, and setting it lets the client offer a speaker control that speaks the card's contents. Worth it for prose — `news`, `quote`, a short `list`, a `kpi` with a sentence of context — and for hands-free or accessibility situations. Wrong for dense numbers, `code`, anything whose value is visual (`chart`, `image`, `video`, `map`), and `audio`, which already carries sound. Omitting it is the right call for most panes.
- Leave `priority` off unless the pane is worse to read an hour late. It's an optional envelope boolean, default `false`: a priority pane is pushed within a minute, naming its publisher and label, instead of waiting for the hourly batch. Right for a countdown about to fire, a price alert that crossed its line, an outage. Wrong for everything else — and it's capped per token (5 an hour, 20 a day by default); past the cap the pane is published as a normal one and a style note says so.
- Give numbers something to be compared against — a delta, a baseline, a 7-day average. A number with no reference point is trivia.
- Set `expiresAt` on anything time-bound. Feeds are FIFO and items stay until completed; a stale price or commute from Tuesday is worse than no pane at all. For a `countdown`, that means `target` plus a short grace window — a countdown that has fired is clutter.
- Write as the publisher, not as an assistant. No "Here's your…", no preamble, no emoji padding.
- Prefer a structured type over `html`. Structured panes inherit the design system — type scale, spacing, dark mode — and `html` panes don't.
- Treat schema maximums as ceilings, not targets. The card is 360pt wide on a phone, which is the real limit: a `label` truncates past ~35 characters, and a one-line context string wraps past ~40 — nowhere near what the schemas allow.
- Check `howItRenders` on the type before composing. It says where each field lands on the card and what the treatment draws for you — most bad panes are a reasonable payload written blind.

## Choosing a type

| When | Type |
| --- | --- |
| One number that moved | `kpi` |
| A market price, with intraday shape | `price` |
| Any other series over a window — including non-financial metrics | `chart` |
| A published story: headline, one line of context, a link | `news` |
| Someone's social post, reproduced verbatim | `tweet` |
| A passage worth reading slowly — a highlight, a line from an interview | `quote` |
| Three to six short parallel items, or tasks with a done state | `list` |
| Results of a vote or survey | `poll` |
| An open role surfaced to someone who might want it | `job` |
| A picture that is itself the point | `image` |
| Something to watch | `video` |
| Something to listen to | `audio` |
| Getting from A to B, right now | `map` |
| Conditions where the user is | `weather` |
| A commit, build, or deploy worth interrupting someone about | `code` |
| A known moment coming up — a launch, a race, a deadline — counted down to | `countdown` |
| Nothing above fits and the content is genuinely visual — last resort | `html` |

## `price`

A tradeable asset's current price with its intraday shape — stocks, crypto, FX, commodities.

![price price-v1](https://getpanes.com/docs/images/price-v1.png)

**Craft**

- Send 12–60 `spark` points. Two points draw a straight line and make the card look broken; the treatment was drawn for a dense series.
- `spark` must end at `value` — the sparkline and the printed price read as one thing, so a mismatch is obvious.
- `change` and `changePct` are signed numbers, not strings: `-0.32` and `-0.75`.
- Fill in `open`, `dayHigh`, `dayLow` whenever you have them — they render as the OHLC grid, which is most of what makes this card feel finished.
- Set `currency` for non-USD assets so the card reads "$42.18 · AUD" instead of implying dollars-unqualified.
- For crypto, set `glyph` ("Ξ", "₿") — it replaces the ticker badge with the asset's own mark.
- Quotes go stale: set `expiresAt` on the envelope (a few hours for intraday) so yesterday's price clears itself.

**Avoid**

- Don't use `price` for something that isn't a market price — a signup count with a sparkline is a `chart`.
- Don't pre-format `value` as a string. This is the one card where the numbers really are numbers; the client formats them.
- Don't pad `spark` with a flat repeated value — omit history you don't have and let the leaner card render.

**Example — An equity the user watches, mid-session**

```json
{
  "type": "price",
  "publisher": "Yahoo Finance",
  "publisherInit": "YF",
  "publisherColor": "#7B68EE",
  "label": "Stock you watch",
  "data": {
    "symbol": "NVDA",
    "name": "NVIDIA Corp.",
    "currency": "USD",
    "currencySymbol": "$",
    "value": 887.21,
    "change": 21.04,
    "changePct": 2.43,
    "open": 866.17,
    "dayHigh": 891.04,
    "dayLow": 862.3,
    "spark": [
      866.17,
      871.4,
      868.2,
      875.6,
      882.1,
      886.4,
      880.9,
      877.3,
      884.6,
      889.2,
      887.21
    ],
    "axis": [
      "09:30",
      "12:00",
      "16:00"
    ],
    "windowLabel": "Since open",
    "windowShort": "today"
  }
}
```

**Example — Crypto, where the asset has its own mark and trades around the clock**

```json
{
  "type": "price",
  "publisher": "Coinbase",
  "publisherInit": "CB",
  "publisherColor": "#0052FF",
  "label": "Holdings",
  "data": {
    "symbol": "ETH",
    "name": "Ethereum",
    "glyph": "Ξ",
    "currency": "USD",
    "currencySymbol": "$",
    "value": 3580,
    "change": 45,
    "changePct": 1.27,
    "open": 3520,
    "dayHigh": 3612,
    "dayLow": 3498,
    "spark": [
      3520,
      3538,
      3555,
      3541,
      3566,
      3589,
      3612,
      3574,
      3560,
      3571,
      3580
    ],
    "axis": [
      "00:00",
      "08:00",
      "16:00"
    ],
    "windowLabel": "Since open",
    "windowShort": "today"
  }
}
```

**Example — A non-USD listing where only the basics are known**

```json
{
  "type": "price",
  "publisher": "ASX Watch",
  "publisherInit": "AX",
  "publisherColor": "#00594F",
  "label": "Portfolio",
  "data": {
    "symbol": "BHP",
    "name": "BHP Group",
    "currency": "AUD",
    "currencySymbol": "$",
    "value": 42.18,
    "change": -0.32,
    "changePct": -0.75,
    "spark": [
      42.5,
      42.62,
      42.55,
      42.44,
      42.38,
      42.31,
      42.24,
      42.2,
      42.15,
      42.22,
      42.26,
      42.19,
      42.14,
      42.18
    ]
  }
}
```

## `chart`

Any single series over a window where the shape of the trend is the point — including non-financial metrics. Reach for `price` instead when it's a live quote and the current number matters more than the curve.

![chart chart-v1](https://getpanes.com/docs/images/chart-v1.png)

**Craft**

- `points` is the whole story: send one value per interval in the window (30 daily points for `period: "30D"`), oldest first.
- `axis` takes 2–4 evenly-spaced labels, not one per point: `["MON", "TUE", "WED", "THU", "FRI"]` for a week, month-day stamps for a month.
- `period` is the short code on the card ("1D", "5D", "30D"); `windowLabel` is the long form ("Last 30 days") used in prose.
- For counts rather than money, set `currencySymbol: " "` — the card prefixes values with "$" otherwise.
- `value`/`change`/`changePct` describe the latest point against the previous one, so the header and the curve agree.
- Send `open` — it's what the dashed reference line is drawn from. For a non-financial series it's the first point of the window, which turns the curve into "up or down since we started watching".
- `symbol` and `name` are read together, big then small: "DAU" over "Daily active users". Don't make `name` a longer spelling of `symbol`, and don't repeat the publisher there.

**Avoid**

- Don't send fewer than ~7 points — a short series renders as an angular zigzag rather than a trend.
- Don't label every point on the `axis`; the labels overlap and the card looks cluttered.
- Don't use `chart` for two series (this vs. last month) — the treatment draws one line. Send the comparison as a `kpi` delta instead.
- Don't send `currency` for a non-financial series — the tag is there to say USD vs AUD, and "· COUNT" next to the name reads as a mistake. Leave it off and no tag is drawn.

**How it renders**

- The header is the market treatment: `symbol` at 22pt, `name · currency` beneath it, `currencySymbol + value` on the right with `change · changePct` under that.
- Header numbers and the OPEN annotation always render to two decimal places, so a count of 22 reads as "22.00". Counts with a natural scale (revenue, minutes, thousands) sit better here than small integers.
- `open` draws a dashed horizontal line across the plot and the `OPEN …` annotation above it; both are simply absent when you don't send it.
- `axis` labels are spread evenly across the full width regardless of where those dates actually fall in `points` — send labels that are themselves evenly spaced through the window.

**Example — A five-day equity trend where the shape matters more than the tick**

```json
{
  "type": "chart",
  "publisher": "Yahoo Finance",
  "publisherInit": "YF",
  "publisherColor": "#7B68EE",
  "label": "This week",
  "data": {
    "symbol": "TSLA",
    "name": "Tesla, Inc.",
    "currency": "USD",
    "currencySymbol": "$",
    "value": 248.5,
    "change": -8.21,
    "changePct": -3.2,
    "open": 256.71,
    "dayHigh": 272,
    "dayLow": 244.1,
    "points": [
      256.71,
      262.3,
      258.9,
      265.2,
      271.4,
      268.1,
      254.7,
      251.2,
      246.8,
      252.3,
      248.5
    ],
    "period": "5D",
    "axis": [
      "MON",
      "WED",
      "FRI"
    ],
    "windowLabel": "5-day",
    "windowShort": "5 days"
  }
}
```

**Example — A product metric over a month — counts, not currency**

```json
{
  "type": "chart",
  "publisher": "Panes Ops",
  "publisherInit": "P",
  "publisherColor": "#FF5A1F",
  "label": "Last 30 days",
  "data": {
    "symbol": "DAU",
    "name": "Daily active users",
    "currencySymbol": " ",
    "value": 1482,
    "change": 96,
    "changePct": 6.9,
    "open": 1180,
    "points": [
      1180,
      1204,
      1156,
      1233,
      1290,
      1247,
      1198,
      1265,
      1310,
      1288,
      1342,
      1301,
      1276,
      1355,
      1389,
      1362,
      1298,
      1341,
      1404,
      1437,
      1398,
      1352,
      1411,
      1466,
      1443,
      1389,
      1425,
      1478,
      1386,
      1482
    ],
    "period": "30D",
    "axis": [
      "JUL 6",
      "JUL 20",
      "AUG 4"
    ],
    "windowLabel": "Last 30 days",
    "windowShort": "30 days"
  }
}
```

## `kpi`

One number that moved, and how it's doing. The default choice for any daily or weekly metric check-in.

![kpi kpi-v1](https://getpanes.com/docs/images/kpi-v1.png)

**Craft**

- `value` renders at display size — keep it under ~8 characters. "$14,892" reads at a glance; "$14,892.37" doesn't.
- `deltaPos` means "this is good news", not "this number went up". Latency rising is `delta: "+18%"` with `deltaPos: false`.
- Always send a `delta` — the card draws the pill whether or not you fill it. "±0" for a flat day, "NEW" for a first run.
- `metric` is the noun alone ("Revenue", "Signups"); put the timeframe in the envelope's `label` ("Yesterday") so the same metric can recur daily without repeating itself.
- `sub` is one line of context, not a sentence — about 40 characters: "vs. avg Mon · 412 charges".
- Don't restate the delta in `sub`. The pill has said it; `sub` is for the *other* reference point — a baseline, an average, a total.

**Avoid**

- Don't repeat `metric` inside `value` — "Revenue: $14,892" prints the word twice.
- Don't repeat `metric` in `label` either — they render one directly above the other, so "Signups · Aug 3" over "SIGNUPS" reads as a stutter. The label is the timeframe alone.
- Don't send raw numbers (`14892.31`) — `value` and `delta` are display strings the client renders verbatim.
- Don't pack three metrics into one card with separators — that's three `kpi` panes, or one `list`.

**How it renders**

- `label` prints immediately above `metric`, in the same uppercase mono — the two lines are read as one unit, so they must not say the same thing.
- `value` is set at 64pt display and shrinks to fit one line; a long string gets smaller rather than wrapping.
- The delta pill is always drawn, coloured green (`deltaPos: true`) or red (`false`). Omitting `delta` leaves an empty coloured capsule on the card.
- `sub` sits on one line beside the pill — roughly 40 characters on a phone before it wraps under the number.

**Example — Daily revenue check-in from a payments integration**

```json
{
  "type": "kpi",
  "publisher": "Stripe Agent",
  "publisherInit": "S",
  "publisherColor": "#635BFF",
  "label": "Yesterday",
  "data": {
    "metric": "Revenue",
    "value": "$14,892",
    "delta": "+12.4%",
    "deltaPos": true,
    "sub": "vs. avg Mon · 412 charges"
  }
}
```

**Example — An operational metric where an increase is bad news**

```json
{
  "type": "kpi",
  "publisher": "Datadog",
  "publisherInit": "DD",
  "publisherColor": "#632CA6",
  "label": "API · last hour",
  "data": {
    "metric": "p95 latency",
    "value": "412ms",
    "delta": "+18%",
    "deltaPos": false,
    "sub": "SLO is 300ms · 2 endpoints over budget"
  }
}
```

**Example — First run, with no prior period to compare against**

```json
{
  "type": "kpi",
  "publisher": "Panes Ops",
  "publisherInit": "P",
  "publisherColor": "#FF5A1F",
  "label": "Today",
  "data": {
    "metric": "Active projects",
    "value": "37",
    "delta": "NEW",
    "deltaPos": true,
    "sub": "First day of tracking"
  }
}
```

## `tweet`

Someone else's social post, reproduced as they wrote it — X, Mastodon, Bluesky, LinkedIn.

![tweet tweet-v1](https://getpanes.com/docs/images/tweet-v1.png)

**Craft**

- Reproduce `text` verbatim, including line breaks. Don't clean up punctuation or trim it to fit.
- `handle` keeps its "@" and `author` is the display name — the card renders them as a pair.
- `likes` is a pre-formatted string ("4.2K"); `replies` is a plain integer. They are deliberately different types.
- Omit `likes`/`replies` entirely rather than sending zeros — a post with no engagement shouldn't advertise it.
- `publisher` is the network ("X", "Bluesky"); `label` is why it's here ("From people you follow").

**Avoid**

- Don't paraphrase or summarize the post — if you're rewriting it, publish a `quote` or `news` instead.
- Don't send threads as one pane; pick the post that carries the point.
- Don't put your own commentary in `text`. Context belongs in `label`.

**Example — A post from an account the user follows**

```json
{
  "type": "tweet",
  "publisher": "X",
  "publisherInit": "X",
  "publisherColor": "#000000",
  "label": "From people you follow",
  "data": {
    "author": "Paul Graham",
    "handle": "@paulg",
    "text": "The most underrated skill in startups isn't writing code or talking to users. It's deciding what not to do.",
    "likes": "4.2K",
    "replies": 318
  }
}
```

**Example — A post surfaced by topic, with a link back to the original**

```json
{
  "type": "tweet",
  "publisher": "Bluesky",
  "publisherInit": "BS",
  "publisherColor": "#0085FF",
  "label": "Matches “design systems”",
  "data": {
    "author": "Jenny Chen",
    "handle": "@jenny.bsky.social",
    "text": "Shipped our token pipeline today. Figma → JSON → every platform, one command. Six months of arguing about naming, one afternoon of actual code.",
    "likes": "612",
    "replies": 24,
    "url": "https://bsky.app/profile/example/post/123"
  }
}
```

## `news`

A published story worth surfacing: headline, one line of context, a link out.

![news news-v1](https://getpanes.com/docs/images/news-v1.png)

**Craft**

- Keep `headline` to 6–14 words. It's set at display size, and long headlines wrap into a wall.
- `standfirst` is exactly one sentence saying why this matters — the fact the headline left out, not a re-statement of it.
- Write the headline as the outlet would, in the present tense, without a trailing period.
- `publisher` is the outlet ("Bloomberg", "The Verge"); `label` is the section or reason ("Markets", "Because you follow Rivian").
- `read` is a formatted display string: "3 min read".
- Include `url` whenever one exists — a news card the user can't open is a dead end.

**Avoid**

- Don't editorialize in `headline` or add your own framing ("Interesting: …") — publish it the way the source wrote it.
- Don't paste the article's opening paragraph into `standfirst`; write the one line that earns the tap.
- Don't use `news` for your own summaries or updates — that's a `quote`, `list`, or `kpi` depending on shape.

**Example — A market-moving story for someone who follows finance**

```json
{
  "type": "news",
  "publisher": "Bloomberg",
  "publisherInit": "B",
  "publisherColor": "#000000",
  "label": "Markets",
  "data": {
    "headline": "Fed signals two cuts before year-end as core inflation eases",
    "standfirst": "Powell told a Senate panel the path is now \"clearly disinflationary,\" opening the door to a September cut.",
    "read": "3 min read",
    "url": "https://www.bloomberg.com/news/example"
  }
}
```

**Example — Industry news matched to something the user tracks**

```json
{
  "type": "news",
  "publisher": "The Verge",
  "publisherInit": "V",
  "publisherColor": "#FA4B2A",
  "label": "Because you follow Figma",
  "data": {
    "headline": "Figma opens its design-system API to third-party tools",
    "standfirst": "Teams can now sync tokens and components into their own pipelines without exporting a file.",
    "read": "4 min read",
    "url": "https://www.theverge.com/example"
  }
}
```

## `job`

An open role surfaced to someone who might want it.

![job job-v1](https://getpanes.com/docs/images/job-v1.png)

**Craft**

- `role` is the title as posted, nothing appended — no company, no location, no seniority editorializing.
- `location` carries the arrangement too: "San Francisco · Hybrid", "Remote (AU/NZ)".
- `salary` is a pre-formatted display range: "$220–280K", "£95–120K + equity". Omit it rather than guessing.
- `why` is the whole reason this card earns its place — say what about *this person* matched: "ex-Stripe · design systems · 8 yrs".
- Put the match strength in `label` ("Match · 94%") so the card leads with the role, not the score.
- Roles close: set `expiresAt` to the posting's close date, or ~30 days out.

**Avoid**

- Don't send a role with no `why` — an unexplained job card reads like spam.
- Don't paste the job description into `why`; it's one line, not a summary.
- Don't invent a salary range the posting doesn't state.

**Example — A strong match from a job board**

```json
{
  "type": "job",
  "publisher": "LinkedIn",
  "publisherInit": "in",
  "publisherColor": "#0A66C2",
  "label": "Match · 94%",
  "data": {
    "role": "Staff Product Designer",
    "company": "Linear",
    "location": "San Francisco · Hybrid",
    "salary": "$220–280K",
    "why": "Why you · ex-Stripe · design systems · 8 yrs",
    "url": "https://linear.app/careers/example"
  }
}
```

**Example — A contract role where compensation isn't published**

```json
{
  "type": "job",
  "publisher": "Hacker News",
  "publisherInit": "Y",
  "publisherColor": "#FF6600",
  "label": "Who's hiring · August",
  "data": {
    "role": "Senior Backend Engineer (Contract)",
    "company": "Volt Grid",
    "location": "Remote (AU/NZ) · 6 months",
    "why": "Why you · Postgres at scale · energy sector · open to contract",
    "url": "https://news.ycombinator.com/item?id=example"
  }
}
```

## `image`

A picture that is itself the point — a photo, a generated render, a screenshot worth seeing.

![image image-v1](https://getpanes.com/docs/images/image-v1.png)

**Craft**

- Panes has no upload: `url` must be a public HTTPS image you already host, and it needs to stay up.
- Set `aspect` to the image's real ratio ("4 / 5", "16 / 9") so the card reserves the right space instead of reflowing on load.
- `title` is a caption, not a filename — say what's in the picture.
- Always write `alt`: it's what VoiceOver reads, and what shows if the image 404s.
- `sub` carries the when/where ("Bondi · this morning").

**Avoid**

- Don't send charts or dashboards as images — a `chart` or `kpi` pane renders in the design system and stays legible on a phone.
- Don't link an image behind auth, a signed URL that expires, or a page (rather than the file itself).
- Don't repeat `title` in `alt`; alt describes the picture for someone who can't see it.

**Example — A photo worth surfacing on its own**

```json
{
  "type": "image",
  "publisher": "Camera Roll",
  "publisherInit": "CR",
  "publisherColor": "#1A4FCC",
  "label": "One year ago today",
  "data": {
    "title": "First light at Bronte",
    "sub": "Sydney · 6:14am",
    "aspect": "4 / 5",
    "url": "https://example.com/photos/bronte-sunrise.jpg",
    "alt": "Swimmers crossing an ocean pool as the sun breaks over the headland"
  }
}
```

**Example — A generated render returned to the user**

```json
{
  "type": "image",
  "publisher": "Studio",
  "publisherInit": "St",
  "publisherColor": "#635BFF",
  "label": "Render finished",
  "data": {
    "title": "Packaging concept · matte black",
    "sub": "Variant 3 of 4 · 2048px",
    "aspect": "1 / 1",
    "url": "https://example.com/renders/pack-v3.png",
    "alt": "Matte black cylindrical package on a pale grey backdrop, lit from the left"
  }
}
```

## `video`

A video worth the user's next ten minutes — an upload, a recording, a clip.

![video video-v1](https://getpanes.com/docs/images/video-v1.png)

**Craft**

- `duration` is pre-formatted and honest: "4:21", "1:02:30". It's the main thing people decide on.
- Send `thumbnailUrl` — a video card without a still is a grey box.
- `aspect` should match the source ("16 / 9" landscape, "9 / 16" vertical) so the card doesn't letterbox.
- `title` is the video's own title; `sub` is the channel, series, or context.
- `url` should open the video directly (deep link or watch page), not a channel index.

**Avoid**

- Don't publish a video card without `url` — there's nothing to do with it.
- Don't estimate `duration` — omit it if you don't know.
- Don't use `video` for a livestream that's already ended.

**Example — A new upload from a subscribed channel**

```json
{
  "type": "video",
  "publisher": "YouTube",
  "publisherInit": "YT",
  "publisherColor": "#FF0000",
  "label": "New from Veritasium",
  "data": {
    "title": "The engineering problem nobody solved for 40 years",
    "sub": "Veritasium · 1.2M views",
    "duration": "18:42",
    "aspect": "16 / 9",
    "url": "https://www.youtube.com/watch?v=example",
    "thumbnailUrl": "https://i.ytimg.com/vi/example/maxresdefault.jpg"
  }
}
```

**Example — A meeting recording the user missed**

```json
{
  "type": "video",
  "publisher": "Zoom",
  "publisherInit": "Z",
  "publisherColor": "#2D8CFF",
  "label": "You were double-booked",
  "data": {
    "title": "Q3 roadmap review",
    "sub": "Product weekly · 8 attendees",
    "duration": "47:15",
    "aspect": "16 / 9",
    "url": "https://zoom.us/rec/share/example",
    "thumbnailUrl": "https://example.com/thumbs/roadmap-review.jpg"
  }
}
```

## `audio`

Something to listen to — a podcast episode, a voice note, a generated briefing.

![audio audio-v1](https://getpanes.com/docs/images/audio-v1.png)

**Craft**

- `duration` is pre-formatted ("12:08", "1:04:22") and is what the user decides on.
- `title` is the episode or clip title; `sub` is the show, speaker, or series.
- Send `artworkUrl` when you have it — show art is how people recognise a podcast.
- `url` must point at something playable or a deep link into the app that plays it.
- For a briefing you generated, say when it was made in `sub` — audio ages badly.

**Avoid**

- Don't publish audio with no `url` and no `duration` — there's nothing to act on.
- Don't put a transcript in `sub`; if the words are the point, publish a `quote`.
- Don't send hour-long audio as a daily card without saying so in `duration`.
- Don't set `readable` on an audio pane. The card already carries sound; a speaker control that reads the title over it is the one place that flag actively makes things worse.

**Example — A new episode of a followed show**

```json
{
  "type": "audio",
  "publisher": "Overcast",
  "publisherInit": "OC",
  "publisherColor": "#FC7E0F",
  "label": "New episode",
  "data": {
    "title": "The company that ran on one server",
    "sub": "Founders Talking · Ep. 212",
    "duration": "58:04",
    "url": "https://overcast.fm/+example",
    "artworkUrl": "https://example.com/art/founders-talking.jpg"
  }
}
```

**Example — A generated morning briefing**

```json
{
  "type": "audio",
  "publisher": "Daily Brief",
  "publisherInit": "DB",
  "publisherColor": "#15130F",
  "label": "Ready for your commute",
  "data": {
    "title": "Your Tuesday briefing",
    "sub": "Generated 6:00am · 4 stories, 2 calendar conflicts",
    "duration": "6:12",
    "url": "https://example.com/briefings/2026-08-04.mp3"
  }
}
```

## `map`

Getting from A to B, right now — a commute warning, a leg of a trip, a leave-by nudge.

![map map-v1](https://getpanes.com/docs/images/map-v1.png)

**Craft**

- `title` should state the situation, not the route: "Leave by 8:05 to make the 8:40" beats "Home → Office".
- `minutes` is an integer count of travel minutes — the card formats it. Don't send "32 min".
- `from`/`to` are short place labels ("Home", "Office", "SYD T2"), not full addresses.
- `sub` is where the delay or reason goes: "12 min slower than usual · crash on the Bridge".
- This is the most time-sensitive card in the set — always set `expiresAt`, usually within the hour.

**Avoid**

- Don't publish a route card with no time signal (`minutes` or a time in `title`) — it's the whole content.
- Don't send full street addresses; they wrap and the card loses its shape.
- Don't use `map` for a place recommendation — that's an `image` or `list`.

**Example — A commute that's worse than usual**

```json
{
  "type": "map",
  "publisher": "Apple Maps",
  "publisherInit": "AM",
  "publisherColor": "#1A4FCC",
  "label": "Leave soon",
  "data": {
    "title": "Leave by 8:05 to make your 9:00",
    "sub": "12 min slower than usual · crash on the Harbour Bridge",
    "from": "Home",
    "to": "Office",
    "minutes": 47
  }
}
```

**Example — The next leg of a trip already in progress**

```json
{
  "type": "map",
  "publisher": "Trip Assistant",
  "publisherInit": "TA",
  "publisherColor": "#00594F",
  "label": "Next leg",
  "data": {
    "title": "Train to the airport departs 14:20",
    "sub": "Platform 3 · 8 min walk from your hotel",
    "from": "Central",
    "to": "SYD T1",
    "minutes": 52
  }
}
```

## `poll`

Results of a vote or survey — how a group actually answered. This card reports results; it doesn't collect them.

![poll poll-v1](https://getpanes.com/docs/images/poll-v1.png)

**Craft**

- `pct` values are percentages (0–100), not fractions. `41.2` means 41.2%.
- They should add up to ~100 (rounding is fine). The server won't enforce it, but a card summing to 60 looks broken.
- 2–4 options read best. Eight bars on a phone is a chart, not a card.
- Option `label`s are short and parallel — "Ship it", "Wait a week", "Cut scope".
- Include `n`; a percentage without a sample size invites the wrong conclusion.
- Order options by `pct` descending unless the options have a natural order (agree → disagree).

**Avoid**

- Don't send a poll the user is meant to answer — there's no interaction; the card shows results only.
- Don't send fractions (`0.41`) — they render as 0.41%.
- Don't include a "no response" bucket unless it's genuinely interesting.

**Example — A team decision that just closed**

```json
{
  "type": "poll",
  "publisher": "Slack",
  "publisherInit": "SL",
  "publisherColor": "#4A154B",
  "label": "#eng-decisions · closed",
  "data": {
    "question": "Ship the migration this Friday, or hold for the next window?",
    "opts": [
      {
        "label": "Ship Friday",
        "pct": 58
      },
      {
        "label": "Hold two weeks",
        "pct": 33
      },
      {
        "label": "No preference",
        "pct": 9
      }
    ],
    "n": 24
  }
}
```

**Example — A published survey result worth knowing**

```json
{
  "type": "poll",
  "publisher": "State of JS",
  "publisherInit": "JS",
  "publisherColor": "#F2C94C",
  "label": "2026 survey",
  "data": {
    "question": "Which runtime do you reach for on a new project?",
    "opts": [
      {
        "label": "Node",
        "pct": 46
      },
      {
        "label": "Bun",
        "pct": 31
      },
      {
        "label": "Deno",
        "pct": 17
      },
      {
        "label": "Something else",
        "pct": 6
      }
    ],
    "n": 18420
  }
}
```

## `quote`

Words worth reading slowly: a book highlight, a line from an interview, a passage someone saved.

![quote quote-v2](https://getpanes.com/docs/images/quote-v2.png) ![quote quote-v4](https://getpanes.com/docs/images/quote-v4.png)

**Craft**

- This is the one card built for prose — 15–50 words is the sweet spot. Under 10 looks thin in the display treatment.
- Pick the variant by weight: `"sidebar-rule"` for a highlight from a longer work, `"statement"` for a single line that stands alone.
- Attribute it. `author` alone is fine when the source is obvious; add `source` when the work is the interesting part.
- No quotation marks in `text` — the treatment supplies them.
- Trim to the sentence that carries the idea. A paragraph in a quote card is a paragraph nobody reads.
- This is the type `readable` was made for — set it when the passage is meant to be heard as much as read.

**Avoid**

- Don't put your own writing here and attribute it to the user — `quote` implies someone said this.
- Don't use it as a generic text card for notes or summaries; a `list` or `news` fits those better.
- Don't include a trailing citation inside `text` ("— Gaiman, 2012") when `author`/`source` already carry it.

**Example — A saved highlight from a book or article**

```json
{
  "type": "quote",
  "publisher": "Readwise",
  "publisherInit": "R",
  "publisherColor": "#E94B3C",
  "label": "Daily highlight",
  "data": {
    "variant": "sidebar-rule",
    "text": "Make new mistakes. Make glorious, amazing mistakes. Make mistakes nobody's ever made before.",
    "author": "Neil Gaiman",
    "source": "Make Good Art"
  }
}
```

**Example — A single line that should land on its own, with no container**

```json
{
  "type": "quote",
  "publisher": "Interview Club",
  "publisherInit": "IC",
  "publisherColor": "#15130F",
  "label": "From this week's issue",
  "data": {
    "variant": "statement",
    "text": "We stopped asking what the user wants and started watching what they do at 4pm on a Friday.",
    "author": "Marta Reyes",
    "source": "Head of Product, Linear"
  }
}
```

## `code`

Something happened in a repo: a commit landed, a build went red, a deploy shipped.

![code code-v1](https://getpanes.com/docs/images/code-v1.png)

**Craft**

- `repo` is the short identifier ("mypane/web"), not a URL.
- `commit` is the subject line only — the first line of the message, no body, no trailing period.
- `files` is a pre-formatted diff stat: "+342 −18". Use the real minus sign the design uses (−), not a hyphen.
- `log` is where a failure explains itself: the failing test name, the error line — the part someone needs, not the whole tail.
- `author` is the handle as the forge shows it (`@ben`, `dependabot[bot]`).
- `url` should deep-link to the commit, run, or deployment being described.

**Avoid**

- Don't publish every commit — this card is for things worth interrupting someone about.
- Don't paste a stack trace into `log`; 400 characters of traceback is unreadable on a phone.
- Don't use `code` to share a snippet — there's no syntax-highlighted body field here.

**Example — A deploy that succeeded**

```json
{
  "type": "code",
  "publisher": "GitHub Actions",
  "publisherInit": "GH",
  "publisherColor": "#15130F",
  "label": "Deployed to production",
  "data": {
    "repo": "mypane/web",
    "commit": "oauth: welcome pane on new grant + whoami guidance",
    "author": "@ben",
    "files": "+342 −18",
    "log": "build 2m 14s · 148 tests passed · deployed to getpanes.com",
    "url": "https://github.com/mypane/web/actions/runs/example"
  }
}
```

**Example — A build that went red and needs someone**

```json
{
  "type": "code",
  "publisher": "GitHub Actions",
  "publisherInit": "GH",
  "publisherColor": "#C8341A",
  "label": "main is broken",
  "data": {
    "repo": "mypane/web",
    "commit": "feed: paginate reads with an opaque cursor",
    "author": "@sam",
    "files": "+96 −41",
    "log": "FAIL src/lib/feed/service.test.ts · listFeed applies the reader cap → expected 0 items, got 20",
    "url": "https://github.com/mypane/web/actions/runs/example"
  }
}
```

## `list`

A handful of short parallel items — a roll-up of numbers, today's agenda, a set of tasks — or a live view of a stored list the user keeps on the server.

![list list-v1](https://getpanes.com/docs/images/list-v1.png) ![list list-v2](https://getpanes.com/docs/images/list-v2.png) ![list list-v3](https://getpanes.com/docs/images/list-v3.png) ![list list-v4](https://getpanes.com/docs/images/list-v4.png)

**Craft**

- Decide first whether this is a SNAPSHOT or a LIVE list. Send `items` for something true at this moment that nobody will edit (yesterday's numbers, today's agenda). Send `listId` for something the user keeps and changes — shopping, to-do, packing.
- Never publish a stored list's contents as `items`. The card would be a photocopy: the user ticks something off in the app and the pane keeps showing the old state. Send the `listId` and let the server resolve it on every read.
- `listId` and `items` are mutually exclusive. To put a list you're composing into the feed, create the stored list first (REST `/api/v1/lists` or `create_list`), then publish its id.
- On a `listId` pane, `label` defaults to the list's name — omit it unless you have something the name doesn't say ("Before Friday", "From your recipe").
- 3–6 items is the range that reads well. The schema allows 30; 30 is a document, not a card.
- Keep each item to one line on a phone — about 40 characters. Items are set at 16pt display, so they run out of room a long way before the schema's 256.
- Drop the empty buckets. "0 enterprise" in a roll-up spends a line saying nothing; leave it out until there's one.
- Write items in parallel grammar: all fragments or all sentences, all starting with the same part of speech.
- Pick the variant by content: `"checklist"` for things with a done state, `"uniform-bullets"` for a roll-up of facts, `"display-drop-caps"` for a short editorial list, `"hanging-mono"` when items are code, IDs, or figures. On a `listId` pane the variant is yours to choose and defaults to `"checklist"` — the stored list itself has no opinion about how it is drawn.
- The `"checklist"` variant takes objects (`{ text, done }`); every other variant takes plain strings. Mixing the two is the most common validation failure on this type.
- Front-load the distinguishing word — the eye scans the first two words of each line.
- Avoid `expiresAt` on a live list card. It's allowed, but a card that clears itself while the list it shows is still in use is a card the user has to ask for again.

**Avoid**

- Don't use a list to hold one item, and don't pad to a round number.
- Don't restate the card's subject in every item ("Revenue: …", "Revenue growth: …") — put the subject in `label`.
- Don't nest or use manual bullet characters/numbers in item text; the treatment adds the marks.
- Don't publish a second `listId` pane for a list that already has one — publishing is an upsert, and republishing updates the existing card in place rather than stacking a duplicate.

**How it renders**

- Every variant sets items at 16pt display — roughly 40 characters per line on a phone-width card, then it wraps.
- The mark (bullet, drop cap, numeral, checkbox) is drawn by the treatment in a fixed left gutter; your text starts after it.
- There is no title row inside the card — the envelope's `label` is the list's only heading. On a live list card that heading is the list's NAME, filled in for you when you omit `label`.
- A `listId` pane is resolved on every read: `data` comes back as `{ variant, items }` — the shape the client already draws — plus a `list` block carrying the list's id, name, version, counts and `updatedAt`, and a `listItems` array giving each item's id so a client can let the user tick one off.
- The `list` block arrives with the first Panes iOS release that renders live list cards. Older builds ignore the key and draw the resolved `items`, which are correct either way — so publishing a `listId` pane is safe before every client has updated.

**Example — A live card for a list the user keeps and edits**

```json
{
  "type": "list",
  "publisher": "Panes",
  "publisherInit": "P",
  "publisherColor": "#FF5A1F",
  "data": {
    "listId": "lst_01JBQ8Z4X7MKT2F3WQ9N6RVD5H",
    "variant": "checklist"
  }
}
```

**Example — A roll-up of the numbers that don't each deserve a card**

```json
{
  "type": "list",
  "publisher": "Panes Ops",
  "publisherInit": "P",
  "publisherColor": "#FF5A1F",
  "label": "Yesterday at a glance",
  "data": {
    "variant": "uniform-bullets",
    "items": [
      "431 users · 88 publishers · 4 enterprise",
      "1,204 panes sitting unread in feeds",
      "37 API tokens issued this week",
      "Most-published types: kpi · news · price"
    ]
  }
}
```

**Example — Tasks with a done state, snapshotted from a task manager**

```json
{
  "type": "list",
  "publisher": "Linear",
  "publisherInit": "L",
  "publisherColor": "#5E6AD2",
  "label": "Due today",
  "data": {
    "variant": "checklist",
    "items": [
      {
        "text": "Ship the OAuth consent copy fix",
        "done": true
      },
      {
        "text": "Review the reader-cap migration",
        "done": true
      },
      {
        "text": "Draft the pricing page rewrite",
        "done": false
      },
      {
        "text": "Reply to the App Store reviewer",
        "done": false
      }
    ]
  }
}
```

**Example — A short editorial list where each item is a phrase, not a fact**

```json
{
  "type": "list",
  "publisher": "Field Notes",
  "publisherInit": "FN",
  "publisherColor": "#1A4FCC",
  "label": "Three things worth knowing",
  "data": {
    "variant": "display-drop-caps",
    "items": [
      "Interest rates held for the fourth meeting running",
      "Two of your holdings report earnings this week",
      "The AUD closed above 68c for the first time since March"
    ]
  }
}
```

## `weather`

Conditions where the user is, now and for the next few hours.

![weather weather-v1](https://getpanes.com/docs/images/weather-v1.png) ![weather weather-v2](https://getpanes.com/docs/images/weather-v2.png) ![weather weather-v3](https://getpanes.com/docs/images/weather-v3.png)

**Craft**

- Pick the variant by what you have: `"hero"` for one big temperature, `"data-brick"` when UV/wind/rain are the story, `"hourly"` when the next few hours matter.
- `temp` is a number and `tempUnit` is the symbol ("°C", "°F") — send both, or the card can't say which scale it means.
- `condition` is two or three words ("Partly cloudy"); `description` is the one-line human read ("Cooler than yesterday, showers clearing by 4pm").
- For the `"hourly"` variant send 6–12 entries and mark the current one with `isCurrent: true`.
- `uvLabel` translates the number for people who don't read UV indices: 8 → "Very high".
- `wind` is pre-formatted with direction and unit ("18 km/h SSE").
- Weather expires faster than anything else here — set `expiresAt` a few hours out.

**Avoid**

- Don't send a 7-day forecast — this card is about now; a week of data doesn't fit any variant.
- Don't send `temp` as a string ("26°C"); the number and the unit are separate fields.
- Don't use the `hourly` variant with two entries — it's built for a run of hours.

**Example — The morning look-out-the-window card**

```json
{
  "type": "weather",
  "publisher": "Weather",
  "publisherInit": "W",
  "publisherColor": "#1A4FCC",
  "label": "Sydney · now",
  "data": {
    "variant": "hero",
    "temp": 26,
    "tempUnit": "°C",
    "tempLabel": "Feels like 28°",
    "condition": "Partly cloudy",
    "description": "Warm and humid, showers clearing by mid-afternoon.",
    "rainChancePct": 30,
    "uvIndex": 8,
    "uvLabel": "Very high",
    "wind": "18 km/h SSE"
  }
}
```

**Example — A heads-up where UV and wind are the actionable part**

```json
{
  "type": "weather",
  "publisher": "Weather",
  "publisherInit": "W",
  "publisherColor": "#F2C94C",
  "label": "Before you head out",
  "data": {
    "variant": "data-brick",
    "temp": 31,
    "tempUnit": "°C",
    "condition": "Sunny",
    "description": "UV peaks between 11am and 2pm — hat weather.",
    "rainChancePct": 5,
    "uvIndex": 11,
    "uvLabel": "Extreme",
    "wind": "9 km/h NE"
  }
}
```

**Example — The next few hours, for someone deciding when to go**

```json
{
  "type": "weather",
  "publisher": "Weather",
  "publisherInit": "W",
  "publisherColor": "#5C544A",
  "label": "Next 6 hours",
  "data": {
    "variant": "hourly",
    "temp": 19,
    "tempUnit": "°C",
    "condition": "Showers easing",
    "rainChancePct": 65,
    "wind": "24 km/h SW",
    "hourly": [
      {
        "time": "1pm",
        "temp": 19,
        "icon": "rain",
        "isCurrent": true
      },
      {
        "time": "2pm",
        "temp": 19,
        "icon": "rain"
      },
      {
        "time": "3pm",
        "temp": 20,
        "icon": "showers"
      },
      {
        "time": "4pm",
        "temp": 21,
        "icon": "partly-cloudy"
      },
      {
        "time": "5pm",
        "temp": 20,
        "icon": "partly-cloudy"
      },
      {
        "time": "6pm",
        "temp": 18,
        "icon": "clear"
      }
    ]
  }
}
```

## `countdown`

Something happens at a known moment and the reader wants to know how long until it does: a launch, a race, a deadline, a flight.

**Craft**

- `event` is the subject, 1–4 words: "Melbourne Cup", "v2.0 launch", "Tax return due". It sets at display size — past about 28 characters it wraps.
- `target` is an ISO-8601 instant WITH its offset: "2026-11-03T15:00:00+11:00" or "2026-11-03T04:00:00Z". A datetime with no offset is rejected — "18:00" means nothing until you say where.
- Send `timezone` whenever the event happens somewhere in particular — the IANA id ("Australia/Melbourne"), not an abbreviation ("AEST"). It's how the card can say "3:00 pm Melbourne time" and stay right across a daylight-saving change.
- Write `target` in the event's own local offset if you can. The server stores the UTC instant and keeps your offset alongside, so either works — but the local form is the one a human can check.
- `sub` is one line of context under the event name, about 40 characters: "Flemington · Race 7", "Doors 7pm, headliner 9:30".
- Set `expiresAt` to `target` plus a short grace window — an hour for a race, a day for a deadline. A countdown that has already fired is clutter. The server doesn't enforce this; the feed just looks better when you do.
- Once the target passes, what the card shows is the client's business — don't republish to flip it to "happening now".

**Avoid**

- Don't send a naive datetime ("2026-10-04T18:00:00") and hope it's read as UTC — it's rejected.
- Don't send an abbreviation or fixed-offset zone as `timezone` ("AEST", "EST", "Etc/GMT-10") — they're ambiguous and don't follow DST. Use "Australia/Melbourne".
- Don't pre-compute the remaining time ("3 days") into `event` or `sub` — the card counts down live, so a baked-in figure is wrong a minute later.
- Don't publish a countdown to something already past, or years away. The first is a dead card; the second is a number too big to feel.
- Don't format the target date into `sub` for the reader — the client formats it in their locale, and in `timezone` when you send one.

**How it renders**

- `event` is the card's headline, set at display size. It's the subject line — the remaining time is drawn from `target`, not from anything you write.
- The remaining time is computed on the reader's device from `target` (returned as UTC). The client corrects for device clock skew against the server's `Date` response header, so the count is honest even when the phone's clock isn't.
- When `timezone` is present, the card can also print the event's local wall-clock time in that zone — the one thing an offset alone can't do once daylight saving moves.
- `sub` sits on one line under `event`; roughly 40 characters before it wraps.
- What the card shows after `target` passes is decided by the client, not the server.

**Example — A sporting event that happens somewhere specific**

```json
{
  "type": "countdown",
  "publisher": "Racing.com",
  "publisherInit": "R",
  "publisherColor": "#0B3D2E",
  "label": "Race day",
  "data": {
    "event": "Melbourne Cup",
    "target": "2026-11-03T15:00:00+11:00",
    "timezone": "Australia/Melbourne",
    "sub": "Flemington · Race 7"
  }
}
```

**Example — A product launch the team is working towards**

```json
{
  "type": "countdown",
  "publisher": "Linear",
  "publisherInit": "L",
  "publisherColor": "#5E6AD2",
  "label": "Launch",
  "data": {
    "event": "v2.0 goes live",
    "target": "2026-10-14T16:00:00Z",
    "sub": "Freeze at noon, go/no-go at 3pm"
  }
}
```

**Example — A deadline in the reader's own city**

```json
{
  "type": "countdown",
  "publisher": "Accountant",
  "publisherInit": "A",
  "publisherColor": "#1A4FCC",
  "label": "Deadline",
  "data": {
    "event": "Tax return due",
    "target": "2026-10-31T23:59:00+11:00",
    "timezone": "Australia/Sydney"
  }
}
```

## `html`

Last resort. Reach for `html` only when no structured type can carry the content — a bespoke visual, an unusual layout, a table that isn't a list. A structured pane inherits the design system for free; an `html` pane is on its own, and a mediocre one looks foreign in a feed of well-made cards.

**Craft**

- Match the design system or the card will look like an ad. Daylight palette: paper #F4EEE2, card #FFFFFF, ink #15130F, secondary #5C544A, accent #FF5A1F, positive #1F7A3A, negative #C8341A, hairlines ink at 10% opacity.
- Use system fonts (`-apple-system, system-ui, sans-serif`) — web fonts won't load. Match the app's rhythm: one display-size number or heading, everything else small.
- Size everything in relative units (%, rem, ch, fr, clamp()). Fixed pixel widths break on narrow phones — the pane fills the device's available space.
- Handle both themes: default to light, then override under `@media (prefers-color-scheme: dark)`. A white card on a dark feed is glaring.
- Style with the `css` field, not inline `style=` attributes — it's the field the renderer places in a <style> block, and it keeps the markup readable.
- Assets must be public HTTPS URLs or inlined `data:` URIs. Prefer inline SVG for anything graphical: it scales, needs no network, and inherits color.
- Keep it to one screen. `html` panes get the device's available space, not a scroll view.
- No JavaScript, ever: `<script>` and `on*` handlers do not execute. Everything must be static. If you need interaction, model it as a structured pane type instead.
- Set `responsive: false` only when the content has a fixed natural size (a small diagram); the default `true` is almost always right.

**Avoid**

- Don't rebuild a card a structured type already gives you — an `html` KPI is strictly worse than a `kpi`.
- Don't ship a full page: no <html>/<head>/<body> wrapper, no CSS reset, no framework classes (Tailwind/Bootstrap aren't loaded).
- Don't rely on JavaScript for layout or content — anything script-generated renders empty.
- Don't use fixed `px` widths, absolute positioning, or viewport units that assume a desktop canvas.
- Don't put walls of text in an `html` pane because it doesn't fit elsewhere — split it into several structured panes.

**Example — A comparison table — genuinely tabular, so no structured type fits**

```json
{
  "type": "html",
  "publisher": "Cost Watch",
  "publisherInit": "CW",
  "publisherColor": "#FF5A1F",
  "label": "Cloud spend · July",
  "data": {
    "html": "<div class=\"wrap\">\n  <p class=\"kicker\">Month to date</p>\n  <p class=\"total\">$8,412</p>\n  <table>\n    <tr><td>Compute</td><td class=\"n\">$4,180</td><td class=\"up\">+12%</td></tr>\n    <tr><td>Storage</td><td class=\"n\">$2,640</td><td class=\"flat\">±0%</td></tr>\n    <tr><td>Egress</td><td class=\"n\">$1,592</td><td class=\"down\">−8%</td></tr>\n  </table>\n</div>",
    "css": ".wrap { font-family: -apple-system, system-ui, sans-serif; color: #15130F; padding: 1.25rem; }\n.kicker { margin: 0; font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; color: #9A8F7E; }\n.total { margin: .25rem 0 1rem; font-size: clamp(2rem, 12vw, 3rem); font-weight: 700; letter-spacing: -.02em; }\ntable { width: 100%; border-collapse: collapse; font-size: .9rem; }\ntd { padding: .5rem 0; border-bottom: 1px solid rgba(21,19,15,.1); }\ntd.n { text-align: right; font-variant-numeric: tabular-nums; }\ntd.up, td.down, td.flat { text-align: right; width: 4ch; font-size: .8rem; }\n.up { color: #C8341A; } .down { color: #1F7A3A; } .flat { color: #9A8F7E; }\n@media (prefers-color-scheme: dark) {\n  .wrap { color: #F4EEE2; }\n  td { border-bottom-color: rgba(244,238,226,.14); }\n}",
    "responsive": true
  }
}
```

**Example — A small custom visual, drawn as inline SVG so it scales and needs no network**

```json
{
  "type": "html",
  "publisher": "Habit",
  "publisherInit": "H",
  "publisherColor": "#1F7A3A",
  "label": "12-week streak",
  "data": {
    "html": "<div class=\"wrap\">\n  <p class=\"kicker\">Running · 12 weeks</p>\n  <svg viewBox=\"0 0 84 8\" preserveAspectRatio=\"none\" role=\"img\" aria-label=\"Twelve weeks, ten completed\">\n    <g fill=\"currentColor\">\n      <rect x=\"0\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/><rect x=\"7\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/>\n      <rect x=\"14\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/><rect x=\"21\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\" opacity=\".2\"/>\n      <rect x=\"28\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/><rect x=\"35\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/>\n      <rect x=\"42\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/><rect x=\"49\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/>\n      <rect x=\"56\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\" opacity=\".2\"/><rect x=\"63\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/>\n      <rect x=\"70\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/><rect x=\"77\" y=\"0\" width=\"6\" height=\"8\" rx=\"1\"/>\n    </g>\n  </svg>\n  <p class=\"note\">10 of 12 weeks · longest run 7</p>\n</div>",
    "css": ".wrap { font-family: -apple-system, system-ui, sans-serif; color: #15130F; padding: 1.25rem; }\n.kicker { margin: 0 0 .75rem; font-size: .75rem; letter-spacing: .08em; text-transform: uppercase; color: #9A8F7E; }\nsvg { width: 100%; height: 2.5rem; color: #1F7A3A; display: block; }\n.note { margin: .75rem 0 0; font-size: .85rem; color: #5C544A; }\n@media (prefers-color-scheme: dark) {\n  .wrap { color: #F4EEE2; }\n  .note { color: #9A8F7E; }\n}",
    "responsive": true
  }
}
```

## Stored lists

Separately from panes, a user keeps named text lists on the server — shopping, to-do, packing, books to read. **The server is the only source of truth for what is on them.** The Panes app edits them, agents edit them over MCP (`list_lists`, `get_list`, `create_list`, `add_list_items`, `update_list_item`, `remove_list_items`, `clear_done_items`, `rename_list`, `delete_list`, `publish_list_pane`) and anything can edit them over REST at `/api/v1/lists`.

### Never hold the list in your own context

Do not remember what you added. Do not rebuild a list from earlier turns. Do not tell the user what is on a list from memory.

The user edits these on their phone between your calls, and another agent may be editing them at the same time — so a copy you made three messages ago is a guess, and a confident wrong answer about someone's shopping list is worse than no answer. Every list tool returns the **whole list** after acting, precisely so you never have to: the call you just made is your source of truth, and `get_list` is cheap.

### Finding the list the user named

People say "my shopping list", "the packing one", "my to-do". Resolve that against what exists:

1. Call `list_lists`. It returns every list with its name, item count and when it last changed.
2. Match the user's words against those names. `get_list` and every mutating tool also accept `name` directly and resolve it server-side — exact case-insensitive first, then a unique prefix, then a unique substring.
3. Create a list **only when nothing plausibly matches**. A second "Shopping" is the exact failure this resource exists to prevent, which is why `create_list` hands back the existing list (with `alreadyExisted: true`) instead of making one.

If a name is ambiguous — two packing lists — the server changes nothing and returns the candidates. Ask the user which they meant rather than picking.

### Snapshot or live?

The `list` PANE type can be either, and choosing wrong is the most common mistake here.

- **Snapshot** (`data.items`): something true at this moment that nobody will edit — yesterday's numbers, today's agenda, a roll-up from another system. Publish and forget.
- **Live** (`data.listId`): a stored list the user keeps and changes. The server resolves the reference on every read, so the card is always current; nobody has to republish when the list moves.

**Never snapshot a stored list into `items`.** That card is a photocopy: the user ticks something off in the app and the pane keeps showing the old state. The two forms are mutually exclusive in one payload — to put a list you are composing into the feed, create the stored list first and then publish its `listId`.

A live card is an **upsert**: one card per list. `publish_list_pane` called again for the same list updates that card in place rather than stacking a second. Completing or deleting the card removes the card only — the list survives.

On read, a `listId` pane comes back with `data` in the shape every client already draws — `{ variant, items }` — plus a `list` block: `{ id, name, version, updatedAt, itemCount, doneCount }`, and a `listItems` array carrying each item's id for a client that lets the user tick things off. The `list` block arrives with the first Panes iOS release that renders live list cards; older builds ignore the key and draw the resolved `items`, which are correct either way. `variant` is the publisher's choice and defaults to `checklist` — the stored list has no opinion about how it is drawn.

### Naming an item the user described

Pass `match` — the words the user actually said — instead of guessing an id. The server resolves it in tiers: exact (case-insensitive, whitespace-collapsed), then unique prefix, then unique substring. The first tier that produces exactly one candidate wins.

If a tier produces several, **nothing is changed** and the candidates come back. Ask which one. A half-right edit to someone's list is worse than no edit, because they have no reason to go and check.

Adding is safe to repeat: text already on the list is skipped rather than duplicated, so re-sending an item you are unsure about costs nothing. Ticking an already-ticked item is a success, and removing an already-removed one is too.

### Concurrency

Every list carries a `version`, bumped on every write. When your change depends on what you just read, pass `expectedVersion`; if the list moved underneath you the call is refused and the **current** list comes back, so you re-apply against reality instead of clobbering an edit you never saw. Act on the list the server returned, not on what you think you added.

### Worked examples

**"Add milk and eggs to the shopping list"**

```
list_lists()                                   → finds "Shopping" (lst_01J…)
add_list_items({ listId: "lst_01J…",
                 items: ["milk", "eggs"] })     → returns the whole list
```

Split the phrasing yourself: `items` is an array, and "milk, eggs and bread" is three elements, never one comma-joined string. If "milk" was already there it is skipped and the call still succeeds — report what the returned list actually contains, not what you sent.

**"Tick off the dentist on my to-do"**

```
update_list_item({ name: "to-do",
                   match: "dentist",
                   done: true })                → returns the whole list
```

One call. Don't fetch the list first to hunt for an id, and don't invent one — `name` and `match` are both resolved server-side. If `match` is ambiguous you get the candidates back and nothing is changed; ask the user which they meant and call again with that `itemId`.

**"What's left on the packing list?"**

```
get_list({ name: "packing" })                  → items with done flags
```

Read it now, every time, even if you put those items there yourself an hour ago. Answer from the items where `done` is false. If "packing" matches two lists, the candidates come back — ask.
