# Laberty API

Everything Laberty can do from another program: list the printers on your
network, print to them, add households, and email a batch of labels.

The point of the printing endpoints is that **your other tools don't need
their own printer stack**. Laberty already runs a bridge on your network
(see [the print bridge](#the-print-bridge)); anything you write can hand
it bytes and let it deal with drivers, sockets and retries.

- Base URL: `https://laberty.app`
- All paths below are relative to `/api`
- Everything is JSON, `Content-Type: application/json`

---

## Authenticating

Every request needs a bearer token:

```
Authorization: Bearer <token>
```

### API keys (use these)

Make keys in Laberty under **Admin → API keys**, one per program. They
look like `lbk_` followed by 40 characters.

- **Shown once.** Laberty stores only a SHA-256 hash, so a key cannot be
  shown again. If you lose one, revoke it and make another.
- **They never expire.** A key that quietly lapses inside another app is
  an outage nobody sees coming. Access ends when you **revoke** the key,
  and that takes effect on the very next request.
- **Scoped.** Each key is for one job:

| Scope | For | Can call |
|---|---|---|
| **Print bridge** | The Docker bridge on your network | `/agent/*` and `/agent/v1/*` only |
| **Printing** | Your own apps that print | `GET /printers`, `POST /print`, `GET /print/{id}`, `GET /agent/status` (or `/agent/v1/status`) |
| **Full access** | Anything else | Every endpoint except `/device/*` |
| **Screen** | A Laberty Screen by the printer (made on **Admin → Devices**) | `/device/*` only. Nothing else can call `/device/*` |

A scope limits a key. It never goes past the plan: bridge calls need any
paid plan, and everything else needs **Home** (20 API actions a month) or
**Business** (unlimited). See [Limits](#limits-and-what-they-cost).

The **Print bridge** page can make a bridge key for you and fills it into
the Docker commands.

### Older tokens

| Token | Scope | Where it comes from |
|---|---|---|
| **Workspace token** | One workspace, everything | Domain console → Manage → Access |
| **Deployment token** | The primary workspace | The `SKILL_API_TOKEN` secret |

These keep working for anything already set up with them. New programs
should use a key, because a key can be revoked without breaking anything
else.

### Responses

- `401 Unauthorized`: the token is missing, unknown or revoked.
- `403 Forbidden`: the key's scope does not cover that endpoint, or the
  workspace's plan does not include it. The `error` field says which.
  The deployment token is never gated this way.

A key is a password. Treat it like one: keep it out of client-side code
and out of git.

---

## The print bridge

```
your program ──HTTPS──> Laberty (cloud) <──HTTPS── bridge (your network) ──TCP/CUPS──> printer
```

Laberty cannot reach your printers, by design — nothing on the internet
should. So printing is a **queue**: you add a job, the bridge picks it up
within a few seconds, prints it, and reports the result back.

That means `POST /print` returning `201` means *queued*, not *printed*.
Poll `GET /print/{id}` if you need to know it actually came out.

Where a job goes is decided by the printer's record in Laberty — you type
its IP address (or print-queue name) once, under **Printers & stock**, and
every job for that printer carries that address. One bridge serves every
printer on the network.

### The bridge's own endpoints are frozen

Everything under `/agent/*` is also served at **`/agent/v1/*`** — the same
handlers, mounted twice. `/agent/v1/*` is a **frozen contract**: inside v1 no
field is ever removed, renamed, or changed in type, and new behaviour arrives
only as new optional fields. That is what lets a customer install the bridge
container once and leave it running for years while Laberty changes
underneath it.

The full specification, including what the bridge must do to hold up its end,
is **[docs/BRIDGE-PROTOCOL-V1.md](BRIDGE-PROTOCOL-V1.md)**. You only need it
if you are writing your own bridge; to *print* from your own program, use
`POST /print` below and let the bridge deal with printers.

The bridge also tells Laberty what it is — its version, what protocols it can
drive, and whether it is running in a container. Laberty then **never hands a
bridge a job it cannot do**: such a job waits in the queue with a reason
instead of failing. A bridge that reports nothing is assumed to be able to do
everything, so older bridges behave exactly as they always have.

---

## Printing

### `GET /printers`

Lists the workspace's printers: what type each is, what it accepts, and
what Laberty believes is loaded in it.

```bash
curl https://laberty.app/api/printers \
  -H "Authorization: Bearer $LABERTY_API_KEY"
```

```json
{
  "bridge": { "online": true, "lastSeenAt": "2026-09-13 14:02:11" },
  "printers": [
    {
      "id": 1,
      "name": "Kitchen QL-820NWB",
      "isDefault": true,
      "type": "label",
      "model": "Brother QL-820NWB",
      "brand": "Brother",
      "protocol": "agent-brother-ql",
      "accepts": ["pdf"],
      "host": "192.168.1.50",
      "port": 9100,
      "cupsQueue": null,
      "maxWidthIn": 2.4,
      "loadedStock": {
        "id": 3, "code": "2205", "name": "Continuous 2.4in roll (2205)",
        "kind": "continuous", "widthIn": 2.4, "heightIn": null, "dkPart": "DK-2205",
        "confirmed": true, "confirmedVia": "switch"
      }
    },
    {
      "id": 2,
      "name": "Front counter receipt",
      "isDefault": false,
      "type": "receipt",
      "model": "Epson TM-T88VII",
      "brand": "Epson",
      "protocol": "agent-escpos",
      "accepts": ["escpos", "raw"],
      "host": "192.168.1.50",
      "port": 9100,
      "cupsQueue": null,
      "maxWidthIn": 3.15,
      "loadedStock": null
    }
  ]
}
```

- `type` is `label`, `receipt` or `tape`. Send `label` jobs to label
  printers and `receipt` jobs to receipt printers.
- `accepts` lists the raw `kind` values that printer understands:

| `protocol` | `accepts` | How it prints |
|---|---|---|
| `agent-brother-ql` | `pdf` | Brother raster to the printer's IP. The bridge checks the loaded roll first |
| `agent-cups` | `pdf` | Through the bridge machine's print system (needs a driver): DYMO, Rollo USB, anything with a CUPS queue |
| `agent-zpl` | `zpl`, `pdf`, `raw` | Raw bytes to the printer's IP, port 9100 by default. A `pdf` is drawn in the printer's dots by the bridge and sent as a ZPL graphic, so any PDF label prints on a Zebra |
| `agent-escpos` | `escpos`, `raw` | Same, for receipt printers (`escpos` is an older spelling of the same thing) |
| `agent-ipp` | `pdf` | IPP to the printer's IP, port 631 by default, with no driver: inkjets, lasers, AirPrint label printers. Only printers that accept PDF over IPP; see `unsupported_format` below |
| `share-sheet` | `pdf` | Not through the bridge: printed from a phone or tablet |

- `dpi` is the printer's density when Laberty knows it (`300` for a
  ZD421-300, `203` for most thermal printers, `null` for an inkjet). A ZPL
  layout is measured in dots, so it matters there.
- `loadedStock.confirmed` is `true` when Laberty knows **for sure** that this
  roll is in the printer. That is a physical act at the printer, on a Laberty
  device: a roll switch or selector, or a confirm button. Admin's Swap roll and
  `POST /printers/{id}/loaded-stock` with `"confirmed": true` confirm it only
  when the workspace allows them (Admin → Printers & stock → "Who can confirm
  a roll"); otherwise they record the roll as "not sure". Then any job that
  needs a different stock is refused before it is queued (see
  [When the roll is confirmed](#when-the-roll-is-confirmed)). `confirmedVia` is
  `device`, `switch`, `admin` or `api` (`screen` on older rows), or `null` when
  not confirmed. Not confirmed ("not sure") means jobs are sent and the
  printer's own errors are reported, as always.
- `loadedStock.sheet` appears when a **sheet** of labels is loaded (Avery
  and similar): `columns`, `rows`, `perSheet`, `labelWidthIn`,
  `labelHeightIn`, `topIn`, `leftIn`, `hPitchIn`, `vPitchIn` and `page`
  (inches, measured from the top-left of a Letter page).

### `POST /print`

Queues one job. Say **what** to print in one of three ways:

| Field | For | Notes |
|---|---|---|
| `label` | Label printers | `{ "lines": ["Jane Smith", "12 Oak St"], "align": "center", "bold": false }`. Laberty renders plain, legible text at the exact size of the labels (see `stock`). Up to 20 lines, shrunk to fit. On a ZPL printer this is native ZPL at the printer's dpi (`kind: "zpl"`); on sheet stock it is placed on the sheet, with optional `position` and `count` (see [Sheets of labels](#sheets-of-labels-avery)) |
| `receipt` | Receipt printers | `{ "lines": [...], "cut": true, "openDrawer": false }`. See below |
| `kind` + `payloadB64` or `text` | Anything | Your own bytes. `kind` is `pdf`, `zpl`, `escpos` or `raw`. `text` works for the text protocols |

And optionally:

| Field | Notes |
|---|---|
| `printerId` | Defaults to the workspace's default printer |
| `stock` | **The labels this job is for.** A stock id, its code (`"2205"`, `"5160"`), its DK part number (`"DK-2205"`) or its product number. If the printer has anything else loaded, the job is refused. See below |
| `copies` | 1–50, default 1 |
| `detail` | A short description, shown in the queue and the activity log |

Header **`Idempotency-Key`**: send a unique value per real-world print
(an order number, say). If your request times out and you retry with the
same key, you get the first job back (`200`, `"duplicate": true`) instead
of a second label.

```bash
# Three lines on the 2.4" roll -- refused if something else is loaded
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: food-1042" \
  -d '{
        "printerId": 1,
        "stock": "2205",
        "label": { "lines": ["Chili", "Made Sep 13", "Use by Sep 20"] }
      }'
```

```json
{
  "jobId": 88,
  "status": "queued",
  "kind": "pdf",
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "stock": { "id": 3, "code": "2205", "name": "Continuous 2.4in roll (2205)", "kind": "continuous", "widthIn": 2.4, "heightIn": null, "dkPart": "DK-2205" },
  "statusUrl": "/api/print/88"
}
```

**Receipts.** Each item in `lines` is one of:

| Item | Prints |
|---|---|
| `"plain text"` | A left-aligned line, wrapped to the paper |
| `{ "text": "TOTAL", "align": "center", "bold": true, "size": "big" }` | `align`: left, center, right. `size`: normal, tall, wide, big |
| `{ "key": "Coffee", "value": "$3.00" }` | `Coffee..............$3.00` across the full width |
| `{ "rule": true }` | A full-width line of dashes (or `"rule": "="`) |
| `{ "feed": 2 }` | Blank lines |

```bash
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "printerId": 2,
        "receipt": {
          "lines": [
            { "text": "LIBANY", "align": "center", "size": "big" },
            { "rule": true },
            { "key": "Coffee", "value": "$3.00" },
            { "key": "TOTAL", "value": "$3.00" },
            "Thank you"
          ],
          "openDrawer": true
        }
      }'
```

```bash
# Your own bytes: ZPL as text, or a PDF you generated
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "printerId": 3, "kind": "zpl", "text": "^XA^FO20,20^A0N,30,30^FDORDER 1042^FS^XZ" }'
```

#### Labels on a Zebra

A `label` sent to an `agent-zpl` printer is written as ZPL, not as a
picture: 4in labels at 300 dpi are `^PW1200`, each line is centred by the
printer, and non-ASCII text is sent as UTF-8 (`^CI28`). It is sized to the
loaded stock (`stock`, or what is recorded as loaded); with nothing
recorded, it is as wide as the print head allows (up to 4in) and as long
as the text needs. The job's `kind` is `zpl`.

A `pdf` job for a ZPL printer works too: the bridge draws it at the
printer's dpi and sends it as a `^GFA` graphic, turning it a quarter turn
if it is wider than the print head. That needs a bridge image built from
the current `agent` folder.

#### Sheets of labels (Avery)

When the labels are a sheet (`stock.kind` is `sheet`), a `label` is
placed on the sheet at the template's exact position and the job is a
Letter-size PDF. Print it at 100%: the bridge asks IPP printers for that
(`print-scaling: none`) when they support it.

| Field | Notes |
|---|---|
| `label.position` (or `label.startPosition`, or top-level `position` / `startPosition`) | Which label on the sheet to start at, 1 to `perSheet`, counted left to right then top to bottom. Default 1. Use it to finish a half-used sheet |
| `label.count` | How many copies of this label to place (1–500). They carry on onto further sheets. Default 1. (`copies` still prints the whole job that many times) |

```bash
# A sheet of return-address labels, starting after the 12 already used
curl -X POST https://laberty.app/api/print \
  -H "Authorization: Bearer $LABERTY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "printerId": 4, "stock": "5167", "label": { "lines": ["Ada Lovelace", "1428 Rosemont Ave"], "count": 68, "position": 13 } }'
```

A `position` outside the sheet is a `400` with `code: "bad_position"`.
Laberty knows Avery 5160/8160, 5161, 5162, 5163, 5164, 5167, 5195, 22805
and 8126 by number; an admin adds them under Printers & stock → Add from
catalog, which also offers a printable alignment sheet.

#### When the wrong labels are loaded

Pass `stock` and Laberty compares it with what is recorded as loaded
**before anything is rendered or queued**:

```json
409
{
  "code": "stock_mismatch",
  "error": "Kitchen QL-820NWB has Continuous 2.4in roll (2205) loaded, but this job is for Large Address (208). Nothing was printed. Swap the labels, update what is loaded, and send the job again.",
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "loaded": { "id": 3, "code": "2205", "...": "..." },
  "requested": { "id": 2, "code": "208", "...": "..." }
}
```

The workspace's admins are emailed at the same time ("Load Large Address
(208) in Kitchen QL-820NWB"), at most once per printer and label every half
hour. Show your user the `error` text, or build your own message from
`loaded` and `requested`.

Laberty's record can be out of date: someone swaps a roll by hand, or
prints from the Brother app. So before it sends anything, the bridge
**asks the printer what is physically loaded**, three ways, stopping at
the first real answer:

1. **IPP** `Get-Printer-Attributes` on port 631 — `media-ready` and
   `media-col-ready` (the exact size, in hundredths of a millimetre).
2. **SNMP** on UDP 161 — the standard Printer MIB's input tray.
3. **Brother's `ESC i S`** status request on port 9100, last, because some
   QL firmware ignores it over the network.

If the printer answers and it disagrees, the job is **never sent**: it is
reported `stock_mismatch` and waits (see `GET /print/{id}`), the printer is
left untouched rather than pushed into an error state, and the admins get
the same kind of email. If the printer answers and agrees, that answer is
also what Laberty records as loaded, **confirmed** — the printer outranks
anything a person or an app said.

Each question has a 2.5-second limit and the answer is cached for 30
seconds, so a batch of labels asks once. A printer that answers none of
the three is printed to exactly as before, so for such a printer **keep
Laberty's loaded stock accurate** (Printers & stock, or
`POST /printers/{id}/loaded-stock`) — that record is then the only
protection, along with the question Laberty asks whoever presses Print
when it is not sure.

#### When the roll is confirmed

If the printer's `loadedStock.confirmed` is `true`, Laberty refuses any job
that needs a different stock, **whether or not you pass `stock`**. Without
`stock`, a `pdf` whose page is exactly one of the workspace's die-cut label
sizes needs that stock. A `label` is sized for the loaded roll, so it always
fits. Raw `zpl`, `escpos` and `raw` need nothing in particular.

```json
409
{
  "code": "stock_mismatch",
  "confirmed": true,
  "error": "Kitchen QL-820NWB has Continuous 2.4in roll (2205) confirmed loaded, but this job needs Large Address (208). Nothing was printed. ...",
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "loaded": { "id": 3, "name": "Continuous 2.4in roll (2205)", "via": "switch" },
  "needed": { "id": 2, "name": "Large Address (208)" }
}
```

With `stock` passed, the body is the one above plus `confirmed`, `needed` and
`loaded.via`. When the roll isn't confirmed, `confirmed` is `false`.

A job queued earlier is checked again when the bridge picks it up. If a
different roll has been confirmed since, the job is never sent. It fails with
`errorCode: "stock_mismatch"` and an `error` starting `not sent:`, and the
admins are emailed. The bridge moves on to the next job. (Occasion labels, and
labels someone released from Ready to print, wait as held instead.)

If the printer itself then reports `stock_mismatch` on a confirmed roll,
Laberty clears the confirmation and emails the admins.

#### Errors

| Status | `code` | Meaning |
|---|---|---|
| `400` | `unknown_stock` | No label stock matches `stock`. `GET /label-stock` lists them |
| `400` | `bad_position` | A sheet `position` that is not a whole number from 1 to the labels per sheet |
| `400` | (none) | Missing or invalid body, payload, base64 or lines, or a workspace with no printers |
| `404` | (none) | Unknown `printerId` |
| `409` | `stock_mismatch` | Different labels are loaded (above). `confirmed: true` when that roll is confirmed, even if you didn't pass `stock` |
| `409` | `stock_unknown` | Nothing is recorded as loaded in that printer. Set it, then retry |
| `413` | (none) | Payload over about 1 MB. A label is a few KB |
| `422` | `wrong_kind` | The job does not suit the printer: a receipt to a label printer, a PDF to a receipt printer. `accepts` is included |
| `429` | (none) | Over 60 jobs a minute (`Retry-After` header) |

### `POST /printers/{id}/loaded-stock`

Tells Laberty which labels are now in a printer, e.g. after your app asks
someone to swap the roll. Body: `{ "stock": "208" }` (id, code or DK part),
plus `"confirmed": true` if your integration truly knows it. A confirmed roll
refuses every job for another stock (409, above). Without `confirmed`, the
roll is recorded as "not sure", even if it was confirmed before. A removed
stock is never confirmed.
**Full access keys only**, because it changes what every later job is
checked against.

By default only a Laberty device at the printer can confirm a roll. Unless an
admin has ticked **Apps confirm it through the API** (Admin → Printers &
stock), `"confirmed": true` still records the stock, as not sure, and the
answer says `"confirmed": false` with a `confirmNote` saying why.

```json
{ "printer": { "id": 1, "name": "Kitchen QL-820NWB" }, "loadedStock": { "id": 2, "code": "208", "...": "...", "confirmed": true, "confirmedVia": "api" }, "confirmed": true }
```

```json
{ "printer": { "id": 1, "name": "Kitchen QL-820NWB" }, "loadedStock": { "id": 2, "code": "208", "...": "...", "confirmed": false, "confirmedVia": null }, "confirmed": false,
  "confirmNote": "Recorded as not sure: this workspace confirms rolls only from a Laberty device at the printer. An admin can allow the API to confirm rolls under Admin → Printers & stock." }
```

### `GET /print/{id}`

```json
{
  "jobId": 88,
  "status": "failed",
  "kind": "pdf",
  "copies": 1,
  "printerId": 1,
  "detail": "API label",
  "source": "Libany",
  "error": "Wrong labels in \"Kitchen QL-820NWB\": it has 29 x 90mm die-cut labels, but this job needs the 62mm continuous roll. ...",
  "errorCode": "stock_mismatch",
  "queuedAt": "2026-09-13 14:02:11",
  "claimedAt": "2026-09-13 14:02:14",
  "completedAt": "2026-09-13 14:02:15"
}
```

`status` is one of `queued`, `claimed`, `done`, `failed`. `source` is
the name of the API key that queued the job. When the job has `failed`,
`error` says why in plain language, and `errorCode` is set when the cause
is one your program can act on:

| `errorCode` | What happened | What to tell the user |
|---|---|---|
| `stock_mismatch` | The printer reported different labels from the job's, or (with `error` starting `not sent:`) a different roll was confirmed loaded, so Laberty never sent it. When the **printer itself** refused it, the job's `status` becomes `held` with an `error` starting `Waiting for` — it prints when the right roll goes in, and nothing was sent to the printer | Load the right labels, confirm them, and print again |
| `no_media` | The printer is empty | Load labels |
| `printer_error` | Cover open, cutter jam, and so on (the text says which) | Fix it at the printer |
| `unreachable` | The bridge could not reach the printer | Is it on, and on the bridge's network? |
| `no_address` | The printer has no IP address in Laberty | An admin needs to add it |
| `unsupported_format` | An IPP printer does not accept PDF (it only takes PWG raster or Apple URF, as many AirPrint-only printers do) | Print from a phone or tablet with the share sheet, or set the printer up as a CUPS queue on the bridge machine |
| `null` | Anything else | Show `error` |

For the first three, nothing was printed and the admins were emailed.
Every coded failure emails the admins, at most once per printer and
problem every half hour.

On an IPP printer, `no_media` and `printer_error` come from what the
printer reports (`printer-state-reasons`) when it is **stopped**: out of
paper, a jam, a door open. Low ink or toner is only logged. A printer that
is not stopped is sent the job even if it mentions a problem, since an
empty second tray does not stop it printing. The bridge reports `done`
once the printer accepts the job; IPP does not say when the paper comes
out.

A job stuck on `claimed` means the bridge took it and then died. Once it
has been claimed for 15 minutes, it goes back to `queued` the next time
any bridge for the workspace asks for work (and a daily sweep catches the
rest), so it prints when the bridge is back. Sending it again as well
prints it twice.

### `GET /print`

Recent jobs, newest first, without their payloads.
`?status=failed&printerId=1&limit=25` (`limit` up to 100).

```json
{ "jobs": [ { "jobId": 88, "status": "failed", "errorCode": "stock_mismatch", "source": "Libany", "...": "..." } ] }
```

### `GET /agent/status`

Check the bridge is alive *before* queueing, so your own UI can say
"printer offline" instead of silently piling up jobs.

```json
{
  "queued": 0,
  "claimed": 0,
  "done": 214,
  "failed": 2,
  "lastAgentSeenAt": "2026-09-07 18:02:14",
  "agentLikelyOnline": true
}
```

`agentLikelyOnline` is true when the bridge claimed something in the last
five minutes. It is a hint, not a guarantee — an idle bridge that has had
no work for an hour reports `false` while being perfectly healthy.

### `GET /agent/v1/config`

What the bridge should be doing, answered by Laberty rather than baked into
the installed container: poll interval, timeouts, retry and backoff, log
level, heartbeat, and feature switches. It means the installed bridge holds
only three things — the Laberty URL, its key, and a cache of this — so its
behaviour can be changed without anyone touching the machine it runs on.

```json
{
  "configVersion": 1,
  "pollSeconds": 5,
  "requestTimeoutSeconds": 30,
  "jobTimeoutSeconds": 180,
  "heartbeatSeconds": 60,
  "configRefreshSeconds": 300,
  "retry": { "initialSeconds": 5, "maxSeconds": 300, "factor": 2, "jitter": 0.2 },
  "logLevel": "info",
  "features": {
    "probeMedia": true,
    "probeOrder": ["ipp", "snmp", "escp"],
    "snmp": true,
    "reportPrinters": true,
    "maxProbesPerPoll": 4
  },
  "minBridgeVersion": "1.0.0"
}
```

- **Every value is clamped**, in Laberty and again in the bridge, so no
  setting can tell a bridge to poll every 0 seconds or wait forever on a
  socket. The worst a bad config can do is choose a different sane number.
- **The bridge caches the last good copy on disk** and falls back to its
  built-in defaults — the same numbers — if this is unreachable or the
  answer is unusable. A bridge that has never once reached this endpoint
  prints perfectly well.
- **`minBridgeVersion` is informational and never enforced.** Refusing an old
  bridge is exactly the breakage the contract exists to prevent.

### `POST /agent/printers/{id}/media`

**Print bridge keys only** (a Full access key is refused with `403`), and
only for a printer in that key's own workspace (anything else is `404`).
This is how the bridge tells Laberty what a printer says is in it, with no
job involved — at startup, and every few minutes afterwards.

```json
{
  "media": {
    "ok": true,
    "source": "ipp",
    "mediaName": "om_62x100mm_62x100mm",
    "widthMm": 62, "lengthMm": 100, "dieCut": true,
    "printerState": "idle",
    "stateReasons": [],
    "raw": "media-ready: om_62x100mm_62x100mm\nmedia-col-ready: {...}"
  }
}
```

`source` is `ipp`, `snmp` or `escp`. Send `{"media": null, "error": "it did
not answer"}` when the printer said nothing: Laberty records that it was
asked, and changes nothing else.

```json
{
  "ok": true,
  "printer": { "id": 1, "name": "Kitchen QL-820NWB" },
  "reported": "62 x 100mm die-cut",
  "matched": { "id": 2, "name": "Shipping (1202)" },
  "confirmed": true
}
```

- A report that **matches** one of the workspace's label sizes (by DK part
  number, by measurement to within a hundredth of an inch, or by the
  Brother label both work out to) becomes that printer's loaded stock and
  is marked **confirmed**, with `confirmedVia: "printer"`. That always
  counts, whatever "who can confirm a roll" allows: it is the printer.
- A report that **matches nothing** is kept and shown on the printer's card
  (`matched: null`), and the recorded roll is left exactly as it was.
- `503` means this Laberty has not had migration 030 applied yet.

The same object may be sent as `loadedMedia` on
`POST /agent/jobs/{id}/done`, on a success as well as on a failure:

```json
{ "ok": false, "code": "stock_mismatch", "error": "Wrong labels in …",
  "loadedMedia": { "source": "escp", "widthMm": 29, "lengthMm": 90, "dieCut": true, "…": "…" } }
```

`GET /agent/jobs` answers with `mediaProbes` alongside the job when
Laberty wants printers asked — each entry is `{ printerId, target, asked }`,
where `target` is the same shape a job carries. The bridge asks them after
it has printed, so a question never delays a label.

---

## Laberty Screens (`/device`)

What a Laberty Screen calls: a touchscreen, rotary knob, e-ink sign or
two-button box next to the printer (firmware in `hardware/laberty-screen/`),
or `/screen` in a browser. Only a **Screen** key can call these, and a Screen
key can call nothing else. Screens are gated like the print bridge (any paid
plan), and **no** `/device` call counts against the monthly API actions.

A switched-off screen (Admin → Devices) can still read its state, but every
`POST` returns `403 { "code": "screen_off" }`.

### `GET /device/state`

Everything the screen draws, and its heartbeat (Admin → Devices shows it
online). Names and label titles only: never a street address or a payload.
About 3–6 KB. Add `?view=compact` for the small devices: well under 2 KB, with
no clock or ages.

Every answer has `stateVersion`, also sent as the `ETag` header. Send it back
as `If-None-Match` and you get `304` with no body when nothing the screen
shows has changed. The clock and "3 min ago" ages don't change the version,
so an e-ink sign only refreshes when something real does.

```json
{
  "v": 1,
  "workspace": "Family",
  "now": "2026-09-13T18:04:00.000Z", "localTime": "1:04 PM", "localDate": "Sun, Sep 13",
  "bridge": { "online": true, "lastSeen": "just now" },
  "printers": [{
    "id": 1, "name": "Kitchen QL-820NWB", "kind": "label", "default": true,
    "status": "ready", "statusText": "Ready",
    "loadedStock": { "id": 2, "name": "Large Address (208)", "confirmed": true, "via": "switch" },
    "rollNeeded": { "count": 0, "needs": [] },
    "swapStocks": [{ "id": 3, "name": "Continuous 2.4in roll (2205)" }]
  }],
  "jobs": [{ "id": 812, "title": "Address labels for Rose (5)", "status": "failed", "errorCode": "no_media", "printer": "Kitchen QL-820NWB", "age": "4 min ago" }],
  "held": [{ "id": 815, "title": "Occasion: Birthday — Ada Lovelace (Sept 30)", "stockName": "Large Address (208)", "loadedMatches": true }],
  "heldCount": 1,
  "heldText": "1 label ready to print — load Large Address (208)",
  "occasions": [{ "id": 4, "title": "Ada & Grace Lovelace — Anniversary", "date": "Sept 30", "when": "in 17 days", "daysAway": 17, "labelDate": "Sept 23", "label": "label Sept 23" }],
  "pendingRoll": { "printerId": 1, "stockId": 3, "name": "Continuous 2.4in roll (2205)", "expiresInSeconds": 94 },
  "screen": { "id": 1, "name": "Kitchen screen", "kind": "touch", "confirmButton": false, "enabled": true, "sleepAfterSeconds": 120, "quietStart": "21:30", "quietEnd": "06:30", "quietNow": false, "pollSeconds": 15, "wake": true },
  "stateVersion": "9f2c41d07a3b55e1"
}
```

- `jobs`: the latest 8, never held ones. `status` is `queued | claimed | done | failed | cancelled`.
- `held`: up to 6 labels waiting for someone to press Print; `heldCount` is all of them.
- `printers[].status`: `ready | bridge_offline | needs_setup | phone_only`, or the last failure's error code (`no_media`, `unreachable`, …). `statusText` says it in words.
- `printers[].loadedStock.confirmed`: the roll is confirmed, so jobs for any other roll are refused. `via` is `device | switch | admin | api` (`screen` on older rows), or `null` when not confirmed.
- `pendingRoll`: a roll this device picked with `selectRoll` that nobody has confirmed yet, or `null`. It is forgotten after 2 minutes (`expiresInSeconds`, which doesn't change `stateVersion`), and used up by `confirmRoll`.
- `screen.confirmButton`: a control on this device is bound to `confirmRoll`. Without one, a touch UI's own Confirm records the roll as not sure.
- `printers[].rollNeeded`: rolls that jobs are waiting for. `count` is the held jobs, plus jobs that failed in the last 24 hours, that the confirmed-roll check refused (`stock_mismatch`) and whose stock isn't in the printer now. `needs` lists those stocks as `{ id, name }`.
- `occasions`: the next 5. `label` says when the label goes out (`label Sept 23`, `label today`, `label sent`).
- `screen.quietNow` is worked out on the server in the account's time zone. `screen.wake` is true when something is waiting (held labels, or a print failed in the last hour) outside quiet hours. A screen should wake once per new thing.

Compact (`?view=compact`) has `workspace`, `localDate`, `bridge.online`, the default
`printer` (with `loadedStock`, `rollConfirmed`, `needsRoll` (`{ count, name }` or
`null`) and up to 3 `swapStocks`), `heldCount`, `heldText`,
up to 5 `held`, `failedCount`, `lastJob`, up to 3 `occasions`, `pendingRoll`,
`screen` and `stateVersion`, with titles shortened to 32 characters.

`screen.profileVersion` goes up every time the device is saved in Admin →
Devices (0 = never saved). When it changes, fetch `GET /device/config`. A
device doesn't have to be flashed again when only what its controls DO
changed: see `layoutVersion` under `POST /device/commands`.

### `GET /device/config`

The device's saved profile, as the firmware needs it: which board, the
display settings, every input and output with its pins and settings, and what
each button, switch, knob or sensor is bound to. Built in Admin → Devices (the
Device Builder). A device that was never given a profile gets the default for
its kind (a touchscreen, a knob, an e-ink sign or two buttons), with
`profileVersion` 0.

Nothing only the server should know is in it: no workspace or user details, no
revision history, and never a webhook's URL (Laberty calls webhooks itself).

```json
{
  "v": 1,
  "profileVersion": 3,
  "name": "Hall box",
  "kind": "buttons",
  "board": { "id": "esp32-devkitc", "chip": "esp32", "display": null },
  "display": { "use": false, "layout": "status", "rotation": 0 },
  "inputs": [
    { "id": "go", "type": "button", "label": "Print", "pins": { "pin": 25 }, "config": { "activeLow": true, "pullup": true, "debounceMs": 50, "longPressMs": 1000 } }
  ],
  "outputs": [
    { "id": "lamp", "type": "led", "label": "Labels waiting", "pins": { "pin": 27 }, "config": { "activeLow": false, "brightness": 100 },
      "states": { "readyLabelsWaiting": { "pattern": "slowBlink" } } }
  ],
  "bindings": [
    { "id": "go.press", "control": "go", "event": "press", "trigger": "press", "command": "releaseReadyLabels", "args": { "which": "all" } },
    { "id": "go.longPress", "control": "go", "event": "longPress", "trigger": "longPress", "command": "relayPulse", "args": { "controlId": "power", "durationMs": 3000 },
      "longPress": true, "disabled": true, "reason": "plan" }
  ],
  "pollSeconds": 15,
  "configVersion": "5b1e0c7a2f9d4e61"
}
```

- `bindings[].disabled` is `true` when the device may not run that binding right now. `reason` is `plan` (an advanced command on a plan without them) or `off` (the command is switched off on this Laberty). Show it, don't send it.
- `longPress: true` marks a command that only ever runs from a long press (or a switch).
- `configVersion` is also the `ETag`. Send it back as `If-None-Match` for a `304` with no body.
- At most one fetch every 5 seconds per device: sooner is `429 { "code": "rate_limited" }` with `Retry-After: 5`. A `304` counts as a fetch.

The state also has `bridge.paused` (someone paused the print bridge from a
device or Admin) and `outbox`: things Laberty is asking this device to do.

### `POST /device/commands`

Runs one control's binding. The device says **which** control and gesture;
Laberty looks the command and its settings up in the device's **current
saved** profile, so a device key can never run anything that isn't bound on
that device, and nothing in the body can change a command's arguments.

```json
{ "requestId": "1a2b3c4d-17", "bindingId": "go.press", "gesture": "press", "holdMs": 0, "layoutVersion": 2, "profileVersion": 3 }
```

- `requestId`: 1-64 of `A-Z a-z 0-9 _ -`, unique per press (the firmware uses its boot id plus a counter). The same `requestId` again within 24 hours gets the first answer back with `"replayed": true`, and nothing runs twice.
- `bindingId`: `bindings[].id` from `GET /device/config` (`control.event`).
- `gesture`: the binding's trigger (`press`, `longPress`, `doublePress`, `switchOn`, `switchOff`, `turn`, `position`, `threshold`). `holdMs`: how long it was held.
- `layoutVersion` (optional): the profile version the firmware's controls and pins were built from. An ESPHome export sends the version it was exported from.
- `profileVersion` (optional): the version the device last heard about (from `/device/state` or an earlier answer). Used as the build version when there is no `layoutVersion`.
- `value`: the reading, for an analog / 4-20 mA `above` / `below` binding. `position`: optional, a selector's position id.

**Changes apply live.** When the build version is older than the current
profile, the press still runs, with the **current** binding's command and
settings, as long as that control is wired the same (same type, pins,
built-in part and selector positions) and that event is still bound the same
kind of act. Otherwise it's `409 profile_changed`, with `reason`:

- `layout` or `control_gone` (`"reflash": true`): the control was rewired, is new, or was removed. Nothing can change wiring remotely: export or flash the firmware again.
- `binding_gone`: nothing is bound to that event any more.
- `trigger`: the event is a different kind of act now, or the gesture sent isn't what it is.

Every answer, success or refusal, carries the current `profileVersion`, so
firmware notices a change without waiting for its next poll. (The older
per-job routes answer with it too.)

The touch UI may instead send `{ "requestId", "command", "params" }` for these
commands only: `releaseJob`, `skipJob`, `retryJob`, `cancelJob`, `swapRollTo`,
`selectRoll`, `confirmRoll`, `releaseReadyLabels`, `skipReadyLabels`,
`retryLastFailedJob`, `reprintLastJob`, `printPreset`, `printTestPage`,
`swapRollNext`, `swapRollPrevious`, `pauseBridge`, `resumeBridge` (plus
`gesture` and `holdMs` for the long-press ones). A command sent by name is a
**tap**: a roll it records is never confirmed (the answer carries
`confirmNote`). The knob, e-ink sign and button box firmware send their
per-label actions this way too; from those devices (`screen.kind` not
`touch`), `skipJob` and a forced `releaseJob` (`"force": true`, "print
anyway") need `gesture: "longPress"` with `holdMs` of at least 1000.

The per-job routes (`POST /device/jobs/:id/release|skip|retry|cancel`,
`POST /device/printers/:id/loaded-stock`) keep working for firmware flashed
before; new firmware doesn't use them.

```json
200
{ "ok": true, "requestId": "1a2b3c4d-17", "command": "reprintLastJob",
  "result": { "jobId": 912, "jobStatus": "queued", "reprintOf": 905 },
  "display": { "text": "Reprinting", "ttlMs": 4000 },
  "led": { "output": "lamp", "mode": "flashTwice", "ms": 800 },
  "profileVersion": 3 }
```

A webhook answers `202` (Laberty sends it right after answering). Every
refusal has `ok: false`, `code`, `error`, `display` and `led` too:

| Status | `code` | When |
|---|---|---|
| 400 | `invalid_request` | Bad JSON, `requestId`, `bindingId`, gesture, or a reading without `value` |
| 403 | `screen_off` | The device is switched off in Admin → Devices |
| 403 | `plan_required` | Advanced commands (relay, threshold, webhook) need Business; checked on every press |
| 403 | `command_not_allowed` | Switched off on this Laberty, or not on the touch UI's list |
| 404 | `unknown_binding` | No such binding in the saved profile |
| 404 | `not_found` | Nothing waiting, no such saved label / webhook / relay, or nothing failed |
| 409 | `profile_changed` | `{ profileVersion, reason, reflash }`: this control was rewired (`reflash: true`: flash again), or its event is no longer bound the same way (fetch the config) |
| 409 | `long_press_required` | `{ minHoldMs }`: skip, cancel, pause, not-sure-which-roll, relay and webhook need a long press (or a switch / selector position where allowed); so do a skip or "print anyway" sent by name from a knob, e-ink sign or button box |
| 409 | `wrong_status` / `duplicate` / `nothing_to_reprint` / `not_in_swap_list` / `stock_mismatch` / `no_roll` | `duplicate` carries `jobId` (the same label within 10 seconds). `no_roll`: `confirmRoll` with nothing picked and nothing recorded as loaded |
| 400 | `not_in_swap_list` / `stock_removed` | A roll command named a stock that isn't on that printer's list, or was removed |
| 410 | `payload_gone` | That label's data was cleared (after 7 days) |
| 422 | `threshold_not_crossed` | The reading isn't past the level, or the threshold hasn't re-armed |
| 429 | `rate_limited` | `{ retryAfterSeconds, scope }` and `Retry-After`. `scope`: `burst` (5 in 2 s), `device_minute` (30), `device_hour` (300), `disruptive` (6 a minute), `print` (the per-minute print limit), `webhook_minute` (6), `webhook_day` (200 per account) |

What each command does:

- `releaseReadyLabels` / `skipReadyLabels`: the oldest held label, or all of them.
- `retryLastFailedJob`: the latest failed job that still has its label data.
- `reprintLastJob`: a new copy of the latest printed job from a person, program or occasion (never a device's own reprint).
- `printPreset`: a saved label (Admin → Devices → Saved labels): text re-rendered with `{date}` and `{time}`, or a kept copy.
- `printTestPage` (`printerId`?): the same test page as Admin's printer card (a test label, receipt, alignment sheet or page, in the printer's own language), on that printer or the default one. Queued as a device job: the 10-second duplicate guard, the print rate limit and the confirmed-roll check apply. The answer has `jobId` and `printer`.
- `swapRollNext` / `swapRollPrevious`: the next or previous roll on the printer's swap list (removed rolls skipped), not confirmed.
- `setLoadedRoll` / `swapRollTo`: one particular roll. From a switch or selector position it is confirmed (`via: "switch"`), from a bound button `via: "device"`; sent by name (a tap) it is not sure.
- `selectRoll` (`stockId`?, `printerId`?): picks a roll on this device without changing the printer. It shows up as `pendingRoll` in the state for 2 minutes. With no `stockId`, each press or turn steps round the printer's swap list (`turnCcw` goes back). The answer has `pendingRoll` and says "Selected: …".
- `confirmRoll` (`printerId`?): the physical confirm button. Records the picked roll as loaded and **confirmed** (`via: "device"`) and clears the pick; with nothing picked it confirms the roll already recorded as loaded. A removed roll is `400 stock_removed`, one no longer on the swap list `400 not_in_swap_list`, and no roll at all `409 no_roll`. Sent by name from a touch UI it records the roll as not sure, with `confirmNote`, and never undoes a confirmation of the same roll.
- "Not sure" (`clearRollConfirmation`) is always allowed.
- `pauseBridge` / `resumeBridge` / `toggleBridge`: while paused the bridge gets `{ "job": null, "paused": true }` from `GET /agent/jobs` and Print on the iPad opens its print dialog. `resumeAfterMinutes` (up to 480) resumes it by itself.
- `relayPulse`: the device pulses its own relay after the 2xx (`led.ms` is the length, at most the relay's `maxPulseMs`, 2000 by default).
- `webhook`: Laberty POSTs `{ event, device, command, value, at }` to the webhook, signed `X-Laberty-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">`, 5 second timeout, no redirects.
- `analogThreshold`: fires its command once when the reading crosses the level; a reading back past the level by the hysteresis re-arms it (the device reports both).

Every press is kept for 90 days (Admin → Devices → a device → Presses), with
web addresses and secrets left out.

### `POST /device/outbox/{id}/ack`

The device did what `outbox` in the state asked (today: `{ "action": "pulse",
"output": "power", "ms": 1000 }` from Admin's relay Test button). Answers
`{ "ok": true, "id": 7 }`, or `404` for an id that isn't this device's. Outbox
items not acked within 10 minutes are dropped.

### `GET /agent/jobs` while paused

The print bridge's poll answers `{ "job": null, "paused": true }` while the
bridge is paused; queued jobs wait.

### `POST /device/jobs/{id}/release`

A held job goes to the queue, and the bridge prints it on its next poll. If
the printer has other labels loaded than the job was made for:

```json
409
{ "code": "stock_mismatch", "error": "Load Large Address (208) in Kitchen QL-820NWB first, or print anyway.", "stockName": "Large Address (208)", "printerName": "Kitchen QL-820NWB" }
```

Send `{ "force": true }` to print anyway.

When the printer's roll is **confirmed** as something else, `force` is ignored:

```json
409
{ "code": "roll_confirmed_mismatch", "confirmed": true, "error": "Needs Large Address (208): Kitchen QL-820NWB has Continuous 2.4in roll (2205) confirmed loaded. Change the roll and press Swap roll first.", "stockName": "Large Address (208)", "printerName": "Kitchen QL-820NWB", "loadedStockName": "Continuous 2.4in roll (2205)" }
```

### `POST /device/jobs/{id}/skip`

A held job is cancelled and never printed.

### `POST /device/jobs/{id}/retry`

A failed job goes back on the queue. Refused with `409 duplicate` (and the
other `jobId`) while the same job is already queued or printing, `410
payload_gone` once its label data has been cleared (after 7 days), and `429`
under the per-minute print limit.

### `POST /device/jobs/{id}/cancel`

A queued job is cancelled before the bridge picks it up. A job the bridge
already has returns `409`.

### `POST /device/printers/{id}/loaded-stock`

Body `{ "stockId": 3 }`. Records a roll swap from a tap on the screen, as
**not sure**, with a `confirmNote`: a tap never confirms a roll. To confirm it
for sure, pick it with `selectRoll` and press a button bound to `confirmRoll`
(or use a switch bound to `setLoadedRoll`). The stock must be on that
printer's list of stocks (Printers & stock), or it's `400 not_in_swap_list`. A
removed stock is `400 stock_removed`. The same swap again answers
`"unchanged": true` and changes nothing.

Every action answers `{ "ok": true, "jobId": 815, "jobStatus": "queued" }` (or
the printer and `loadedStock` for a swap), or an `error` with a `code`:
`not_found` (404, including another workspace's ids), `wrong_status` (409),
and the ones above. Every action is written to the activity log as the
screen, for example `Screen "Kitchen screen"`.

---

## Households and labels

### `GET /households`

Every household with its people, addresses and active label variants.

A household can have several addresses (a lake house, a winter home).
`address` is the household's **primary** address, exactly as it always was;
`addresses` lists every address, primary first. Use an address's `id` as
`address_id` when you print. A household with one address has a one-entry list.

```json
{
  "households": [
    {
      "id": 12,
      "displayName": "Ada & Grace Lovelace",
      "address": { "line1": "1428 Rosemont Ave", "line2": null, "city": "Charleston", "state": "SC", "zip": "29403" },
      "addresses": [
        { "id": 31, "nickname": "Town", "primary": true, "line1": "1428 Rosemont Ave", "line2": null, "city": "Charleston", "state": "SC", "zip": "29403", "notes": null },
        { "id": 32, "nickname": "Lake house", "primary": false, "line1": "12 Lake Rd", "line2": null, "city": "Ely", "state": "MN", "zip": "55731", "notes": null }
      ],
      "people": [{ "id": 20, "title": "Ms.", "first_name": "Ada", "last_name": "Lovelace", "is_child": false }],
      "variants": [{ "id": 41, "display_label": "Couple", "name_text": "Mr. & Mrs.\nAda & Grace Lovelace", "variant_type": "couple" }]
    }
  ]
}
```

### `POST /households`

`address_line1`, `city`, `state` and `zip` are required. `people` is
optional; when given, the standard label variants (individual, couple,
family) are generated automatically.

```bash
curl -X POST https://laberty.app/api/households \
  -H "Authorization: Bearer $LABERTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "address_line1": "1428 Rosemont Ave",
        "city": "Charleston", "state": "SC", "zip": "29403",
        "people": [{ "title": "Ms.", "first_name": "Ada", "last_name": "Lovelace" }]
      }'
```

```json
{ "householdId": 12, "peopleCreated": 1, "variantsGenerated": 2 }
```

To give the household more than one address, add an `addresses` array. Each
entry takes `nickname`, `address_line1` (or `line1`), `address_line2`, `city`,
`state`, `zip` and `notes`. The top-level address stays the primary. Or leave
the top-level fields out and mark one entry `"primary": true` (else the first
is the primary). Up to 10 addresses in all. The response then also says
`addressesCreated`.

```json
{
  "address_line1": "1428 Rosemont Ave", "city": "Charleston", "state": "SC", "zip": "29403",
  "addresses": [{ "nickname": "Lake house", "address_line1": "12 Lake Rd", "city": "Ely", "state": "MN", "zip": "55731" }],
  "people": [{ "first_name": "Ada", "last_name": "Lovelace" }]
}
```

### `GET /label-stock`

The workspace's active label sizes, for choosing one to print on.

### `POST /print/email`

Renders a batch of address labels as a PDF and emails it to the member
account — the remote-printing path for someone who prints from a tablet.

```bash
curl -X POST https://laberty.app/api/print/email \
  -H "Authorization: Bearer $LABERTY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "items": [{ "householdId": 12, "variantId": 41 }] }'
```

```json
{ "sentTo": "member@example.com", "labelCount": 1 }
```

Each item may carry `address_id` (or `addressId`), one of the household's
address ids from `GET /households`. Without it the household's primary address
prints, as always. An `address_id` that isn't that household's is refused with
`400` and `"code": "bad_address"`, and nothing is sent.

Needs a member account with an email address on it (Users page), and
`stockId` is optional — it uses the default stock otherwise. On sheet
stock, `startPosition` (default 1) says where on the first sheet the
labels begin.

---

## Limits, and what they cost

**Home includes 20 API actions a month. Business is unlimited.** An action
is a call that does something: `POST /print`, `POST /households`,
`POST /print/email`, `POST /printers/{id}/loaded-stock`. Reads (`GET`) never
count, so polling a job's status is free, and print-bridge calls
(`/agent/*`) and Laberty Screen calls (`/device/*`) never count. A refused or failed call doesn't use one up. The
month is the account's own calendar month (Central time by default). Once
the allowance is used, a call that would count gets:

```json
429
{ "code": "api_quota", "error": "Home includes 20 API actions a month, and this workspace has used 20. ...", "limit": 20, "used": 20, "resetsAt": "2026-10-01T05:00:00.000Z" }
```

**On Business, printing through the API is not metered and not billed per
job.** Queueing a job costs a database
write and a share of a poll the bridge was making anyway — fractions of a
cent — so a meter would cost more to build, explain and argue about than
it could ever recover.

What exists instead are guards against the runaway case:

| Guard | Value | What happens |
|---|---|---|
| Rate limit | **60 print jobs / minute** per workspace | `429` with `Retry-After: 60`. This is what stops a loop in your code, not a ration |
| Fair use | **10,000 jobs / month** per workspace | **Nothing is blocked.** The deployment owner gets one notification, so an unusual integration gets noticed |
| Payload size | ~1 MB decoded | `413`. A label is a few KB |
| Copies | 50 per job | Clamped, not rejected |
| Printers | 1 / 2 / unlimited on Free / Home / Business | Enforced when adding one, with a hard ceiling of 50 as a typo-catcher |
| API actions | none / **20 a month** / unlimited on Free / Home / Business | `429` with `code: "api_quota"` and `resetsAt`. Reads and bridge calls don't count |
| API keys | none / 3 / unlimited on Free / Home / Business | Checked when a key is made. Revoked keys don't count. A hard ceiling of 200 live keys catches a runaway script |

`GET /agent/status` returns these live, including your month-to-date
count, so your own UI can show them:

```json
"limits": { "jobsPerMinute": 60, "fairUseJobsPerMonth": 10000, "jobsThisMonth": 214, "apiActionsPerMonth": 20, "apiActionsThisMonth": 12, "metered": false }
```

**Retention.** A finished job's payload is blanked after **7 days** and
the row is deleted after **30**. Queued and claimed jobs are never
touched however old — a stuck job is something to look at, not something
to silently delete. So poll `GET /print/{id}` while you care, and keep
your own record if you need one beyond a month.

## Notes worth knowing

**Everything is workspace-scoped.** A token only ever sees its own
workspace. Passing an id belonging to another workspace returns "not
found", never someone else's row.

**Polling.** Don't poll `GET /print/{id}` in a tight loop — once a second
is plenty, and the bridge only checks for work every five seconds anyway.
Reads are not rate limited; only `POST /print` is.

**Stability.** These paths and field names are what the print bridge and
the Claude skill already use, so they won't change shape without a
version bump. Additive fields may appear.

The bridge's own endpoints go further: `/agent/v1/*` is a **frozen
contract** with golden tests that fail the build if any field is removed,
renamed or retyped. See [docs/BRIDGE-PROTOCOL-V1.md](BRIDGE-PROTOCOL-V1.md).

**Errors** are always `{ "error": "a sentence you can show a user" }` with
a matching HTTP status.
