# Sideseen setup guide for agents

Sideseen is a phone app that shows a person's own values (numbers, percentages, times, statuses) in the app and in Home Screen widgets. It reads them from a JSON source that the person controls. It never connects to their other accounts and never asks for passwords. This guide is everything you need to prepare a source and a setup for it.

## What you deliver

- A source link: an https address that returns the person's values as JSON.
- An access key for that source: a read-only key the source requires. Only a source whose values are all fine to be public can do without one.
- A setup link: an https address that returns a setup document, the JSON that tells Sideseen where the source is, which values to take from it and how to show them.

The person pastes the setup link into Sideseen, reviews what it will show, pastes the access key and connects. Never ask them for a password to any service, and never put the access key into the setup document or into any link.

## Steps

1. Agree with the person on the values to show. Choose only values that are fine to show on a Home Screen or Lock Screen.
2. Build the source: an https address that returns the current values as JSON. Any HTTPS server the person controls works, for example a small web service, or a scheduled job that rewrites a JSON file which a web server serves after checking the key.
3. Create a read-only access key and make the source require it.
4. Write the setup document and check every path against a real response of the source.
5. Publish the setup document at a setup link.
6. Run the checks at the end of this guide.
7. Give the person the setup link and the access key, and tell them how to revoke or replace the key.

## Source

### Transport

- HTTPS with a publicly trusted certificate on a DNS name. No IP address, no self-signed certificate, no plain http, no host that only resolves on a local network. Internationalized domain names are written in punycode (`xn--`).
- Sideseen sends `GET` with `Accept: application/json`, plus `Authorization: Bearer <key>` when the setup says the source uses a key. It sends no cookies, uses no cache and needs no CORS headers: requests come from the app, not from a browser.
- Answer `200` with `Content-Type: application/json` and `Cache-Control: no-store`. Any final status other than 2xx is an error.
- Serve the source without redirects. Sideseen follows at most 5 redirects, each only within the same origin (scheme, host and port); a redirect to another origin fails.
- Answer within a few seconds: Sideseen gives up after 12 s without progress or 15 s in total.

### Body

- Strict UTF-8 JSON, at most 1,000,000 bytes (aim for a few KB), nested at most 64 levels. No comments, `NaN`, `Infinity` or trailing text; every number must fit in a double.
- One JSON object with stable, descriptive property names (`used_percent`, `resets_at`, `uptime_s`). Add properties rather than renaming them. Avoid `.` and whitespace in names (paths cannot address them), and never repeat a name within one object.
- Numbers as JSON numbers, not strings. Percentages from 0 to 100 (a 0-1 fraction needs `"format": { "scale": 100 }`). Durations in seconds (milliseconds need `"format": { "scale": 0.001 }`). Units belong in the setup document, not in the value.
- Points in time as ISO 8601 UTC with `Z` (`2026-09-24T14:05:00Z`) or as unix seconds. A remaining time ("resets in 2 hours") is a duration in seconds, not a point in time. Add a top-level `updated_at` (or similar) with the time the data was produced, for `dataAtPath`.
- Statuses as short lowercase words from a fixed set (`online`, `degraded`, `offline`), mapped to texts and levels in the setup's `statusMap`. Texts are shown up to 64 characters.
- History, for values shown as a chart: numbers oldest to newest at a steady cadence, at most 64 points, ending with the current value. Either an array of numbers next to the value (`"history": [4, 9, 15, 22]`), or one array of records that repeat the value paths (`"history": [{ "temperature_c": 21.6 }, { "temperature_c": 22.4 }]`); record history is read only for values shown as a chart.

### Load and content

- Every widget refresh is one request; expect up to about 100 requests per device per day, so the response should be cheap to produce. The refresh interval in the setup is a preference: the phone's system decides when widgets refresh.
- Include only values that are fine to show on a Home Screen or Lock Screen, and nothing the person did not ask to see.

## Access key

- Protect personal data with a read-only access key checked from the `Authorization: Bearer <key>` header. Answer `401` when the header is missing or the key is wrong.
- Format: 1 to 4096 visible ASCII characters (U+0021 to U+007E), so no spaces or line breaks. For example 32 random bytes encoded as base64url (43 characters).
- The key only reads these values: it changes nothing and opens nothing else. Generate it yourself; keep only a hash of it on the server if you can, compare in constant time, and never log it.
- Use one key per device if you can, so one device can be revoked alone.
- Never put the key in a URL (Sideseen rejects a source link with a query parameter named like a key), in the setup document (Sideseen removes it and tells the person the key was exposed) or in any file served without the key.
- Sideseen stores the key only on the device (the iOS Keychain, Keystore-encrypted storage on Android) and sends it only to the origin of the source link. When the source address changes, the person enters the key again.
- `"auth": { "type": "none" }` declares a public source: anyone who knows its address can read the values. Use it only for values that are fine to be public.

## Setup link

- An https address that returns the setup document, under the same URL rules as the source link (see Setup document, General rules). Use the direct address of the file: Sideseen follows up to 5 redirects to other https origins here and shows the final host, but avoid them.
- Sideseen first requests the setup link without a key. When it answers `401` or `403`, Sideseen asks the person for the access key and requests it again with `Authorization: Bearer <key>`, following redirects only within the same origin. When the setup link and a keyed source have the same origin, Sideseen also offers that key for the source. So either serve the setup document without a key, or protect it with the source's key on the source's origin.
- The setup document holds no secret, but it reveals the source address; an unguessable path or the key keeps it private.
- Sideseen reads the setup link only when the person adds the setup. Values always come from the source link.

## Setup document

The setup document is JSON data; Sideseen never runs anything from it. At most 131,072 bytes of UTF-8 and 16 levels of nesting; after Sideseen fills in the defaults it must stay under 32,768 bytes as compact JSON. The smallest useful setup:

```json
{
  "version": 1,
  "kind": "setup",
  "name": "Living room",
  "source": { "url": "https://example.com/sideseen/room.json", "auth": { "type": "bearer" } },
  "values": [
    { "id": "temperature", "path": "temperature_c", "label": "Temperature", "type": "number", "unit": "°C" }
  ]
}
```

### General rules

- Member names are exact and case-sensitive (`refreshMinutes`, not `refresh_minutes`). Unknown members are removed. An unknown member whose name differs from a known one only by case, `_` or `-` fails the import.
- Ids of values, cards and widget setups: 1 to 64 characters from `A-Z`, `a-z`, `0-9`, `_` and `-`, unique within their list, and not `__proto__`, `constructor` or `prototype`. Keep ids stable across revisions of a setup: placed widgets refer to them.
- Texts (names, labels, units, status texts, extra texts): no control or invisible characters and not blank. Lengths count Unicode code points.
- Numbers: finite, from -1e15 to 1e15.
- Paths are dot paths into the source JSON: `claude.five_hour.used_percent`, `items.0.value`. A segment of digits indexes an array; any segment names a property of an object. No JSONPath: `$.a.b` and `a[0]` are read as literal property names and draw a warning. At most 256 characters. A path that does not resolve, or ends on `null`, shows `—`.
- URLs (source link and setup link): `https://`, a DNS host (letters, digits and inner hyphens; no IP address, user info or trailing dot), an optional port, and an optional path and query made only of letters, digits, `-._~!$&'()*+,;=:@/`, `?` in the query and `%` followed by two hex digits. No fragment and no spaces; at most 2048 characters.
- Credential-like names: a name is credential-like when, lowercased and with `_` and `-` removed, it is `key`, `sig`, `signature`, `auth`, `authorization`, `bearer`, `password`, `passwd`, `pwd`, `secret`, `token`, `cookie`, `session`, `sessionid`, `credential`, `credentials` or `jwt`, or ends with `token`, `secret`, `password`, `passwd`, `apikey`, `accesskey`, `privatekey`, `credential` or `credentials`. A query parameter of the source link with such a name fails the import; an unknown member with such a name is removed with a warning.

### Document

- `version`: `1`.
- `kind`: `"setup"`. Sideseen does not accept templates yet.
- `name`: 1-40 characters; the setup's name in the app and the default name of its widget setups.
- `source`: required, see below.
- `values`: 1-50 values.
- `cards`: optional, 1-50 cards. Omit it to get one card per value.
- `widgets`: optional, 1-20 widget setups. Sizes you do not cover are derived.
- `$schema`: optional editor hint, ignored.

### `source`

- `url`: the source link.
- `auth`: `{ "type": "bearer" }` when the source requires the access key; `{ "type": "none" }` (the default) for a public source.
- `refreshMinutes`: preferred refresh interval, an integer from 15 to 1440, default 15. The system decides the actual schedule.
- `staleAfterMinutes`: an integer from 15 to 10080. Values are shown as out of date after this many minutes without a successful update. Default twice `refreshMinutes`, and never less than `refreshMinutes`. Set it when the source itself changes less often: 90 for a source that produces new data every 30 minutes.
- `dataAtPath`: path to the source's own timestamp (ISO 8601 or unix time). When that time is older than the stale threshold, the app says the source is sending old data.
- `historyPath`: path to an array of records, oldest first, used for chart cards of numeric values that have no `historyPath` of their own.

### `values`

Each value has `id`, `path`, `label` (1-64 characters) and `type`:

- `number`: a JSON number (or a numeric string). Shown with the locale's grouping and up to 2 decimals, trailing zeros dropped: `1,284`, `3.14`.
- `percent`: 0 to 100 (or 0 to 1 with `"scale": 100`). Shown without decimals by default: `42%`.
- `duration`: seconds (or another unit with `scale`). Shown as `45 s`, `12 min`, `2 h 15 min`, `1 d 2 h`.
- `datetime`: ISO 8601 (no zone means UTC), unix seconds (or milliseconds), or a `YYYY-MM-DD` date. Shown as a local time or date, or as a relative time (`in 2 h 15 min`). Use `datetime`, not `timestamp`.
- `text`: a string, number or boolean, shown as is; up to 64 characters are kept.
- `status`: a string, number or boolean, shown through `statusMap`.

Optional members, each only on the types listed; members on other types are removed with a warning:

- `shortLabel` (all types): 1-24 characters, used in widgets; falls back to `label`.
- `unit` (`number`): 1-16 characters without surrounding spaces. `%`, `‰`, `°`, `′` and `″` attach to the number (`21°`); any other unit follows after a space (`22.4 °C`, `612 GB`). A percentage is a `percent` value, not a `number` with a `%` unit.
- `format` (with at least one member):
  - `decimals` (`number`, `percent`): exactly this many fraction digits, 0-3.
  - `scale` (`number`, `percent`, `duration`): the shown value is the source value times `scale`; above 0 and at most 1e9. `100` for a 0-1 fraction shown as a percentage, `0.001` for milliseconds shown as a duration.
  - `style` (`datetime`): `short` (the default: the time for today, the date and time within this year, the date otherwise), `date`, or `relative`.
- `historyPath` (`number`, `percent`, `duration`): path to an array of numbers, oldest first.
- `max` (`number`, `percent`, `duration`): upper bound for bars and rings, in shown units (seconds for a duration).
- `maxPath` (`number`, `percent`, `duration`): path to the upper bound in the source, in source units; wins over `max` when it resolves to a number.
- `statusMap` (`status`): 1-32 entries `"<source string>": { "level": "ok" | "warn" | "error", "text": "<1-24 characters>" }`, `text` optional. Keys are 1-64 characters and are matched after trimming and lowercasing, so they must be unique that way; they are not `__proto__`, `constructor` or `prototype`. A string without an entry is shown as is, without a level.

### `cards`

Each card has `id`, `valueId` (an existing value; several cards may show the same value) and `style`:

- `number`: any value, as text.
- `bar`, `ring`: `number`, `percent` and `duration` values with an upper bound: the card's `max`, the value's `max` or `maxPath`, or the implicit 100 of a `percent`.
- `chart`: `number`, `percent` and `duration` values with a history path (the value's `historyPath` or `source.historyPath`). The current app shows chart cards as a number and keeps the history.
- `status`: a level glyph and color: `status` values by their `statusMap` level (none without a matching entry), numeric values `warn` when `warnAt` is met, `ok` when it is not, and no level without `warnAt`.

Optional members:

- `min`, `max` (`bar` and `ring` only): bounds in shown units. `min` defaults to 0; `max` must be above `min`.
- `warnAt` (cards of `number`, `percent` and `duration` values): threshold in shown units (`80` for 80%, seconds for a duration), compared with the value as shown (79.6 shown as `80%` meets 80). The card shows a warning color and glyph.
- `warnWhen`: `above` (the default: warn when the value is at or above `warnAt`) or `below` (at or below). Only together with `warnAt`.
- `extra`: a second line, `{ "text": "<1-40 characters>", "valueId": "<value id>" }` with at least one member. Both together read as `Resets in 2 h 15 min`.

### `widgets`

A widget setup is a named set of cards for one widget size; the person picks it when placing or editing a widget. Each has `id`, `name` (1-40 characters, shown in the widget editor), `family` and `cardIds` (existing card ids, no repeats; without `cards`, card ids are value ids).

- `small`: 1 card.
- `medium`: up to 6 cards.
- `large`: up to 6 cards.
- `accessoryRectangular`, `accessoryInline`: 1 card (iOS Lock Screen).
- `accessoryCircular`: 1 card of a `number`, `percent` or `duration` value (iOS Lock Screen).

The app offers small, medium and large widgets on iOS and Android, and circular and rectangular Lock Screen widgets on iOS; `accessoryInline` widget setups are kept for when that family is offered.

### Defaults

- Without `cards`: one card per value, in value order, with the value id as card id: `ring` for the first `percent` value, `bar` for the other `percent` values, `status` for `status` values and `number` for the rest.
- For every family that no widget setup covers, Sideseen adds one named after the setup, with the family name as id (`small`, `medium`, …; `small-2` when that id is taken): `small` shows the first card, `medium` the first 6, `large` the first 6, `accessoryRectangular` and `accessoryInline` the first, `accessoryCircular` the first card of a numeric value.
- So omit `cards` and `widgets` when the defaults fit, and list only the widget setups that should differ.

## What import rejects or adjusts

The person importing usually cannot fix the JSON. Aim for a document that imports without warnings.

The import fails when:

- the text is over 131,072 bytes or nested deeper than 16 levels, or the document with defaults is over 32,768 bytes;
- it is not JSON, or not an object with `kind`;
- `version` is not `1`, or `kind` is not `"setup"`;
- `source.auth.type` or a value `type` is unknown;
- a query parameter of `source.url` is named like a credential;
- an unknown member differs from a known one only by case, `_` or `-`;
- a required member is missing or has the wrong JSON type, or an id, path, URL, text or number breaks the rules above in a way the adjustments below do not cover;
- ids repeat within values, cards or widget setups, or a `valueId`, `extra.valueId` or card id refers to nothing.

The import adjusts and shows a warning when:

- a member has a credential-like name: it is removed, and the person is told that the link exposed a key;
- a member does not apply to the value type or card style: it is removed;
- `refreshMinutes` or `staleAfterMinutes` is out of range: it is clamped;
- a text is too long: it is cut;
- a card style does not fit: an unknown style becomes `bar` or `number`; a style not allowed for the value type, a bar or ring without an upper bound or with `max` not above `min`, and a chart without a history path become `number`;
- `warnAt` is on a non-numeric value, or `warnWhen` comes without `warnAt`: they are removed;
- a widget setup repeats a card, has more cards than its family shows, has an unknown family, or is circular with a non-numeric value: the extra cards or the widget setup are removed;
- a value type is `timestamp`: it is read as `datetime`;
- a path starts with `$` or contains `[`, `]` or `/`, or a `percent` card has `warnAt` between -1 and 1 (write `80`, not `0.8`): kept, with a warning.

Other unknown members are removed without a warning.

## Example: AI limits

Source (`https://example.com/sideseen/ai-limits.json`, requires the key):

```json
{
  "updated_at": "2026-09-24T11:50:00Z",
  "claude": {
    "five_hour": {
      "used_percent": 42,
      "resets_at": "2026-09-24T14:05:00Z",
      "history": [4, 9, 15, 22, 27, 31, 36, 42]
    },
    "weekly": {
      "used_percent": 68,
      "resets_at": "2026-09-29T07:00:00Z",
      "history": [11, 19, 30, 41, 52, 60, 68]
    }
  },
  "chatgpt": { "weekly": { "used_percent": 31, "resets_at": "2026-09-28T07:00:00Z" } }
}
```

Setup: a source that needs the access key, a ring with a reset countdown, bars, a chart, and small, medium and Lock Screen widget setups; the other families are derived.

```json
{
  "version": 1,
  "kind": "setup",
  "name": "AI limits",
  "source": {
    "url": "https://example.com/sideseen/ai-limits.json",
    "auth": { "type": "bearer" },
    "refreshMinutes": 15,
    "dataAtPath": "updated_at"
  },
  "values": [
    {
      "id": "claude_5h",
      "path": "claude.five_hour.used_percent",
      "label": "Claude 5h",
      "shortLabel": "5h",
      "type": "percent",
      "historyPath": "claude.five_hour.history"
    },
    {
      "id": "claude_5h_reset",
      "path": "claude.five_hour.resets_at",
      "label": "Claude 5h reset",
      "type": "datetime",
      "format": { "style": "relative" }
    },
    {
      "id": "claude_weekly",
      "path": "claude.weekly.used_percent",
      "label": "Claude weekly",
      "shortLabel": "Week",
      "type": "percent",
      "historyPath": "claude.weekly.history"
    },
    {
      "id": "chatgpt_weekly",
      "path": "chatgpt.weekly.used_percent",
      "label": "ChatGPT weekly",
      "shortLabel": "GPT",
      "type": "percent"
    },
    {
      "id": "chatgpt_weekly_reset",
      "path": "chatgpt.weekly.resets_at",
      "label": "ChatGPT reset",
      "type": "datetime",
      "format": { "style": "date" }
    }
  ],
  "cards": [
    {
      "id": "claude_5h",
      "valueId": "claude_5h",
      "style": "ring",
      "warnAt": 80,
      "extra": { "text": "Resets", "valueId": "claude_5h_reset" }
    },
    { "id": "claude_weekly", "valueId": "claude_weekly", "style": "bar", "warnAt": 80 },
    {
      "id": "chatgpt_weekly",
      "valueId": "chatgpt_weekly",
      "style": "bar",
      "warnAt": 80,
      "extra": { "text": "Resets", "valueId": "chatgpt_weekly_reset" }
    },
    { "id": "claude_5h_trend", "valueId": "claude_5h", "style": "chart" }
  ],
  "widgets": [
    { "id": "small", "name": "Claude 5h", "family": "small", "cardIds": ["claude_5h"] },
    {
      "id": "medium",
      "name": "AI limits",
      "family": "medium",
      "cardIds": ["claude_5h", "claude_weekly", "chatgpt_weekly"]
    },
    { "id": "lock_claude", "name": "Claude 5h", "family": "accessoryCircular", "cardIds": ["claude_5h"] }
  ]
}
```

## Example: home server

Source (`https://example.net/home/status.json`, requires the key; new data every 30 minutes, `generated_at` in unix seconds):

```json
{
  "generated_at": 1790250900,
  "status": "online",
  "uptime_s": 1209600,
  "temperature_c": 22.4,
  "cpu_percent": 23,
  "disk": { "used_gb": 612, "total_gb": 1000 },
  "history": [
    { "at": 1790229600, "temperature_c": 21.6, "cpu_percent": 18 },
    { "at": 1790233200, "temperature_c": 21.4, "cpu_percent": 22 },
    { "at": 1790236800, "temperature_c": 21.9, "cpu_percent": 35 },
    { "at": 1790240400, "temperature_c": 22.3, "cpu_percent": 41 },
    { "at": 1790244000, "temperature_c": 22.8, "cpu_percent": 29 },
    { "at": 1790247600, "temperature_c": 22.6, "cpu_percent": 26 },
    { "at": 1790250900, "temperature_c": 22.4, "cpu_percent": 23 }
  ]
}
```

Setup: a `status` value with a `statusMap`, uptime as a duration in the extra line, a temperature chart from the record history, CPU as a ring (no chart shows it, so no history is read for it), disk usage as a bar whose upper bound comes from `maxPath`, `staleAfterMinutes` for the 30-minute cadence, and no `widgets` (all derived).

```json
{
  "version": 1,
  "kind": "setup",
  "name": "Home server",
  "source": {
    "url": "https://example.net/home/status.json",
    "auth": { "type": "bearer" },
    "refreshMinutes": 30,
    "staleAfterMinutes": 90,
    "dataAtPath": "generated_at",
    "historyPath": "history"
  },
  "values": [
    {
      "id": "status",
      "path": "status",
      "label": "Home server",
      "shortLabel": "Server",
      "type": "status",
      "statusMap": {
        "online": { "level": "ok", "text": "Online" },
        "degraded": { "level": "warn", "text": "Degraded" },
        "offline": { "level": "error", "text": "Offline" }
      }
    },
    { "id": "uptime", "path": "uptime_s", "label": "Uptime", "type": "duration" },
    {
      "id": "temperature",
      "path": "temperature_c",
      "label": "Living room",
      "shortLabel": "Room",
      "type": "number",
      "unit": "°C",
      "format": { "decimals": 1 }
    },
    { "id": "cpu", "path": "cpu_percent", "label": "CPU load", "shortLabel": "CPU", "type": "percent" },
    {
      "id": "disk",
      "path": "disk.used_gb",
      "label": "Disk used",
      "shortLabel": "Disk",
      "type": "number",
      "unit": "GB",
      "maxPath": "disk.total_gb",
      "format": { "decimals": 0 }
    }
  ],
  "cards": [
    { "id": "status", "valueId": "status", "style": "status", "extra": { "text": "Up", "valueId": "uptime" } },
    { "id": "temperature", "valueId": "temperature", "style": "chart", "warnAt": 28 },
    { "id": "cpu", "valueId": "cpu", "style": "ring", "warnAt": 85 },
    { "id": "disk", "valueId": "disk", "style": "bar", "warnAt": 900 }
  ]
}
```

## Checks before you hand over

Replace the example addresses with yours; `$KEY` holds the access key.

Without the key, the source answers `401` and does not redirect:

```sh
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' https://example.com/sideseen/ai-limits.json
```

With the key, it answers `200` with the JSON, under 1,000,000 bytes:

```sh
curl -sS -H "Authorization: Bearer $KEY" https://example.com/sideseen/ai-limits.json
```

The setup link answers `200` with the setup document and does not redirect (or `401` without the key and `200` with it):

```sh
curl -sS -w '\n%{http_code} %{redirect_url}\n' https://example.com/sideseen/ai-limits.setup.json
```

Then check that:

- every `path`, `maxPath`, `historyPath` and `dataAtPath` resolves in the real response to the expected kind of data;
- the setup document is valid JSON with `"version": 1` and `"kind": "setup"`, uses only the members in this guide, has unique ids and existing references, and contains no key or other credential anywhere;
- bars and rings have an upper bound, charts have a history path, thresholds and bounds are in shown units, and `unit` is only on `number` values.

## What to give the person

- The setup link.
- The access key on its own, ready to paste (unless the source is public).
- One sentence on what the setup shows, and how to revoke or replace the key.

## If Sideseen shows a problem

- "Access key required": the source uses a key and none was entered.
- "Access key not accepted": the source answered `401` or `403` to the key.
- "Access key stopped working": the source now refuses the stored key; give the person a new one.
- "Couldn't reach the source": DNS, TLS, connection or timeout. Check the address, the certificate and the response time.
- "Nothing found at this source link" or "The source returned an error": the source answered `404` or another status that is not 2xx.
- "Not a secure address": the link is not https or breaks the URL rules.
- "The source redirected to another address": a redirect left the source's origin.
- "The source didn't send values Sideseen can read": the body is not strict JSON.
- "The source sent too much data": the body is over 1,000,000 bytes.
- "Couldn't reach" and the host, under the setup link: the setup link failed to load or answered a status other than 2xx, `401` or `403`.
- "This link doesn't contain a Sideseen setup": the setup link returned something other than a setup document, such as a web page instead of the raw file.
- "This setup has errors": see What import rejects or adjusts.
- "This setup puts a key in its source link": remove the key from the URL, replace it at the source and send the new key separately.
- "Sideseen checked or adjusted N parts of this setup": import warnings; fix the document so it imports without them.
- A value shows `—`: its path does not resolve, ends on `null` or holds the wrong kind of data.
- "No new data for …": the time at `dataAtPath` is older than the stale threshold; the source has stopped producing new data.
