# The plugin manifest

A Peek plugin is a small app described in a JSON file, the manifest. It says three things: where to read from, what to draw and which buttons to offer. Anyone can write one, keep it under version control, share it and import it.

This page describes every key of the manifest, as the app reads it today. To build a plugin without writing JSON, see the [Plugins chapter of the Manual](/manual#plugins): the Peek editor writes this same file.

<!-- ia -->

## Principles

**A module and a plugin are different things.** A module comes with the app and reads what the app can reach: Mac data, tasks, links, feeds. A plugin reads an address, a program or a script, and is described in a manifest.

**Declarative structure, logic in scripts.** Sources, icon, card and buttons are declared in the JSON. Logic that doesn't fit there comes from a program that returns JSON, or from a script in AppleScript or JavaScript for Automation (JXA).

**An unknown key is ignored.** So is an unknown value: it falls back to the default. The exception is a component's `type`, which must be on the list of components. That is why `schema` exists: it keeps an older app from installing half a plugin.

**Secrets stay out of the file.** Tokens and passwords live in the Keychain or in the app's secrets file, never in the manifest. A plugin can travel without carrying anyone's token.

**Visible safety.** Before installing, the app shows everything the plugin reads and runs, derived from the keys it uses. There is no permissions block in the file.

## A minimal plugin

```json
{
  "schema": 2,
  "id": "plugin.exemplo.dolar",
  "name": "Dollar",
  "interval": 600,
  "icon": { "kind": "symbol", "symbolName": "dollarsign.circle" },
  "source": {
    "kind": "http",
    "url": "https://economia.awesomeapi.com.br/json/last/USD-BRL"
  },
  "slot": { "label": "{{USDBRL.bid | shape #,##0.00}}" },
  "card": {
    "title": "Dollar",
    "components": [
      { "type": "hero", "value": "{{USDBRL.bid}}", "caption": "Buy", "format": "money", "currency": "BRL" }
    ]
  }
}
```

Only `id` is required. Without a ready source, the plugin is added and asks you to finish setting it up in Settings › Plugins.

## The root

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `schema` | number | `1` | The format version. See [The version](#the-version-schema). |
| `id` | text | required | A unique identifier. The editor generates `plugin.` followed by a UUID. |
| `name` | text | `""` | The plugin's name. Empty becomes “Plugin”. |
| `icon` | object | symbol `puzzlepiece.extension` | What shows inside the icon. |
| `interval` | number, in seconds | `300` | How often to read. |
| `source` | object | `{ "kind": "http" }` | Where to read from. |
| `slot` | object | empty | The label and the arc around the icon. |
| `card` | object | empty card | The card that opens when the pointer rests on the icon. |
| `actions` | list | `[]` | The buttons. |
| `attention` | object | `{ "mode": "never" }` | When the icon blinks and bounces. |
| `role` | `reading` \| `button` | `reading` | With `button`, the icon is the button: it doesn't read on its own, has no card, and a click runs the source. |
| `button` | object | defaults | How the icon button behaves. Only applies with `role: "button"`. |
| `settings` | list | `[]` | Fields the person installing fills in. |
| `history` | object | absent | Keeps one field from each reading, for charts and comparison. |
| `readsOnItsOwn` | boolean | `true` | With `false`, the plugin has no clock: it only reads when the card asks or the icon is clicked. |
| `answer` | text | `""` | A hand-written response, read with the source's `format`. With `source.kind: "panel"` it is the plugin's data. With a real source, it is the example the editor draws before the first reading. Up to 256 KB. |
| `parameters` | list | `[]` | The old form of `{{param.name}}` values. Prefer `settings`. |

## The version: schema

The app always writes the lowest `schema` that covers what the plugin uses, and on import it refuses a `schema` higher than the ones it knows, with the message “This plugin needs a newer version of Peek.” The current version of the app reads up to `schema` 12.

If you write by hand, declare the number of the highest row the plugin reaches:

| `schema` | When |
| --- | --- |
| `12` | A `list` or `tabs` component. |
| `11` | A `breakdown` component. |
| `10` | A `heatmap` component, or a `#RRGGBB` color in any color map. |
| `9` | A `controls` component, or a button with `place: "control"`. |
| `8` | A `properties` component, a component with `width: "half"`, a grid with `look: "tiles"`, or a button with `place: "property"`. |
| `7` | An `answer` at the root. |
| `6` | `needs` on a button, or the errands `addToList`, `dropFromList` and `readLater`. |
| `5` | `card.picture`, the `remember` errand, or `headline` together with components. |
| `4` | A table column that isn't text, or a button with `place: "column"`. |
| `3` | `source.kind: "panel"`, a `buttons` component, a button with `place: "panel"` or with `rules`, or `query` in any call. |
| `2` | `card.components`, `settings`, `pagination`, `history`, `slot.style` other than `arc`, a `run` as a list, or the errands `openApp`, `notify`, `playSound` and `runShortcut`. |
| `1` | None of the above. |

> [!TIP]
> When in doubt, declare the highest number in the table that applies. The app writes the right value back when the plugin is saved in the editor.

## The icon: icon

What shows inside the circle at the edge of the screen.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `kind` | `symbol` \| `word` \| `picture` | `symbol` | A symbol, a short word or an image. |
| `symbolName` | text | `puzzlepiece.extension` | The name of an SF Symbol. |
| `word` | text with fields | `""` | A word in place of the symbol, like “CPU”. Accepts fields: `{{[0].code}}`. Empty, it goes back to the symbol. |
| `pictureSource` | text with fields | `""` | An `https` address or a file path for an image. The app crops it to a circle and keeps a copy. |

- With fields in `pictureSource`, the image is fetched again when the address changes between readings.
- `pictureFilename` is generated by the app. Don't write it by hand.
- The old name of `word` was `text`, which is still read.

```json
"icon": { "kind": "word", "word": "{{USDBRL.code}}" }
```

## The source: source

The source says where the plugin reads from. There are four doors.

| `kind` | What it is |
| --- | --- |
| `http` | A call with method, parameters, headers and body. |
| `command` | One or more Mac programs, by absolute path, with no shell. |
| `script` | AppleScript or JXA. |
| `panel` | Reads nothing. The plugin is a panel of buttons, or it uses the hand-written `answer`. |

An unknown `kind` is read as `command`, so the plugin shows up asking for setup instead of disappearing.

### The request: http

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `method` | text | `GET` | `GET`, `POST`, `PUT`, `PATCH` or `DELETE`. |
| `url` | text with fields | `""` | The address. |
| `query` | list of `{ name, value }` | `[]` | Parameters added to the address. The app escapes each value, so spaces and accents don't break anything. |
| `headers` | list of `{ name, value }` | `[]` | Headers. The value accepts fields. |
| `body` | text with fields | `""` | The body. It is sent with any method when it isn't empty. |

```json
"source": {
  "kind": "http",
  "url": "https://api.github.com/repos/{{$settings.repo}}/issues",
  "query": [{ "name": "per_page", "value": "20" }],
  "headers": [{ "name": "Authorization", "value": "Bearer {{$settings.token}}" }]
}
```

- Only `https` addresses, or `http` for `localhost`, `127.0.0.1`, `::1` and names ending in `.local`.
- The app adds `Accept: application/json` when it is missing, and `Content-Type: application/json` when there is a body.
- A redirect to another server drops the plugin's headers, so the token doesn't leak.
- A value from `settings`, `$global` or typed by the user is escaped for an address in `url` and for JSON in `body`.
- Success is HTTP 2xx.

### Programs: command

```json
"source": {
  "kind": "command",
  "steps": [
    { "program": "/usr/bin/pmset", "arguments": ["-g", "batt"] },
    { "program": "/usr/bin/grep", "arguments": ["-o", "[0-9]*%"] }
  ],
  "format": { "kind": "text" }
}
```

| Key of each step | Type | Default | What it is |
| --- | --- | --- | --- |
| `program` | text with fields | `""` | The absolute path of an executable. |
| `arguments` | list of texts with fields | `[]` | One item per argument. No shell, no quotes, no wildcards. |
| `isEnabled` | boolean | `true` | A step that is turned off stays in the file and doesn't run. |

- The steps are chained: the output of one goes into the next. At most 8.
- Success is exit code 0 in every step.
- The old form, `command` with `arguments` directly in the source, is still read.

### Scripts: script

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `language` | `applescript` \| `javascript` | `applescript` | `javascript` is JXA. |
| `script` | text with fields | `""` | The script, handed to `osascript`. |

```json
"source": {
  "kind": "script",
  "language": "javascript",
  "script": "JSON.stringify({ naoLidos: Application('Mail').inbox.unreadCount() })"
}
```

The first time a script talks to another app, macOS asks for Automation permission.

### The response format: format

The response doesn't have to be JSON. `source.format` says how to read the bytes, and the result becomes the tree that fields read.

| `kind` | What it becomes |
| --- | --- |
| `json` | The JSON as it came. This is the default. |
| `text` | `{ "text": "…" }` with the whole output. |
| `lines` | `{ "lines": [{ "index": 0, "line": "…" }] }`, one per non-empty line. |
| `columns` | `{ "rows": [{ "c1": "…", "c2": "…" }] }`. With `hasHeader`, the first line gives the names. |
| `keyValue` | `{ "name": "value" }`, splitting each line at the first `:` or `=`. |
| `regex` | Named groups `(?<name>…)` become keys. With `all`, `{ "matches": [ … ] }`. |
| `plist` | A property list, as JSON. |
| `files` | `{ "files": [{ "path", "name", "ext", "exists", "isFolder", "size", "modified" }] }`, one row per path. |

Other `format` keys: `separator` (`whitespace`, `tab`, `comma`, `semicolon`) for `columns`, `pattern` for `regex`, and `skip`, the number of lines to skip at the top.

### Limits

| Door | Maximum time | Maximum size |
| --- | --- | --- |
| `http` | 15 s | 2 MB |
| `command` | 15 s | 2 MB |
| `script` | 15 s | 2 MB |

Each server gets at most two calls at the same time. A 429 response leaves that server alone for the `Retry-After` time, or for 60 s, doubling with each consecutive 429, up to 15 min.

## The interval

`interval` is in seconds, with a minimum of 5 for `http`, `command` and `script`. The editor offers 5, 10, 15 and 30 s, 1, 2, 5, 10, 15 and 30 min, and 1 h.

- On battery, the app stretches the wait. With the screen off, nothing is read.
- There is no clock with `role: "button"`, with `readsOnItsOwn: false` or with `source.kind: "panel"`.

## The edge: slot

What shows around the icon and below it.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `label` | text with fields | `""` | The short label below the icon. |
| `style` | `arc` \| `number` \| `status` \| `sparkline` | `arc` | What the ring draws. |
| `value` | number | `""` | `arc`: the arc's value. |
| `total` | number | `""` | `arc`: the total. Without a total, a `value` above 1 is read as a percentage. |
| `figure` | text with fields | `""` | `number`: the number in place of the icon. Empty uses `value`. |
| `status` | text with fields | `""` | `status`: the value that decides the color. |
| `statusColors` | map value → color | `{}` | `status`: the color for each value. |
| `sparkCount` | number | `24` | `sparkline`: how many saved readings to draw. Needs `history`. |
| `alertsOnThreshold` | boolean | `false` | Sends a notification when the arc passes 80% and 100%, once per crossing. |

A number field accepts `{{path}}`, a bare path (`data.total`) or a written number (`100`).

The colors are `positive`, `attention`, `negative`, `neutral` or `#RRGGBB`. Without an entry in the map, the word itself decides: `ok`, `up`, `success` and `green` are positive, `warn`, `warning` and `yellow` call for attention, `error`, `down`, `bad` and `red` are negative.

```json
"slot": {
  "style": "status",
  "label": "{{status.description}}",
  "status": "{{status.indicator}}",
  "statusColors": { "none": "positive", "minor": "attention", "major": "negative" }
}
```

> [!NOTE]
> Fields in `slot` see the response and `$settings`. `$global` and `param` are empty there.

## The card: card

The card opens when the pointer rests on the icon. It has a top and a stack of components.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `isEnabled` | boolean | `true` | With `false`, the icon opens no card. |
| `title` | text with fields | `""` | The title. Empty uses the plugin's `name`. |
| `subtitle` | text with fields | `""` | The smaller line below the title. |
| `headline` | text with fields | `""` | A number to the right of the title. |
| `picture` | image | none | The mark at the top of the card. Without it, the plugin's icon. |
| `emptyMessage` | text with fields | `""` | The sentence when the list is empty. Empty, “No items in the list.”. |
| `components` | list | `[]` | The components, drawn from top to bottom. |
| `rowLimit` | number | `5` | The default page size, when the `list` component shows every item. |

`metrics`, `tabs` and `shape` belong to the format from before components. The app turns a card like that into components when the plugin opens, and saves the plugin in the new format.

### An image: picture

`card.picture`, a tab's image, a list item's image and a button's mark all use the same object:

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `kind` | `none` \| `symbol` \| `address` \| `file` | `none` | Nothing, an SF Symbol, an address or a file. |
| `value` | text with fields | `""` | The symbol name, the address or the path. |
| `shape` | `rounded` \| `round` | `rounded` | Rounded corners or a circle. |
| `scale` | number | `1` | From 0.6 to 2.5. |

### Keys of every component

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `id` | UUID | generated | The component's identifier. A tab points at its body with it. |
| `type` | text | `hero` | The component type. An unknown `type` keeps the plugin from opening. |
| `title` | text with fields | `""` | A title above the component. Doesn't apply to `tabs`. |
| `width` | `full` \| `half` | `full` | Half width. Doesn't apply to `list`, `heatmap` and `tabs`. |

Two `half` components in a row share the same line. A `half` on its own takes half a line and leaves the other half empty.

When a component is missing something, only that component shows the problem, in its own place. The others keep working.

### Three ways to point at a value

Components read the response in three ways, and each key accepts one of them:

- **List path**: a plain path, without `{{ }}`, like `items`, `data.rows` or `$`. This is what `path` keys ask for. With `| entries` at the end, an object becomes `{ key, value }` rows: `"path": "languages | entries"`.
- **Text with fields**: words and `{{fields}}` mixed together, like `Closing from {{date | ago}}`. Inside a list, the field is relative to the row.
- **Number**: `{{field}}`, a bare path or a written number.

### Number formats

`hero`, `chart`, `stages` and table columns accept `format`:

| `format` | Example |
| --- | --- |
| `number` | `1,240.5`. This is the default, with up to two decimal places. |
| `integer` | `1,241` |
| `money` | `R$1,240.50`, with `currency` (`BRL`, `USD`…). |
| `compact` | `1.2K` |
| `percent` | `42%`. A value up to 1 is read as a fraction. |
| `bytes` | `1.2 GB` |
| `duration` | `5 h 12 min`, with `unit`: `seconds` (default), `minutes` or `hours`. |

Numbers written as text are read too: `"R$ 1.240,50"` becomes 1240.5.

## The components

There are fourteen. The editor shows all of them in **Add a component**, with a **Fits** tag on the ones that suit the response on screen.

| `type` | In the editor | What for |
| --- | --- | --- |
| `hero` | Highlight | An important number, its change and the distance to the goal. |
| `chart` | Trend | How a value moved over time. |
| `heatmap` | Heat map | Intensity by day and hour. |
| `stages` | Stages | A count per stage, in order. |
| `breakdown` | Breakdown | How the items in a list split into groups. |
| `grid` | Status grid | Many items at once, each one fine or not. |
| `timeline` | Timeline | What happened and what's coming, by date. |
| `table` | Table | Columns of short values. |
| `text` | Text | A paragraph to read, with a copy button. |
| `list` | List | Items of a list, each one built from texts, images, icons, buttons and indicators. |
| `buttons` | Buttons | A panel of buttons, each one running something. |
| `properties` | Properties | Name and value pairs, with buttons next to each one. |
| `controls` | Controls | Turn on, turn off and adjust right on the card. |
| `tabs` | Tabs | Tabs at the top; each tab shows one or more components of the card. |

### Highlight: hero

A large number, the change since the previous reading and, if there is a goal, a bar toward it.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `value` | number | required | The number. |
| `previous` | number | `""` | What to compare with. Empty uses the second-to-last reading saved in `history`. |
| `total` | number | `""` | The goal. Above zero, it draws the bar. |
| `caption` | text with fields | `""` | The line below the number. |
| `goodDirection` | `up` \| `down` | `up` | Which direction paints the change green. |
| `format`, `currency`, `unit` | | `number` | How to write the number and the goal. |

```json
{ "type": "hero", "value": "{{vendas.hoje}}", "total": "{{vendas.meta}}", "caption": "Today's sales", "format": "money", "currency": "BRL", "width": "half" }
```

### Trend: chart

A line, an area or bars over time.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `style` | `line` \| `area` \| `bar` | `line` | The drawing. |
| `path` | list path | `""` | The points. Empty draws the readings saved in `history`. |
| `x` | text with fields | `""` | The label or date of each point. |
| `y` | number | `""` | The value of each point. |
| `dateFormat` | text | `""` | How to read the date, in the `dd/MM/yyyy` pattern. Empty recognizes ISO 8601 and Unix. |
| `height` | `small` \| `medium` | `small` | Short or tall. |
| `format`, `currency`, `unit` | | `number` | How to write the values. |

- It needs at least two points. Without `path`, it shows “Collecting data” until it has three readings.
- When every `x` is a date, the points are sorted from oldest to newest.
- If the list is plain numbers, each item is the value, and `x` can point to another list with the labels.

```json
{ "type": "chart", "style": "area", "path": "$", "x": "{{timestamp}}", "y": "{{bid}}", "height": "medium", "format": "money", "currency": "BRL" }
```

### Heat map: heatmap

Cells colored by the sum of the values. Always full width.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `heatStyle` | `grid` \| `compact` \| `calendar` | `grid` | Week × hour, a single strip, or a month. |
| `path` | list path | required | The items. |
| `value` | number | `""` | The value of each item. Items without a number are skipped. |
| `heatRow`, `heatColumn` | text with fields | `""` | Which row and column each item falls in. |
| `heatRows`, `heatColumns` | list of texts | `[]` | The names of the rows and columns, in order. Each item's value must match one of them. |
| `date` | text with fields | `""` | `calendar`: the day of each item. The month drawn is the one of the most recent date. |
| `dateFormat` | text | `""` | How to read the date. |
| `heatColor` | color | `blue` | The color of the cells. |
| `scale` | `automatic` \| `fixed` | `automatic` | Scale by the lowest and highest values, or by the limits below. |
| `scaleMin`, `scaleMax` | number | `0`, `100` | The limits of the fixed scale. |
| `legend` | boolean | `true` | Shows the legend. |
| `heatUnit` | text | `""` | The unit in each cell's tooltip, like “visits”. |

Component colors are `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `indigo`, `purple`, `pink`, `gray` or `#RRGGBB`.

```json
{ "type": "heatmap", "heatStyle": "calendar", "path": "downloads", "date": "{{day}}", "value": "{{downloads}}", "heatColor": "green", "heatUnit": "downloads" }
```

### Stages: stages

A count per stage, in the order of the response, with a bar proportional to the largest. It also works as a ranking: the response just needs to come sorted.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `path` | list path | required | The stages, already in order. |
| `label` | text with fields | `""` | The name of the stage. |
| `value` | number | required | The count. |
| `limit` | number | absent | How many stages to show. Without it, all of them. |
| `format`, `currency`, `unit` | | `number` | How to write the values. |
| `filters` | `{ list, field }` | absent | Clicking a stage filters the `list` component at position `list` (counting only lists) to the items whose `field` equals the stage's name. |

```json
{ "type": "stages", "path": "etapas", "label": "{{nome}}", "value": "{{total}}", "filters": { "list": 0, "field": "{{etapa}}" } }
```

### Breakdown: breakdown

Splits the items of a list into groups you define and shows how much each group has.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `breakdownStyle` | `bar` \| `funnel` \| `list` | `bar` | A split bar, a funnel with the passage between groups, or a list. |
| `path` | list path | required | The items. |
| `groupField` | text with fields | required | The field that decides the group, always as `{{field}}`. |
| `groupKind` | `number` \| `text` \| `boolean` | detected from the first item | How to compare. |
| `measure` | `count` \| `sum` | `count` | Count items or add up a field. |
| `sumField` | number | `""` | The field added up, with `measure: "sum"`. |
| `shows` | `amount` \| `percent` \| `both` | `amount` | What each group writes. |
| `prefix`, `suffix` | text | `""` | Around the value, like `R$`. |
| `groups` | list | required | The groups. |
| `others` | `{ shows, name, color }` | hidden, “Others”, `gray` | The group for whatever fit in none. |

Each group has `name`, `color` and the rule for its `groupKind`:

- `number`: `from`. The item goes into the group with the highest `from` that doesn't exceed its value.
- `text`: `match`, a comma-separated list. Case doesn't matter.
- `boolean`: `truth`, `true` or `false`.

```json
{
  "type": "breakdown",
  "path": "pedidos",
  "groupField": "{{status}}",
  "groupKind": "text",
  "shows": "both",
  "groups": [
    { "name": "Paid", "color": "green", "match": "paid, captured" },
    { "name": "Pending", "color": "yellow", "match": "pending" }
  ],
  "others": { "shows": true, "name": "Others", "color": "gray" }
}
```

### Status grid: grid

Many items at once, each with a colored dot. Problems come first.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `path` | list path | required | The items. |
| `label` | text with fields | required | The name of each item. |
| `status` | text with fields | `""` | The status of each item. |
| `statusColors` | map value → color | `{}` | The color for each status. |
| `look` | `chips` \| `tiles` | `chips` | A name with a dot, or squares only. |
| `tipTitle`, `tipText` | text with fields | `""` | The tooltip when the pointer passes over, in `tiles`. |
| `rowURL` | text with fields | `""` | What opens on click. |
| `limit` | number | `5` | How many items, from 6 to 60. |

```json
{ "type": "grid", "path": "pods", "label": "{{nome}}", "status": "{{fase}}", "limit": 36, "statusColors": { "Running": "positive", "Pending": "attention", "Failed": "negative" } }
```

### Timeline: timeline

Events by date, with a mark at now.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `path` | list path | required | The events. |
| `date` | text with fields | required | The date of each event. Those without a readable date are skipped. |
| `dateFormat` | text | `""` | How to read the date. |
| `rowTitle` | text with fields | `""` | The event's title. |
| `rowSubtitle` | text with fields | `""` | The second line. |
| `rowURL` | text with fields | `""` | What opens on click. |
| `range` | `today` \| `week` \| `upcoming` \| `all` | `upcoming` | Which events. `upcoming` shows the next ones and the last one that passed. |
| `limit` | number | `5` | How many events, from 1 to 12. |

```json
{ "type": "timeline", "path": "$", "date": "{{date}}", "rowTitle": "{{name}}", "range": "upcoming", "limit": 8 }
```

### Table: table

Columns over a list. Every row is included, and whatever goes past `limit` scrolls. Clicking a column title sorts by it.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `path` | list path | required | The rows. |
| `columns` | list | required | The columns, up to 8. |
| `sortable` | boolean | `true` | Sort by the title. |
| `limit` | number | `5` | Visible rows, from 1 to 12. |
| `rowEvent` | `none` \| `open` \| `run` | `open` | What a click on the row does. |
| `rowURL` | text with fields | `""` | What opens, with `open`. |
| `event` | button | empty | The button the row runs, with `run`. Same format as an item in `actions`. |

Each column:

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `title` | text | `""` | The title. |
| `kind` | `text` \| `image` \| `status` \| `button` | `text` | What the cell shows. |
| `value` | text with fields | `""` | The value, the image address, the status or the button label. |
| `align` | `leading` \| `trailing` | `leading` | Alignment. |
| `format`, `currency` | | absent | With `format`, the text becomes a formatted number and the column sorts as a number. |
| `isEnabled` | boolean | `true` | A column that is turned off stays in the file and doesn't show. |
| `shape`, `scale` | | `rounded`, `1` | `image`: shape and size. |
| `hover` | text with fields | `""` | `image`: text shown above the image while the pointer rests on it, such as `{{login}}`. |
| `statusColors`, `showsWord` | | `{}`, `false` | `status`: the colors, and whether the word shows next to the dot. |
| `action` | text | `""` | `button`: the `name` of a button with `place: "column"`. |
| `symbolName`, `tint`, `isProminent` | | | `button`: symbol, color and fill. The symbol takes fields, like `{{symbol}}`, and changes from row to row. |

```json
{
  "type": "table",
  "path": "$",
  "limit": 8,
  "rowEvent": "none",
  "columns": [
    { "title": "Day", "value": "{{timestamp | shape dd/MM}}" },
    { "title": "Close", "value": "{{bid}}", "align": "trailing", "format": "money", "currency": "BRL" },
    { "title": "", "kind": "button", "action": "Copy", "symbolName": "doc.on.doc", "align": "trailing" }
  ]
}
```

### Text: text

A paragraph, with bold, italics and code in Markdown, and a copy button.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `value` | text with fields | required | The text. |
| `maxHeight` | `small` \| `medium` | `medium` | The height before scrolling. |
| `copy` | yes or no | `true` | Shows the copy button. |

```json
{ "type": "text", "value": "{{explanation}}", "maxHeight": "small" }
```

### List: list

The items of a list in the response. Every item is drawn with the same structure: columns side by side, each column with lines, each line with pieces side by side. Always full width.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `path` | list path | required | The items. |
| `limit` | `3` \| `5` \| `10` \| `0` | `5` | How many items to show. `0` shows all. |
| `spacing` | `compact` \| `medium` \| `wide` | `medium` | The vertical space of each item: 7, 10 or 14 pt. |
| `columnGap` | `8` \| `12` \| `16` | `12` | The space between columns. |
| `separator` | boolean | `true` | A thin line between items. |
| `highlight` | boolean | `false` | A background on the item under the pointer. |
| `structure` | list | required | The columns. |
| `rowEvent` | `none` \| `open` \| `run` | `open` | What a click on the item does. |
| `rowURL` | text with fields | `""` | What opens, with `open`. Takes an address or a file path. |
| `event` | button | empty | The button the item runs, with `run`. |
| `rowDetail` | text with fields | `""` | A text beside the item on hover. |
| `sections` | text with fields | `""` | A heading above each group of items with the same value. |

A column of `structure`:

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `width` | `auto` \| `fill` \| `fixed` | `fill` | The width. `fill` shares what is left. |
| `fixedWidth` | number | `48` | The width in pt, with `fixed`. |
| `align` | `top` \| `center` \| `bottom` | `center` | How the lines align vertically. |
| `lineGap` | `2` \| `4` \| `6` \| `8` | `4` | The space between lines. |
| `padding` | `0` \| `4` \| `8` \| `12` | `0` | The inner spacing. |
| `background` | `none` \| `card` | `none` | `card` is a slightly lighter background with 8 pt corners. |
| `lines` | list | `[]` | The lines. |

A line:

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `gap` | `4` \| `6` \| `8` \| `12` | `6` | The space between pieces. |
| `distribute` | `start` \| `center` \| `end` \| `between` | `start` | How the pieces spread. |
| `align` | `top` \| `center` \| `bottom` | `center` | How the pieces align vertically. |
| `items` | list | `[]` | The pieces, drawn side by side. |

Every piece has an `id` and a `kind`, plus the keys of its kind:

| `kind` | Keys |
| --- | --- |
| `text` | `content` (text with fields), `size` (11, 12, 13, 15, 17 or 20), `weight` (`regular`, `medium`, `semibold`, `bold`), `ink`, `lines` (1, 2, 3 or `0` for free), `fills` (takes what is left and shrinks first), `textAlign` (`leading`, `center`, `trailing`, only with `fills`). |
| `image` | `picture` (a symbol, an address with fields or a file), `size` (16, 20, 24, 32, 44, 56 or 72), `form` (`square`, `rounded`, `circle`), `border`, `hover` (text shown above the image while the pointer rests on it, such as `{{login}}`). With no image, a gradient made from the item, with initials in a circle from 24 pt. |
| `icon` | `symbolName`, `size` (11, 12, 13, 15, 18 or 22), `ink`, `content` (text beside it), `background` (`none`, `circle`, `square`), `tintFrom` with `rules` so the colour follows a field. |
| `button` | `action` (the `id` of a button with `place: "row"`), `look` (`filled`, `outline`, `subtle`, `circle`), `buttonSize` (`small`, `medium`, `large`), `ink`. The name, icon and `shows` come from the button. |
| `indicator` | `content` (the value), `indicator` (`dotAndText`, `dot`, `text`), `size` (11, 12 or 13), `rules` (`{ value, tint, label }`, matching the text without case), `otherTint`. |
| `space` | `spaceWidth` (`0` for flexible, or 4, 8, 16, 24). |

`ink` is `primary`, `secondary`, `tertiary`, a hue (`blue`, `red`, `orange`, `yellow`, `green`, `teal`, `indigo`, `purple`, `pink`) or `#RRGGBB`.

```json
{
  "type": "list",
  "path": "items",
  "limit": 5,
  "structure": [
    { "width": "auto", "lines": [
      { "items": [{ "kind": "image", "picture": { "kind": "address", "value": "{{thumb}}" }, "size": 56, "form": "rounded" }] }
    ] },
    { "width": "fill", "lines": [
      { "items": [{ "kind": "text", "content": "{{title}}", "weight": "medium", "lines": 2, "fills": true }] },
      { "items": [
        { "kind": "space", "spaceWidth": 0 },
        { "kind": "text", "content": "{{source}} · {{age}}", "size": 12, "ink": "secondary" }
      ] }
    ] }
  ],
  "rowURL": "{{url}}"
}
```

A list in the earlier format, with `tabs`, is converted when the plugin opens: each tab becomes a `list` component, and a `tabs` component points at them.

### Tabs: tabs

A bar of tabs. Each tab shows one or more components of the card, right under the bar and in the tab's order. A component put in a tab leaves the card's flow. No title, always full width.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `tabStyle` | `pill` \| `square` | `pill` | The shape of the tabs. |
| `tabAlign` | `start` \| `center` \| `end` | `start` | How the bar aligns. |
| `tabRing` | boolean | `false` | A 2 pt ring on the selected tab. |
| `tabRingColor` | hue or `#RRGGBB` | `purple` | The ring's colour. |
| `tabLine` | boolean | `true` | A line under the bar. |
| `pages` | list | `[]` | The tabs, in order. |

A tab:

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `id` | UUID | generated | The tab's identifier. |
| `kind` | `text` \| `symbol` \| `image` | `text` | What the tab shows. |
| `name` | text with fields | `""` | The tab's text, or the hint on hover for a symbol or an image. Takes fields, like `Review {{prs \| count}}`. |
| `symbolName` | text | `sun.max` | The symbol, with `symbol`. |
| `picture` | image | file | The image, with `image`: `file` or `address`. |
| `imageSize` | number | `20` | The image size, from 12 to 40 pt. |
| `form` | `square` \| `rounded` \| `circle` | `circle` | The image's shape. |
| `border` | boolean | `false` | A thin border around the image. |
| `symbolSize` | 11, 13, 15, 18, 22 or 28 | `15` | The symbol's size. |
| `showsName` | boolean | `false` | Shows `name` beside the symbol or the image, instead of only on hover. |
| `textSize` | 11, 12, 13, 15, 17 or 20 | `13` | The text size. |
| `weight` | `regular` \| `medium` \| `semibold` \| `bold` | `medium` | The text weight. |
| `ink` | `primary`, `secondary`, `tertiary`, a hue or `#RRGGBB` | `primary` | The colour of the text and the symbol. Tabs that aren't selected look dimmer. |
| `bodies` | list of UUID | `[]` | The `id`s of the components the tab shows, in order. A single `body` from the earlier format is still read. |

- A `tabs` component can't go inside a tab.
- When the same component is in more than one tab, the first one in the card's order wins.
- A component removed from the card leaves the tab. Taken out of the tab only, it goes back to the card's flow.
- The first tab opens selected. With the card in focus, ⌘1 to ⌘9 pick a tab.

### Buttons: buttons

The button panel. The component doesn't hold buttons: it draws the items in `actions` with `place: "panel"`.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `group` | text | `""` | Empty shows every panel button. Filled in, only those with the same `group`. That way two panels can share the card. |
| `resultSpot` | `above` \| `below` \| `leading` \| `trailing` | `below` | Where the text or image a button brought back appears. On the left and on the right, it shares the width with the buttons. |

```json
{ "type": "buttons", "resultSpot": "below" }
```

How each button looks is described in [Panel buttons](#panel-buttons).

### Properties: properties

Name and value pairs, each with buttons beside it.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `layout` | `columns` \| `stacked` \| `grid` | `columns` | Two columns, stacked or in a grid. |
| `properties` | list of `{ id, name, value }` | `[]` | The fixed pairs. `value` accepts fields. |
| `path` | path | `""` | An object from the response. Each of its fields becomes a pair, after the fixed ones. |

A button next to a pair is an item in `actions` with `place: "property"` and `property` equal to the pair's `id`. Without `property`, it shows on every pair that came from `path`. Inside the button, `{{name}}` and `{{value}}` are the clicked pair.

```json
{
  "type": "properties",
  "layout": "columns",
  "properties": [
    { "id": "8F1C2A4E-0B5D-4C7A-9E21-3D4F5A6B7C80", "name": "Plan", "value": "{{plan.name}}" },
    { "id": "1A2B3C4D-5E6F-4A7B-8C9D-0E1F2A3B4C5D", "name": "Key", "value": "{{api_key}}" }
  ]
}
```

### Controls: controls

Switches and levels from 0 to 100, right on the card.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `controlStyle` | `tiles` \| `list` \| `compact` | `tiles` | Tiles, list or compact. |
| `perRow` | `2` \| `3` | `2` | Tiles per row. |
| `controls` | list | `[]` | The controls. |

Each control:

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `id` | UUID | required | The button it runs points to this `id`. |
| `name` | text with fields | `""` | The name. |
| `kind` | `toggle` \| `level` | `toggle` | Switch or level. |
| `path` | path | `""` | Where the response gives the current state. |
| `isOn`, `level` | | `false`, `50` | The fixed state, when there is no `path`. |

Changing a control runs the item in `actions` with `place: "control"` and `control` equal to its `id`. The new value arrives in `{{value}}`. If the action fails, the control goes back to what it was.

```json
"card": {
  "components": [
    {
      "type": "controls",
      "controls": [
        { "id": "EF459F1A-12E6-454B-A63E-95009EDAA3C9", "name": "Living room", "kind": "toggle", "path": "[entity_id=light.sala].state" }
      ]
    }
  ]
},
"actions": [
  {
    "name": "Living room",
    "place": "control",
    "control": "EF459F1A-12E6-454B-A63E-95009EDAA3C9",
    "successReport": "nothing",
    "run": {
      "kind": "http",
      "method": "POST",
      "url": "http://homeassistant.local:8123/api/services/light/toggle",
      "headers": [{ "name": "Authorization", "value": "Bearer {{$settings.token}}" }],
      "body": "{\"entity_id\": \"light.sala\"}"
    }
  }
]
```

## Buttons: actions

Every button in the plugin is an item in `actions`. Where it shows is `place`.

| `place` | Where it shows |
| --- | --- |
| `card` | At the top of the card, next to the title. This is the default. |
| `row` | In a `button` piece of a `list` component, on every item of the list. |
| `panel` | In the `buttons` component. |
| `column` | In a `button` column of a table, linked by `name`. |
| `property` | Next to a pair in the `properties` component. |
| `control` | Doesn't show: it runs when a control changes. |

### The keys of a button

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `name` | text | `""` | The button's name. Empty, “Run”. |
| `mark` | image | symbol `bolt.fill` | The button's symbol or image. |
| `place` | text | `card` | Where it shows. |
| `shows` | `both` \| `icon` \| `text` | `icon` | `row`: icon and text, icon only or text only. |
| `property` | UUID | absent | `property`: next to which pair. |
| `control` | UUID | absent | `control`: which control triggers it. |
| `hint` | text with fields | `""` | The tooltip when the pointer passes over. |
| `isProminent` | boolean | `false` | A filled button, at the top, on the row and in properties. |
| `needs` | text with fields | `""` | `card`: the button only shows while this field has a value. |
| `asksForValue` | boolean | `false` | Asks for a value before running. Whatever is typed is `{{$input}}`. |
| `question` | text | `""` | The placeholder text of the field. Empty, “Type a value”. |
| `confirms` | boolean | `false` | Asks for confirmation before running. |
| `confirmText` | text with fields | `""` | The question. Empty, “Run name?”. |
| `runningText` | text with fields | `""` | What the row says while it runs. Empty, “Running…”. |
| `spinsRing` | boolean | `true` | The icon shows that it's busy. |
| `successReport` | `message` \| `output` \| `nothing` | `message` | What shows when it works: a sentence, the program's output, or nothing. |
| `successMessage` | text with fields | `""` | The sentence. `{{@field}}` reads the button's response. |
| `failureReport` | `message` \| `output` \| `nothing` | `message` | The same, when it fails. A failure is never silent: without a sentence, the error shows. |
| `failureMessage` | text with fields | `""` | The failure sentence. |
| `run` | object or list | `{ "kind": "http" }` | What the button runs. |
| `stopsOnFailure` | boolean | `true` | In a list of steps, stops at the first one that fails. |
| `rules` | list | `[]` | What to do depending on the response. |

When the button works, the plugin reads the source again and redraws the icon and the card.

Older files with `symbolName`, `picture`, `isWide`, `statusHues` and `thenRun` are still read.

### What a button runs: run

`run` has the same format as `source`: `http`, `command` and `script` work the same way, with the same limits. A button also has a fifth door, `onTheMac`, which does something on the Mac itself:

| `errand` | Keys | What it does |
| --- | --- | --- |
| `copy` | `text` | Copies the text. |
| `openLink` | `text` | Opens the address in the browser. |
| `openApp` | `text` | Opens an app by bundle id (`com.apple.Music`), path or name. |
| `notify` | `title`, `text` | Sends a system notification. |
| `playSound` | `sound` | Plays one of the sounds from Settings › Notifications. |
| `runShortcut` | `text`, `input` | Runs a Shortcut by name, with `input` as its input. |
| `remember` | `variable`, `text` | Saves a value into a global variable. |
| `addToList` | `variable`, `text` | Adds a line to a list: a `textList` field of the plugin or a global variable. |
| `dropFromList` | `variable`, `text` | Removes a line from that list. |
| `readLater` | `text` | Saves the address in Read later. |

```json
"run": { "kind": "onTheMac", "errand": "copy", "text": "{{url}}" }
```

The old name of `text` in errands was `address`, which is still read.

### Steps in sequence

`run` accepts a list. The steps run in order, and each one reads the previous one's response with `@`. By default, a step that fails stops the ones after it, and the message says which one failed.

```json
"run": [
  { "kind": "http", "method": "POST", "url": "https://api.exemplo.com/deploy" },
  { "kind": "onTheMac", "errand": "openLink", "text": "{{@deploy.url}}" }
]
```

> [!NOTE]
> The visual editor only edits the first step. A sequence is written in the JSON.

### Panel buttons

With `place: "panel"`, the button gets a shape of its own, like an icon on the iPhone home screen.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `size` | `small` \| `medium` \| `large` | `medium` | A square, half a row or the whole row. A row takes buttons until the next one doesn't fit. |
| `hue` | color | `theme` | `theme`, `red`, `orange`, `yellow`, `green`, `teal`, `blue`, `indigo`, `purple` or `pink`. |
| `label` | text with fields | `""` | The word on the button. Empty, the `name`. |
| `spot` | `top` \| `leading` \| `trailing` | `top` | The symbol above, before or after the word. |
| `titleSpot` | `inside` \| `below` | `inside` | The word inside the button or below it. |
| `shape` | `rounded` \| `round` | `rounded` | `small`: square or circle. |
| `group` | text | `""` | Which `buttons` component it goes into. |
| `status` | text with fields | `""` | A field from the reading that decides how it looks at rest. |
| `states` | list of `{ value, hue, symbolName, label }` | `[]` | How it looks for each value of `status`. |

`status` and `states` are how nine lights turn on and off with a single reading:

```json
{
  "name": "Living room",
  "place": "panel",
  "size": "small",
  "mark": { "kind": "symbol", "value": "lightbulb" },
  "status": "{{[entity_id=light.sala].state}}",
  "states": [
    { "value": "on", "hue": "yellow", "symbolName": "lightbulb.fill", "label": "On" },
    { "value": "off", "hue": "theme", "symbolName": "lightbulb", "label": "Off" }
  ],
  "run": { "kind": "http", "method": "POST", "url": "http://homeassistant.local:8123/api/services/light/toggle" }
}
```

## Rules: rules

A button looks at what came back and decides what to do. Rules run in order, and **every rule that matches happens**. An if/else is two rules.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `test` | text | `worked` | What to check. |
| `field` | text with fields | `""` | The field being looked at, almost always with `@`, like `{{@state}}`. Empty is the whole response. |
| `value` | text with fields | `""` | What to compare with. |
| `then` | list | `[]` | What happens when the rule matches. |

| `test` | In the editor | Matches when |
| --- | --- | --- |
| `worked` | it worked | The button worked. |
| `failed` | it failed | The button failed. |
| `isTrue` | is true | The field is true. |
| `isFalse` | is false | The field is false. |
| `equals` | is | The field equals `value`. |
| `differs` | is not | The field is different from `value`. |
| `has` | contains | The field contains `value`, ignoring case. |
| `above` | is above | The field is a number greater than `value`. |
| `below` | is below | The field is a number less than `value`. |
| `isEmpty` | is empty | The field is empty. |
| `exists` | came back | The field exists in the response. |

What can happen in `then`, each item with a `kind`:

| `kind` | Keys | What it does |
| --- | --- | --- |
| `mark` | `hue`, `symbolName`, `label` | Changes the button itself. |
| `say` | `text` | Writes a line on the card, in place of the `successMessage` or the `failureMessage`. Empty, whatever came back. |
| `show` | `text` | Opens a text panel next to the `buttons` component. Empty, the whole response. |
| `picture` | `picture` | Shows an image from an `https` address. |
| `run` | `run` | Runs another call, with the button's response in `@`. |
| `reread` | | Reads the plugin again, even if the button failed. |
| `attention` | | Makes the icon blink and bounce. |

```json
"rules": [
  { "test": "equals", "field": "{{@state}}", "value": "on", "then": [{ "kind": "mark", "hue": "yellow", "label": "On" }] },
  { "test": "failed", "then": [{ "kind": "say", "text": "Couldn't reach the light." }] }
]
```

- A rule's `run` doesn't trigger other rules. There are at most four per click, and they stop at the first one that fails.
- A rule's mark lasts until the button runs again or until the next reading. It isn't kept when the app quits.
- The button takes on, in this order: what the last rule said, then the reading's `states`, then the fixed keys.
- Every run goes into the plugin's history, with time, trigger, result and the real error. The app keeps the last 60.

## The icon as a button: role and button

With `role: "button"`, the icon becomes the button: a click runs the `source` and the response becomes the icon's data. There is no periodic reading and no card.

| Key of `button` | Type | Default | What it is |
| --- | --- | --- | --- |
| `confirms` | boolean | `false` | Asks before running. |
| `question` | text with fields | `""` | The question. Empty, “Run name?”. |
| `okLabel` | text | `""` | The confirm button. Empty, “Yes”. |
| `runningText` | text with fields | `""` | What shows while it runs. |
| `successReport`, `failureReport` | `message` \| `output` \| `nothing` | `message` | What shows afterward. |
| `successMessage`, `failureMessage` | text with fields | `""` | The sentences. |

A question left unanswered cancels itself after 5 seconds.

## Calling attention: attention

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `mode` | `never` \| `newRows` \| `countAbove` | `never` | Never, when a new row shows up, or when a number crosses a limit. |
| `countPath` | number | `""` | `countAbove`: the number being watched. |
| `comparison` | `above` \| `below` | `above` | Crossing upward or downward. |
| `threshold` | number or text with fields | `0` | The limit. Accepts `{{$settings.limite}}`. |

- `newRows` compares the rows of every list with the previous reading. The first reading only takes note.
- `countAbove` alerts at the crossing, not while the number stays on the other side.
- The alert makes the icon blink and bounce, and follows the sound and peek in Settings › Notifications.

```json
"attention": { "mode": "countAbove", "countPath": "{{items | count}}", "comparison": "above", "threshold": "{{$settings.limite}}" }
```

## Plugin settings: settings

Fields the person installing fills in, on the install screen and later in Settings › Plugins. That way a shared plugin works without anyone editing the file.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `key` | text | required | The name, read as `{{$settings.key}}`. Letters, numbers and `_`. |
| `label` | text | `key` | The label. |
| `help` | text | `""` | A line of help. |
| `type` | `text` \| `number` \| `toggle` \| `choice` \| `textList` \| `secret` | `text` | The type. |
| `default` | depends on the type | empty | The initial value. Doesn't apply to `secret`. |
| `required` | boolean | `false` | Installation only finishes with the field filled in. |
| `options` | list | `[]` | `choice`: texts or `{ value, label }`. Without `default`, the first option comes selected. |
| `placeholder` | text | `""` | The example inside the empty field. |

```json
"settings": [
  { "key": "repo", "label": "Repository", "type": "text", "placeholder": "owner/name", "required": true },
  { "key": "limite", "label": "Alert above", "type": "number", "default": 5 },
  { "key": "periodo", "label": "Period", "type": "choice", "default": "daily",
    "options": [{ "value": "daily", "label": "Today" }, { "value": "weekly", "label": "Week" }] },
  { "key": "feeds", "label": "Addresses", "type": "textList" },
  { "key": "token", "label": "GitHub token", "type": "secret", "required": true }
]
```

- `{{$settings.key}}` works everywhere that accepts fields: source, buttons, icon and card.
- `number` arrives as a number, `toggle` as true or false, `textList` as a list of lines.
- Each `secret` stays out of the manifest, in the Keychain or in the app's secrets file.
- The exported file carries the definitions and the `default` values, never what was filled in.

> [!TIP]
> Every token goes in a `secret` field in `settings`. That's how you share the plugin without handing over your key.

## Global variables

A value written once in Settings › Variables and read in any plugin, like `{{$global.cidade}}`. The types are the same as in `settings`, and a secret stays in the Keychain.

`settings` belongs to the plugin and travels with it. `$global` belongs to the app and applies to every plugin. The `remember` errand saves a global variable from a button.

## Fields and filters

Every text that accepts fields mixes words with `{{ }}`:

```text
{{caminho}}
{{caminho | filtro}}
{{caminho | filtro argumento}}
{{caminho ?? outro ?? "texto fixo"}}
```

- `??` tries each alternative until it finds one with a value.
- One filter per field. Filters don't chain.
- An empty field takes the text attached to it along: `{{nome}} · {{nota}}` with no note doesn't leave the dot hanging.

### Paths

| Path | What it reads |
| --- | --- |
| `campo.sub` | A field. Inside a list, a field of the row. |
| `lista[0]`, `lista[-1]` | An item by position. Negative counts from the end. |
| `lista[*].nome` | Every item. |
| `lista[status=open]` | The items with that value. |
| `$` | The whole response. |
| `/campo` | A field of the whole response, from inside a row. |
| `@campo` | The response of the button that just ran. |
| `$input` | The value typed into the button. |
| `$settings.chave` | A plugin setting. |
| `$global.nome` | A global variable. |
| `$page`, `$offset`, `$size`, `$cursor` | The page being fetched. See [Pagination](#pagination). |

### Fields of the moment

| Field | What it brings |
| --- | --- |
| `{{$clipboard}}` | The text on the clipboard. |
| `{{$frontmostApp}}` | The name of the app in front. |
| `{{$now}}` | The current date and time, in ISO 8601. |
| `{{$activeURL}}` | The address of the tab open in the front browser. |
| `{{$activeTitle}}` | The title of that tab. |

`$activeURL` and `$activeTitle` work in Safari and in Chromium browsers (Chrome, Arc, Brave, Edge, Vivaldi, Opera), and only when a button runs. The first time, macOS asks for Automation permission for each browser.

### Filters

| Filter | What it does | Example |
| --- | --- | --- |
| `count` | How many items a list has. | `{{items \| count}}` → `12` |
| `round` | Rounds. | `{{temp \| round}}` → `24` |
| `percent` | A fraction as a percentage. | `{{taxa \| percent}}` → `42%` |
| `bytes` | File size. | `{{size \| bytes}}` → `1.2 GB` |
| `ago` | How long ago. | `{{created \| ago}}` → `5 min` |
| `date` | Short date and time. | `{{start \| date}}` → `23/09 10:00` |
| `clock` | Seconds as a clock. | `{{left \| clock}}` → `19:40` |
| `duration` | Seconds, or minutes with `minutes`, written out. | `{{mins \| duration minutes}}` → `5 h 12 min` |
| `upper`, `lower`, `trim` | Uppercase, lowercase, no spaces at the ends. | `{{code \| upper}}` |
| `plural` | How many items a list has, with the word in singular or plural. | `{{items \| plural item/items}}` → `3 items` |
| `shape` | A number, date or text pattern. | `{{bid \| shape #,##0.00}}`, `{{data \| shape dd/MM/yyyy}}` |

`shape` accepts number patterns (`#,##0.00`, `R$ #,##0.00`, `+#,##0;-#,##0`), date patterns (`dd/MM`, `HH:mm`, `MM/yyyy`) and `upper`, `lower` and `title`.

> [!WARNING]
> `count` and `plural` count list items. On a field that is already a number, they answer 1. To write a number with a word, use `{{total}} items`.

## Pagination

With `source.pagination`, the card gets a **Show more** button at the end of the list, which fetches the next page and adds its rows.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `kind` | `page` \| `offset` \| `cursor` \| `nextURL` \| `linkHeader` | `page` | How the API paginates. |
| `listPath` | path | the first tab | The list that grows. |
| `start` | number | `1` for `page`, `0` for `offset` | The first value. |
| `size` | number | `card.rowLimit` | Items per page. |
| `cursorPath` | path | `""` | `cursor`: where the response gives the next cursor. |
| `nextURLPath` | path | `""` | `nextURL`: where the response gives the next address. |
| `hasMorePath` | path | `""` | A field that says whether there is more. |
| `maxPages` | number | `10` | Page limit, up to 50. |
| `buttonText` | text | `Show more` | The button's text. |

- `page` replaces `{{$page}}` in the address or the body: 1, 2, 3…
- `offset` replaces `{{$offset}}` (0, `size`, 2 × `size`…) and `{{$size}}`.
- `cursor` replaces `{{$cursor}}` with the cursor read on the previous page.
- `nextURL` fetches the address read in `nextURLPath`, with the same headers.
- `linkHeader` follows the `rel="next"` of the `Link` header, as on GitHub. `nextURL` and `linkHeader` are for `http` only.

Rows repeated across pages are dropped, by `rowURL` or by the title and second line. The periodic reading only reads the first page again.

```json
"pagination": { "kind": "linkHeader", "listPath": "$" }
```

## Saved readings: history

The app can keep one field from each reading, to draw how it changes even when the API has no history.

| Key | Type | Default | What it is |
| --- | --- | --- | --- |
| `path` | number | `""` | The field saved on each good reading. |
| `keep` | number | `48` | How many readings to keep, from 2 to 500. |

A `chart` component without `path`, a `slot` with `style: "sparkline"` and an empty `previous` on a `hero` use these readings. They stay on this Mac, outside the manifest.

```json
"history": { "path": "{{USDBRL.bid}}", "keep": 96 }
```

## Installation

A plugin that comes from outside shows what it does before it goes in. The list comes from the keys it uses:

- what it reads, how often, and whether it touches the network;
- the programs and scripts it runs, with their contents in view;
- each button, and whether it asks for confirmation;
- the `settings` fields, which can be filled in right there;
- whether it asks for a token;
- the fields of the moment it reads, and when;
- the apps it opens and the Shortcuts it runs;
- the permissions macOS may ask for;
- the new buttons, when it's a new version of a plugin already installed.

Every imported plugin opens first in the editor, with a preview of the card, and the **Install** button shows this screen with the fields still missing. The preview uses the manifest's `answer`. Without an `answer`, an address plugin that already has everything it needs does a real reading, and the others use values the app builds from the fields the card reads.

> [!TIP]
> A plugin that asks for a token or an address works better with an `answer` shaped like the real response, with a few rows and every field the card uses.

A plugin can be installed in three ways: **Settings › Plugins › Import…**, by dragging the file onto the list, or with a `peek://install?url=` link followed by the file's `https` address. The link downloads up to 2 MB and opens the same screen.

> [!CAUTION]
> Programs and scripts run on your Mac with your permissions. Read their contents on the install screen before confirming.

## A complete example

Pull requests waiting for your review, with the count on the icon, a list on the card and a button to copy the address:

```json
{
  "schema": 12,
  "id": "plugin.exemplo.revisoes",
  "name": "Reviews",
  "interval": 300,
  "icon": { "kind": "symbol", "symbolName": "arrow.triangle.pull" },
  "source": {
    "kind": "http",
    "url": "https://api.github.com/search/issues",
    "query": [{ "name": "q", "value": "is:pr is:open review-requested:{{$settings.usuario}}" }],
    "headers": [{ "name": "Authorization", "value": "Bearer {{$settings.token}}" }]
  },
  "slot": { "label": "{{total_count}}" },
  "card": {
    "title": "Waiting for review",
    "emptyMessage": "No reviews waiting.",
    "components": [
      {
        "type": "list",
        "path": "items",
        "limit": 10,
        "rowURL": "{{html_url}}",
        "structure": [
          { "width": "auto", "lines": [
            { "items": [{ "kind": "image", "picture": { "kind": "address", "value": "{{user.avatar_url}}" }, "size": 32, "form": "circle" }] }
          ] },
          { "width": "fill", "lines": [
            { "items": [{ "kind": "text", "content": "{{title}}", "fills": true }] },
            { "items": [
              { "kind": "text", "content": "{{user.login}} · {{updated_at | ago}}", "size": 12, "ink": "secondary", "fills": true },
              { "kind": "button", "action": "6F1C2D3E-0000-4000-8000-000000000001", "look": "circle", "buttonSize": "small" }
            ] }
          ] }
        ]
      }
    ]
  },
  "actions": [
    {
      "id": "6F1C2D3E-0000-4000-8000-000000000001",
      "name": "Copy link",
      "place": "row",
      "mark": { "kind": "symbol", "value": "doc.on.doc" },
      "run": { "kind": "onTheMac", "errand": "copy", "text": "{{html_url}}" },
      "successMessage": "Copied"
    }
  ],
  "attention": { "mode": "newRows" },
  "settings": [
    { "key": "usuario", "label": "Your GitHub username", "type": "text", "required": true },
    { "key": "token", "label": "GitHub token", "type": "secret", "required": true }
  ]
}
```

The `schema` is 12 because the card uses a `list` component.
