# Integrating with 00Widget

You're an agent (Claude Code, Codex, etc.) working on a project that wants to **publish state to 00Widget** so it shows up on the operator's iOS widgets, Lock Screen, and Dynamic Island.

This document is the entire contract you need. You do **not** need to read the rest of this repo. The 00Widget app and backend handle rendering — you just publish structured JSON.

## TL;DR

- Set `00WIDGET_BASE_URL=https://api.00widget.com`; this hosted copy already knows the public Worker URL.
- Get `00WIDGET_API_KEY` from the operator.
- `POST <BASE_URL>/v1/cards/upsert` with `Authorization: Bearer <API_KEY>` and a card body to push state.
- Use a **stable `id`** per card so re-publishing updates instead of duplicating.
- Pick a `template` (`summary`, `progress`, `list`, `action`) that matches the shape of the data you're surfacing.
- If the project has a dashboard, admin page, or useful detail page, set `deepLink` so a card tap opens it.
- Don't add a heavy SDK. A single `fetch` is enough.

## Tap behavior

Cards can open a destination when tapped via `deepLink`. If you want a card tap to open your app or website, set `deepLink` to an `https://...` URL, for example `"deepLink": "https://example.com/dashboard"`.

On Home Screen widgets, iOS first launches the containing 00Widget app with the widget URL. 00Widget then forwards `http`/`https` deep links to the system, so a universal link can open its matching app and a normal `https://...` URL opens in Safari. You may briefly see 00Widget during that handoff; it is not an in-app web view. Inside the 00Widget app, the card is rendered as a normal link to the same URL. If `deepLink` is omitted, a widget tap just opens 00Widget with no external destination.

## Operator checklist for agents

- Verify this Worker URL (`https://api.00widget.com`) with `/health` and `00WIDGET_API_KEY` with `GET /v1/cards`.
- Pick one stable `id` per logical card.
- If the project has a dashboard, consider setting `deepLink`.
- Prefer common card fields: `id`, `template`, `title`, `status`, `icon`, `updatedAt`, `staleAfter`, `deepLink`.
- Avoid secrets and private user data in card text; widgets can appear on the Lock Screen.

## Configure access

This hosted llms.md is being served by the 00Widget Worker you should call, so do not ask the operator for the base URL. Use:

```sh
00WIDGET_BASE_URL=https://api.00widget.com
```

Ask the operator only for the API key:

| Env var              | Example              | Where it comes from                         |
| -------------------- | -------------------- | ------------------------------------------- |
| `00WIDGET_API_KEY`   | a long random string | A tenant API token generated by the operator |

Verify the URL and API key before doing anything else:

```sh
curl -s "$00WIDGET_BASE_URL/health"
# → {"ok":true}

curl -s -H "Authorization: Bearer $00WIDGET_API_KEY" "$00WIDGET_BASE_URL/v1/cards"
# → {"cards":[...]}    (200, possibly empty)
# → {"error":"invalid API key"}   (401, key is wrong)
```

`GET /v1/cards` returns the full stored card JSON for that API token, including `deepLink` when present. If `/health` returns 200 but `/v1/cards` returns 401, the URL is right and the key is wrong. Ask the operator for the correct key — don't try to guess.

## Data model

A **DashboardCard** is one tile on a widget. Wire format:

```json
{
  "id": "string (stable per logical thing)",
  "template": "summary | progress | list | action",
  "title": "string",
  "subtitle": "string?",
  "value": "string?",
  "unit": "string?",
  "status": "unknown | good | warning | critical | running | finished | paused | offline",
  "icon": "SF Symbol name? (e.g. sun.max, bolt.car, flame, washer, creditcard)",
  "statusIcon": "SF Symbol name? (secondary glyph for a runtime status — e.g. bolt.fill while boosting, arrow.up while charging; rendered on every widget size, including grid cells)",
  "updatedAt": "ISO-8601 string? (server fills in if omitted)",
  "staleAfter": "ISO-8601 string? (after this, widget shows a 'stale' state)",
  "deepLink": "URL? (tapping the card opens this destination; use https://... for web apps)",
  "items": "DashboardItem[]? (only for template=list)",
  "actions": "ActionDefinition[]? (only for template=action; safe-only from widgets)"
}
```

**DashboardItem** (rows inside a `list` card):

```json
{
  "id": "string",
  "title": "string",
  "subtitle": "string?",
  "value": "string?",
  "unit": "string?",
  "status": "DashboardStatus?"
}
```

**ActionDefinition** (buttons on an `action` card):

```json
{
  "id": "string",
  "label": "string",
  "role": "normal | destructive (default: normal)",
  "confirm": "boolean (default: false)",
  "payload": "Record<string,string>? (server-side context, not shown)"
}
```

Only `role: normal` + `confirm: false` actions can run from widgets. Anything else routes through the iOS app for confirmation. Don't make destructive actions auto-runnable.

## Size limits

00Widget stores compact widget state, not logs or arbitrary documents. Oversized requests return `400 validation failed` or `400 invalid JSON body`.

Request body limits:

| Endpoint group | Limit |
| -------------- | ----: |
| `POST /v1/cards/upsert` | 32 KiB |
| Live Activity start/update/end | 16 KiB |
| Device, widget token, and Live Activity token registration | 8 KiB |
| Action runs, webhook integration, and share requests | 4 KiB |
| Apple app login token exchange | 16 KiB |

Card field limits:

| Field | Limit |
| ----- | ----: |
| `id` | 96 chars |
| `title` | 120 chars |
| `subtitle` | 240 chars |
| `value` | 80 chars |
| `unit` | 24 chars |
| `icon` | 64 chars |
| `statusIcon` | 64 chars |
| `deepLink` | 2048 chars |
| `items` | 20 rows |
| `actions` | 8 buttons |

Dashboard item limits match card text limits: `id` 96 chars, `title` 120 chars, `subtitle` 240 chars, `value` 80 chars, and `unit` 24 chars.

Action payload limits:

| Field | Limit |
| ----- | ----: |
| `id` | 96 chars |
| `label` | 80 chars |
| `payload` keys | 16 keys |
| payload key | 64 chars |
| payload value | 512 chars |
| total serialized `payload` | 4 KiB |

Live Activity limits:

| Field | Limit |
| ----- | ----: |
| `externalActivityId` | 128 chars |
| `title` | 120 chars |
| `subtitle` | 240 chars |
| `state` | 120 chars |
| `icon` | 64 chars |
| `value` | 80 chars |
| `unit` | 24 chars |
| `deepLink` | 2048 chars |
| `alert.title` | 120 chars |
| `alert.body` | 240 chars |

Registration and integration limits:

| Field | Limit |
| ----- | ----: |
| `deviceId` | 128 chars |
| `widgetKind` | 128 chars |
| `attributesType` | 128 chars |
| APNs push token fields | 4096 chars |
| `appVersion` | 64 chars |
| `platform` | 32 chars |
| `recipientEmail` | 254 chars |
| webhook URL | 2048 chars and must be public `https` |

## Choosing a template

Pick one based on the *shape* of the data, not the domain:

| Source feels like…                    | Template   | Required fields                            |
| ------------------------------------- | ---------- | ------------------------------------------ |
| One headline number or short state    | `summary`  | `title`, usually `value`, `unit`, `status` |
| Something filling up over time        | `progress` | `title`, `value` as `0.0–1.0`              |
| 2–6 things with their own values      | `list`     | `title`, `items[]`                         |
| One or more buttons                   | `action`   | `title`, `actions[]`                       |

If unsure, default to `summary` — it degrades gracefully on every widget size.

## Publishing a card

```sh
curl -X POST "$00WIDGET_BASE_URL/v1/cards/upsert" \
  -H "Authorization: Bearer $00WIDGET_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "id": "build-status",
    "template": "summary",
    "title": "Build",
    "subtitle": "main is green",
    "status": "good",
    "icon": "checkmark.seal",
    "deepLink": "https://example.com/dashboard"
  }'
```

200 with `{ "card": {...} }` means it landed. Re-POST with the same `id` to update — there's no separate update endpoint and no need to delete first.

To verify the stored card, including `deepLink`, call:

```sh
curl -s -H "Authorization: Bearer $00WIDGET_API_KEY" "$00WIDGET_BASE_URL/v1/cards/build-status"
```

To remove a card:

```sh
curl -X DELETE "$00WIDGET_BASE_URL/v1/cards/build-status" \
  -H "Authorization: Bearer $00WIDGET_API_KEY"
```

`staleAfter` is a rendering hint. iOS keeps showing the card, but renders it in a stale/secondary state so the operator can tell the value is old; it does not hide or delete the card.

### Stable ids

The `id` field is the dedupe key. Pick a slug that's stable across runs of your code:

- `solar-home`, `washer-cycle`, `tesla-charge`, `school-balance-<child>`, `build-status-<repo>`
- **Don't** put timestamps or run ids in there — every publish would create a new card.
- **Do** namespace if you might collide with another integration: `myapp-build-status` rather than `build-status`.

## Live Activities

Use a Live Activity instead of a card when:

- The thing has a **defined start and end** (a charge cycle, a build, a delivery, a meeting, a job).
- The user benefits from seeing it on the Lock Screen / Dynamic Island while it's happening.
- It will end on its own within ~hours.

Otherwise, stick with cards — they're cheaper and don't compete for screen real estate.

### Start (queue a pending activity)

```sh
curl -X POST "$00WIDGET_BASE_URL/v1/live-activities/start" \
  -H "Authorization: Bearer $00WIDGET_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "externalActivityId": "ci-build-2026-04-26-1234",
    "kind": "job",
    "title": "CI build #1234",
    "subtitle": "running tests",
    "state": "running",
    "icon": "hammer",
    "progress": 0.2,
    "endsAt": "2026-04-26T18:45:00Z",
    "staleAt": "2026-04-26T19:00:00Z"
  }'
```

The iOS app polls `/v1/live-activities/pending` and starts the activity locally on the device. Once it starts, it registers a per-activity APNs push token with the backend, which is how subsequent updates reach the device. Calling `start` again with the same `externalActivityId` replaces the pending record for that id; if an activity is already registered, subsequent `update` calls address that registered activity by the same id.

`state` is free-form text for your domain. The current iOS renderer displays it as text and does not reserve special rendering behavior for values like `paused` or `finished`; use `progress`, `value`, `unit`, `endsAt`, `icon`, and `kind` for visual structure.

Live Activity rendering fields:

- `kind`: one of `generic`, `progress`, `charging`, `appliance`, `job`, `timer`. If no icon is set, these render as `square.dashed`, `chart.bar`, `bolt.car`, `washer`, `hammer`, and `timer`.
- `icon`: optional SF Symbol name, such as `flame.fill`; overrides the kind icon. Same semantics as card icons.
- `progress`: optional `0.0`–`1.0` progress bar.
- `endsAt`: optional ISO-8601 end time. When present, iOS renders a native countdown driven by the device clock; you do not need periodic updates just to tick time forward.
- `relevanceScore`: optional non-negative number. Smart Stack on iPhone Lock Screen and Apple Watch ranks Live Activities by it (higher wins, no fixed ceiling). Send a low score early in a long-running activity and ramp it up as the activity gets more urgent or interesting; spike it on the finishing update so the wrist surfaces it. Accepted on both `start` and `update`.

For a time-bounded operation, prefer `endsAt` over server-ticked percentage updates. A "boosting until 21:30" activity can be `start` with `endsAt`, then `end` when finished or cancelled.

### Update

```sh
curl -X POST "$00WIDGET_BASE_URL/v1/live-activities/update" \
  -H "Authorization: Bearer $00WIDGET_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "externalActivityId": "ci-build-2026-04-26-1234",
    "state": "running",
    "subtitle": "linting",
    "progress": 0.6,
    "endsAt": "2026-04-26T18:45:00Z",
    "relevanceScore": 60
  }'
```

Add an `alert: { title, body }` for state changes worth notifying the user about (finished, failed). Use sparingly.

### End

```sh
curl -X POST "$00WIDGET_BASE_URL/v1/live-activities/end" \
  -H "Authorization: Bearer $00WIDGET_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "externalActivityId": "ci-build-2026-04-26-1234",
    "finalTitle": "CI build #1234",
    "finalSubtitle": "passed in 4m 12s",
    "finalState": "finished"
  }'
```

Always end. Stale activities clutter the Lock Screen until iOS gives up on them.

## Actions

If your card has buttons, define them as `actions` on the card:

```json
{
  "id": "boiler",
  "template": "action",
  "title": "Boiler",
  "subtitle": "Manual override",
  "value": "Ready",
  "status": "good",
  "icon": "flame",
  "actions": [
    { "id": "boiler-boost-1h", "label": "Boost 1h" }
  ]
}
```

Before buttons can call your system, register your webhook with the same API key used to publish cards:

```sh
curl -X PUT "$00WIDGET_BASE_URL/v1/integrations/webhook" \
  -H "Authorization: Bearer $00WIDGET_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{ "url": "https://example.com/00widget/actions" }'
```

The response includes a `signingSecret`. Store it once; `GET /v1/integrations/webhook` returns the URL and timestamps but not the secret. To rotate, call the same `PUT` with `"rotateSecret": true` and update your verifier before accepting new deliveries. To disable actions, call `DELETE /v1/integrations/webhook`.

When the user taps the button, 00Widget receives `POST /v1/actions/<actionId>/run` from the app/widget, resolves the stored card action, and forwards a signed `POST` to your webhook:

```json
{
  "deliveryId": "018f6e62-3d3c-7c5c-9f7a-...",
  "timestamp": "2026-05-01T12:34:56.789Z",
  "source": "widget",
  "accountId": "tenant_...",
  "action": {
    "id": "boiler-boost-1h",
    "label": "Boost 1h",
    "payload": { "duration": "3600" }
  },
  "context": {
    "cardId": "boiler",
    "cardTitle": "Boiler"
  }
}
```

Headers:

- `X-00Widget-Delivery`: same value as `deliveryId`, for idempotency.
- `X-00Widget-Timestamp`: Unix seconds when the delivery was signed.
- `X-00Widget-Signature`: `sha256=<hex hmac>`.

The HMAC key is the `signingSecret`. The canonical string is:

```text
<X-00Widget-Timestamp>.<raw JSON request body bytes>
```

Reject deliveries whose timestamp is more than 5 minutes from your clock, and dedupe by `deliveryId`. Treat delivery as **at least once**: 00Widget retries network errors and `5xx` responses up to 3 total attempts with short backoff. It does not retry `2xx`, `4xx`, or permanent validation failures. If all attempts fail, the app/widget action run returns `502` and the tap is considered abandoned.

Any `2xx` from your webhook is an ack. An empty body is fine. If you return JSON shaped as either a `DashboardCard` or `{ "card": DashboardCard }`, 00Widget immediately upserts that card and reloads widgets that can display cards; otherwise the response body is ignored. You can also return `2xx` and publish a separate `/v1/cards/upsert` later.

Only `role: "normal"` + `confirm: false` actions run directly from widgets. `confirm: true` and `role: "destructive"` actions are shown in the 00Widget app, where the user can confirm before the app calls the same `/v1/actions/:id/run` path with `source: "app"`.

## Rate limits

00Widget enforces fixed-window rate limits per tenant and, for hot resources, per card/action/activity id. If you receive `429`, stop sending that operation and retry after the `Retry-After` header.

| Operation | Scope | Limit |
| --------- | ----- | ----: |
| Any mutating `/v1/*` write | tenant | 1000 / hour |
| Card upsert | tenant | 600 / hour |
| Card upsert | card id | 60 / hour |
| Live Activity start | tenant | 120 / hour |
| Live Activity update | tenant | 600 / hour |
| Live Activity update | activity id | 120 / hour |
| Live Activity end | tenant | 240 / hour |
| Action run | tenant | 240 / hour |
| Action run | action id | 60 / hour |
| Device/widget/Live Activity token registration | tenant | 240 / day |
| Webhook integration changes | tenant | 20 / day |
| Share mutations | tenant | 120 / day |
| Apple app login token exchange | Apple user | 30 / hour |

Agents should coalesce state changes and avoid hot loops. A good default is to publish a given card no more than once per minute, unless the displayed value materially changed and occasional `429` responses are acceptable.

## Errors

- `401 {"error":"..."}` — bad or missing bearer token. Don't retry with the same key.
- `400 {"error":"validation failed: ..."}` — body shape is wrong. Read the message and fix the JSON; don't retry blindly.
- `404 {"error":"not found"}` — endpoint, card id, action id, or webhook integration doesn't exist.
- `409 {"error":"webhook integration not configured"}` — an action was run before `PUT /v1/integrations/webhook`.
- `429 {"error":"rate limit exceeded", ...}` — wait for `Retry-After` seconds before retrying that operation.
- `502 {"error":"webhook delivery failed", ...}` — the configured action webhook returned `5xx`/failed after retries.
- `5xx {"error":"internal error"}` — backend issue. Retry with exponential backoff if the operation is idempotent (cards are; live-activity updates are by `externalActivityId`).

Error bodies are simple JSON with at least an `error` string; some endpoints add fields like `detail`, `status`, `attempts`, or `deliveryId`. The API never returns secrets in errors and is safe to log.

## Snippets

### TypeScript / JavaScript (Node, Bun, Workers, browser)

```ts
async function upsertCard(card: Record<string, unknown>) {
  const res = await fetch(`${process.env.WIDGET_BASE_URL}/v1/cards/upsert`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.WIDGET_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(card),
  });
  if (!res.ok) throw new Error(`upsert failed: ${res.status} ${await res.text()}`);
  return res.json();
}

await upsertCard({
  id: "build-status",
  template: "summary",
  title: "Build",
  subtitle: "main is green",
  status: "good",
  icon: "checkmark.seal",
  deepLink: "https://example.com/dashboard",
});
```

### Python

```py
import os, requests

def upsert_card(card):
    r = requests.post(
        f"{os.environ['WIDGET_BASE_URL']}/v1/cards/upsert",
        headers={"Authorization": f"Bearer {os.environ['WIDGET_API_KEY']}"},
        json=card,
        timeout=10,
    )
    r.raise_for_status()
    return r.json()
```

### Go

```go
func UpsertCard(card map[string]any) error {
    body, _ := json.Marshal(card)
    req, _ := http.NewRequest("POST", baseURL+"/v1/cards/upsert", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+apiKey)
    req.Header.Set("Content-Type", "application/json")
    resp, err := http.DefaultClient.Do(req)
    if err != nil { return err }
    defer resp.Body.Close()
    if resp.StatusCode >= 300 { return fmt.Errorf("upsert: %d", resp.StatusCode) }
    return nil
}
```

### Swift (host app, not the 00Widget app)

```swift
struct WidgetClient {
    let baseURL: URL
    let apiKey: String

    func upsert(_ card: [String: Any]) async throws {
        var req = URLRequest(url: baseURL.appendingPathComponent("/v1/cards/upsert"))
        req.httpMethod = "POST"
        req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try JSONSerialization.data(withJSONObject: card)
        let (_, resp) = try await URLSession.shared.data(for: req)
        guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
            throw URLError(.badServerResponse)
        }
    }
}
```

## Don'ts

- **Don't** ship UI HTML, layouts, or markdown that you expect 00Widget to render. The server only stores typed state. Pick a template.
- **Don't** create a new card id per publish. Re-use the same `id` to update.
- **Don't** put secrets, API tokens, or PII in `value`/`subtitle`/`title`. Cards are visible on the Lock Screen.
- **Don't** start a Live Activity without ending it. Always send `/v1/live-activities/end` when the work is done.
- **Don't** make destructive actions auto-run from widgets. Set `confirm: true` or `role: destructive` and let the iOS app handle confirmation.
- **Don't** publish more than ~once a minute per card unless the value genuinely changed. Each publish triggers a widget reload via APNs.

## Notes for Cloudflare Workers callers

The 00Widget backend runs on Cloudflare Workers. If the project you're integrating from is *also* a Cloudflare Worker (or a Pages Function), two things change:

### Prefer a Service Binding when same-account

If the integrating Worker and the 00Widget Worker live in the same Cloudflare account, set up a [Service Binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/) rather than calling the public URL. It's faster (no DNS, no TLS), uses the internal Cloudflare network, and skips egress.

In `wrangler.toml` of the *caller*:

```toml
[[services]]
binding = "ZEROZEROWIDGET"
service = "zerozerowidget-server"
```

Then in code:

```ts
const res = await env.ZEROZEROWIDGET.fetch(
  new Request("https://zerozerowidget/v1/cards/upsert", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${env.WIDGET_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(card),
  }),
);
```

The hostname in the URL is irrelevant — Service Bindings route by binding name. Auth headers and JSON bodies are unchanged.

### Sub-request budget

Every Worker invocation has a sub-request quota (50 on free, 1000 on paid). Each `fetch` or Service Binding call to 00Widget consumes one. If your Worker is already making a lot of outbound calls, one upsert per state change is fine; **don't** publish on a hot loop.

### CPU time

Network awaiting (waiting for 00Widget's response) doesn't count against your Worker's CPU limit, but JSON serialization of a giant `DashboardCard` does. The card schema is small enough that this is never a concern in practice — flagged here only so you don't worry about it.

### Non-Cloudflare callers

If you're integrating from Vercel, Lambda, a Cloudflare Pages static site (no functions), a shell script, or anything else, none of the above applies. Just `fetch` the public URL.

## Where to look for more

- The full public API surface is documented in [`server/README.md`](../server/README.md).
- The data model in TypeScript (zod) lives at [`server/src/types.ts`](../server/src/types.ts).
- The same model in Swift (for reference) lives at [`ios/Sources/Shared/Models/`](../ios/Sources/Shared/Models/).

If you're integrating from a project that doesn't have an obvious place to call from, prefer the smallest possible code path: a single function that builds the card JSON and POSTs it. Resist the urge to wrap this in a class hierarchy.
