# Keyspace Meeting Rooms & Desks — Integration Guide

**Version 1.15.0** · The narrative guide for third-party apps that book Keyspace
meeting rooms and desks. Platform basics (credentials, token, envelope,
webhooks) are on the [getting-started page](/docs/onboarding.md).

This guide is the *narrative* — how the pieces fit and the journey a booking
travels. For field-by-field endpoint detail see the
[full API reference](/llms-full.txt); for a machine-readable contract see the
[OpenAPI spec](/docs/openapi.json). To scaffold a client quickly, hand your coding
agent the [integration prompt](/llms-full.txt) and this environment's
`/docs/openapi.json`.

---

## 1. Before you start

Keyspace issues you, per environment:

- `app_id` + `app_secret` — your OAuth client credentials (server-side only)
- a default `projectId` — the property whose rooms/desks you may book

| Environment | Base URL | Use for |
| ----------- | ------------------------------ | -------------------------- |
| Development | `https://api.keyspace-dev.com` | Early build + smoke tests |
| UAT | `https://api.keyspace-qat.com` | Pre-production sign-off |
| Production | `https://api.keyspace.tech` | Live |

Credentials are **not** shared across environments. Each environment also serves
its own copy of this guide, the reference, and the OpenAPI spec — always read the
docs from the environment you are integrating against.

---

## 2. The five-minute mental model

- **Resources.** A `projectId` (property) contains **meeting rooms** and
  **desks**. Both are booked through the same `POST /meetings` endpoint — a room
  booking passes `meetingRoomId`, a desk booking passes `deskId`.
- **Envelope.** Every response except the token endpoint is wrapped:
  `{ code, success, message, data, meta? }`. `code: "KS000"` means success; any
  other `KSxxx` is an error. Unwrap `data`; read `meta` on paginated lists.
- **Lifecycle.** A booking moves
  `unpaid → active → checked-in → done`, with `cancelled` reachable from
  `unpaid` and `active` only — **a `checked-in` booking cannot be cancelled**;
  it ends via check-out (organizer or `pinCode`) or automatically at the
  booking's end time. `rejected` is a terminal decline. Free rooms skip
  `unpaid` and land on `active` immediately.
- **Bookings change state on their own.** Timers run against every booking —
  payment window, no-show auto-cancel, close at end time. Section 3 has the
  state diagram, every timer and every refusal code.
- **Webhooks push, polling reconciles.** Register a listener server (see
  section 7) and Keyspace POSTs signed lifecycle events to your backend —
  bookings, changes, cancels (including the automatic ones), reminders. Keep
  `GET /meetings/:id` polling as the reconciliation fallback (payment
  confirmation, missed deliveries).
- **No availability endpoint per se.** You search rooms/desks *for a time window*
  and get back the ones that are free — see step 3.

---

## 3. The booking lifecycle

`status` on `GET /meetings/:id` is the single source of truth for a booking. It
is in exactly one of six states, and three things move it: **your calls**, **the
clock** (timers configured per room/desk) and, for calendar-synced rooms, **the
organizer's own calendar**. Statuses change without any call from you — re-fetch
before you show a state-dependent action.

```mermaid
stateDiagram-v2
    state "checked-in" as checkedin
    [*] --> unpaid: POST /meetings, paid room
    [*] --> active: POST /meetings, free room or desk
    unpaid --> active: payment settled
    unpaid --> cancelled: cancel, or payment window lapses (5 min)
    active --> unpaid: PATCH that adds a charge
    active --> checkedin: check-in (organizer, attendee or pinCode)
    active --> cancelled: cancel, or no-show auto-cancel
    active --> done: end time reached
    checkedin --> done: check-out, or end time reached
    [*] --> rejected: calendar invite to a synced room, declined by Keyspace
    cancelled --> [*]
    done --> [*]
    rejected --> [*]
```

### 3.1 States — what each means and what you may call

| Status | Meaning | Allowed calls | Leaves via |
| --- | --- | --- | --- |
| `unpaid` | Paid room (or a paid extension): the slot is held while the user pays through `payment.webPaymentUrl` / `webPaymentQr` | cancel | payment settled → `active`; cancel, or the 5-minute payment window lapsing → `cancelled` |
| `active` | Confirmed, nobody checked in yet | `PATCH`, cancel, check-in (inside the window) | check-in → `checked-in`; cancel or no-show auto-cancel → `cancelled`; end time → `done` |
| `checked-in` | Someone checked in; the room and its devices are live | `PATCH` (end time only), check-out | check-out, or end time → `done` |
| `done` | Ended — checked out, or closed by the clock | — | terminal |
| `cancelled` | Cancelled by a user, an admin, your app, or the system (then `cancelReason` is set) | — | terminal |
| `rejected` | Came in through a synced calendar (an Outlook/Google invite to the room) and Keyspace declined it | — | terminal; never produced through this API |

Calling out of state is refused, not ignored:

| Call | State | Response |
| --- | --- | --- |
| `PATCH /meetings/:id` | `unpaid` | `422` "Cannot edit unpaid meeting" — pay or cancel first |
| `PATCH` moving `startDateTime` | `checked-in` | `409` "Start time of a checked-in meeting cannot be modified." — the end may still be extended |
| `PATCH` | `cancelled` | `KS072` "The meeting has already cancelled" |
| `POST /meetings/:id/cancel` | `checked-in`, `done`, `cancelled` | `409 MEETING_011` — a started booking ends by check-out, never by cancel |
| `POST /meetings/:id/check-in` | anything but `active` inside the window | see 3.4 |

### 3.2 Where a new booking starts

- Free room or desk → `active` on the `201`. Paid room → `unpaid` with a `payment`
  object; it becomes `active` when the payment settles (poll `GET /meetings/:id`,
  or wait for `ROOM_BOOK` / `DESK_BOOK`).
- Rooms/desks configured with `postStartExpireMinutes: 0` ("check-in not
  required"): a booking created inside the check-in window is `checked-in` on the
  `201`; otherwise Keyspace checks the organizer in at the start time itself
  (history entry `source: "system"`).

### 3.3 The clock — timed transitions

Timers are per room/desk: read `config.meetingConfig` on `GET /meeting-rooms` /
`GET /desks` (defaults below; absent = defaults; `-1` disables that tick).

| When (default) | What happens | Webhook |
| --- | --- | --- |
| booking + 5 min, still `unpaid` | auto-cancel, `cancelReason: "Payment window lapsed — auto-cancelled by system"` | `ROOM_/DESK_CANCEL` |
| back-office booking (`bookedVia: "backoffice"`): `paymentDueAt` (default 24 h, clamped to the start) | the resident is nudged at create and once more at ~75 % of the hold, carrying `amount`, `paymentDueTime`, `minutesUntilDue`; then auto-cancel, `cancelReason: "Unpaid — back-office hold expired"` | `ROOM_/DESK_PAYMENT_DUE`, then `_CANCEL` |
| start − `preStartMinutes` (15) | check-in window opens; reminder to participants | `_REMINDER` |
| start + `postStartWarnMinutes` (10), nobody checked in | last warning, carries `minutesUntilAutoCancel` | `_NO_SHOW_WARNING` |
| start + `postStartExpireMinutes` (15), nobody checked in | auto-cancel, `cancelReason: "No-show — auto-cancelled by system"` | `_CANCEL` |
| end − `preEndMinutes` (15) | ending soon — extend with `PATCH` if the room is free | `_ENDING_SOON` |
| end time | `active` / `checked-in` → `done` | none |

- `postStartExpireMinutes: -1` — no warning and no auto-cancel: an unattended
  booking stays `active` until its end time, then closes to `done`.
- All-day bookings are never no-show-cancelled.
- There is **no webhook for check-in, check-out or `done`** — poll
  `GET /meetings/:id` for those.
- A recurring series is one booking that rolls: at each occurrence's end it moves
  on to the next occurrence and returns to `active`; it becomes `done` only after
  the last occurrence.

### 3.4 Check-in

Who may check in: the organizer, a listed attendee (their own or an `act_as`
token), or anyone who sends the booking's `pinCode`. When: from
start − `preStartMinutes` until the booking closes; with `preStartMinutes: -1`,
any time on the booking's day. Refusals, in the order Keyspace evaluates them:

| Situation | HTTP | Code |
| --- | --- | --- |
| booking is cancelled | `404` | `MEETING_043` |
| booking already ended | `409` | `MEETING_004` |
| still `unpaid` | `422` | `MEETING_005` |
| too early — the message says how many minutes before start the window opens | `409` | `MEETING_040` |
| wrong day (assets with `preStartMinutes: -1`) | `409` | `MEETING_041` |
| wrong `pinCode` | `404` | `MEETING_042` |
| PIN locked after repeated wrong attempts — stays locked until the organizer sets a new PIN | `429` | `MEETING_039` |
| caller is neither organizer nor attendee and sent no `pinCode` | `404` | `MEETING_003` |

Route `MEETING_040` / `MEETING_041` to "come back later" copy; they are not
"room unavailable" (`MEETING_006`), and no other room will help.

### 3.5 Check-out and early end

`POST /meetings/:id/check-out` works from `checked-in` only: `endDateTime` is
rewritten to the actual end (never before the start, never after the booked
end), the room and its devices are released, and the status becomes `done`.
Irreversible. A booking nobody checks out of closes itself at its booked end.

### 3.6 Calendar-synced rooms — edits from Outlook or Google

For rooms synced to Microsoft 365 or Google Workspace the organizer can also
change the booking from their calendar. Keyspace applies these rules:

- **Move, before check-in** — applied, `pinCode` unchanged (`ROOM_MODIFY`). If
  the new slot breaks a booking rule the room declines the invite, the booking is
  `cancelled` with that rule as the reason, and the old PIN stops working.
- **Move, while `checked-in`** — the start change is ignored; the end is extended
  if the room is free, otherwise refused. Never cancelled.
- **Delete, before check-in** — `cancelled`; room released, payment and PIN
  voided (`ROOM_CANCEL`).
- **Delete, while `checked-in`** — ends now: `done` with the actual end time. The
  meeting happened, so it is not `cancelled`.
- **Room copy removed / room declined** while the organizer's copy still stands —
  `cancelled` with an explicit reason.

### 3.7 Reading the history

`histories[]` on the booking is the audit trail: `action` (`create`, `pay`,
`payment-timeout`, `update`, `cancel-updating`, `cancel`, `check-in`,
`check-out`, `rejected`), `at`, `source` (`user`, `partner`, `system`,
`ms_graph`, `google_calendar`) with `sourceDetail`, `reason` on cancels and
declines, `statusBeforeUpdate`, and the old/new times on a move. Use it to tell
your user *who* changed a booking and *why*.

---

## 4. Step by step

### Step 1 — Authenticate

`POST /auth/accessToken` with `{ app_id, app_secret, grant_type: "app_credentials" }`.
You get back `access_token`, `refresh_token`, and their lifetimes in seconds.

- Send the token on every call as `Authorization: Bearer <access_token>`.
- **Refresh proactively:** when the access token is within ~5 minutes of expiry,
  call the same endpoint with `{ refresh_token, grant_type: "refresh_token" }`
  and store the **new** pair it returns.
- **Recover once:** if a call still returns `401` (`KS002`), refresh → retry once
  → full re-auth → then surface the error.
- **Retry, don't re-auth, on `503` `KS234`:** authentication is temporarily
  unavailable — the token was not rejected. Retry with backoff and keep your
  tokens.
- Do **not** hardcode token lifetimes — they are environment configuration; read
  `expires_in` from the response.

Authenticate once per server process, not per request.

**Advanced — act on behalf of a user (`act_as`).** The app token above books
everything *as your app*; a person on a booking is just an **attendee**. If your
users should instead reuse Keyspace's **per-user** scope (member booking quotas,
member-owned resources, per-member roles), exchange your app credentials for a
**member-scoped token**: same endpoint, `grant_type: "act_as"`, plus
`subject: "<your-user-id>"` — the member is created and linked to your app on
first use, no Keyspace password or login involved. The delegated token is
short-lived, has **no refresh token** (re-run the grant), and can never exceed
your app's own scope. This is a coordinated feature — agree it with your
Keyspace contact first. Full detail + errors (`KS221`/`KS223`):
[reference §act_as](/llms-full.txt#4-act-on-behalf-of-a-member-act_as--advanced).

**Your app owns its users?** If you hold your users' profiles, access-card
numbers or face photos and want Keyspace to enrol them on the doors, that is a
separate surface — the [Members & credentials guide](/docs/guides/members.md).
The `act_as` `subject` above is the same id as that guide's `externalUserId`, so
a member you provision and a member you book as are one person.

### Step 2 — Find something to book

Rooms — `GET /meeting-rooms?projectId=…&status=active`. Add a window to filter to
what's free:

```
GET /meeting-rooms?projectId=<id>&status=active
    &startDateTime=2026-07-15T14:00:00+07:00
    &endDateTime=2026-07-15T15:00:00+07:00
```

Rooms with a conflicting booking are dropped from the result — including a room
whose cleaning time (`cleanupMinutes`, below) the window would run into. A room
that is free but not bookable for that window carries an `unavailableReason`
(`OPERATING_HOURS` or `BREAK_TIME`); skip those. No `unavailableReason` ⇒
bookable.

Desks — `GET /desks?projectId=…`. For availability, pass `startDateTime` plus
**exactly one** of `timePeriod` (`morning`/`afternoon`/`allDay`) or `endDateTime`
(explicit window). A desk is dropped when it is booked — or when the window
would run into a booking's cleaning time (desks use the property's default).

Read the asset's `config` before you build the booking form:

- `config.dayConfig` — **the operating hours that matter**, per weekday, each with
  `availableTime {start,end}` and optional `breakTimes[]`. A missing weekday means
  closed that day. (`config.start`/`config.end` are legacy overall hours — prefer
  `dayConfig`.)
- `config.minimumLeadTime` — how far ahead a booking must start.
- `config.maxAdvanceBookingDays` / `maxDurationMinutes` — booking-horizon and
  length limits.
- `cleanupMinutes` (on the room itself, 0–60) — cleaning time reserved after
  every booking. A booking holds the room until its end plus the cleaning: with
  15 minutes, a 10:00–11:00 booking keeps the room until 11:15. Absent means the
  property's default (possibly none; desks always use it). The room availability
  search above already accounts for it.

Times inside `config` are property-local wall-clock (`HH:mm:ss`).

### Step 3 — Create the booking

`POST /meetings`. Rules to enforce client-side (the server enforces them too, but
failing fast is a better UX):

- Exactly one of `meetingRoomId` / `deskId`.
- Rooms use `startDateTime` + `endDateTime`. Desk bookings use `startDateTime` +
  exactly one of `endDateTime` / `timePeriod`.
- The window must sit inside the day's operating hours, avoid `breakTimes`, meet
  `minimumLeadTime`, and stay within `maxAdvanceBookingDays` / `maxDurationMinutes`.
- Leave the cleaning time free: start no earlier than the previous booking's end
  plus the cleaning, and end at least that long before the next booking. A
  booking that runs into it gets the same `409` as an overlap, with a message that
  says when the cleaning ends.

Preview price before committing with `draft: true` (nothing is created); use
`draftEndDateTimes` to price several durations in one call.

**Booking for a member (back office).** If your app acts for front-desk staff,
pass `organizerId` — the member's Keyspace user id. The caller must hold
`MEETING_UPDATE` on the asset's project (`403` otherwise) and the member must
belong to it (`400`). The member becomes the organizer (it shows in their app,
they get the reminders, they may check in); your caller is recorded as
`bookedByUserId` and `bookedVia` marks the booking as back-office; promo codes
are priced in the member's scope. Nothing is charged at create: for a paid room
the response is `status: "unpaid"` with a `kind: "quote"` billing entry and
**no `webPaymentUrl`** — the member pays from their own device before
`paymentDueAt` (see Step 4). `draft: true` previews the price the same way.

The success response carries the booking `_id` and a **`pinCode`** — store both.
The pinCode authorizes later update/cancel/check-in/check-out when the caller is
not the organizer. Treat it as a secret; never log it.

### Step 4 — Handle payment (paid rooms only)

If the create (or an extending update) returns `status: "unpaid"`, payment is
required. It is a **human web flow** — do not try to automate it.

```mermaid
sequenceDiagram
    participant App as your app
    participant API as Keyspace API
    participant Payer as payer (browser)

    App->>API: POST /meetings
    API-->>App: 201 status "unpaid" + payment.webPaymentUrl
    App->>Payer: show webPaymentUrl / QR
    Payer->>API: completes payment (≤ 5 min window)
    App->>API: poll GET /meetings/:id
    API-->>App: status "active" — booking confirmed
```

- Present `payment.webPaymentUrl` (or render `payment.webPaymentQr`) to the user.
- The window is ~5 minutes (`billingSummaries[].expiredAt`); past it the booking
  auto-cancels. A back-office booking (`organizerId`) is instead held until
  `paymentDueAt` — `paymentDueMinutes` on the request (back-office only) → the
  project's `backofficePaymentDueMinutes` → 1440 minutes, clamped to 7 days and
  to the meeting start — and there is no `webPaymentUrl` to show: the member
  pays it in the Keyspace app.
- Pass `frontendRedirectUrl` on create so the payer returns into your app after
  paying.
- Confirm by polling `GET /meetings/:id` until `status` leaves `unpaid`.

### Step 5 — Manage the booking

- **Update / extend** — `PATCH /meetings/:id` (only `active`/`checked-in`; start
  time is frozen after check-in; extending a paid room can return a new `unpaid`
  payment step).
- **Cancel** — `POST /meetings/:id/cancel` (from `unpaid`/`active` **only** —
  a `checked-in` booking cannot be cancelled, check out instead; irreversible;
  voids pending payment).
- **Check-in** — `POST /meetings/:id/check-in`, opens ~15 min before start;
  `active` only. **Do not skip this**: an `active` booking with no check-in is
  auto-cancelled ~15 minutes after start (unless the asset disables it).
- **Check-out** — `POST /meetings/:id/check-out`, `checked-in` only,
  irreversible — and the only way to end a `checked-in` booking early.

Include `pinCode` in the body when the caller is not the organizer. State rules,
timers and every refusal code: section 3.

---

## 5. Error handling strategy

- Branch on **HTTP status + the body `code`**; treat `message` as human text, not
  a stable contract. On `400` (`KS001`), `message` is an array of constraint
  strings — surface them.
- `409 KS012` (time-slot conflict) is a **normal, recoverable** outcome of two
  users racing for the same slot — catch it and offer another slot, don't treat it
  as a crash. The same refusal covers a booking that runs into the cleaning time
  (`cleanupMinutes`); its message says when the cleaning ends — show it.
- `403 MEETING_049` means an admin has **blocked the organizer from booking**.
  It comes back from `POST /meetings`, and from `PATCH /meetings/:id` when the
  change moves the start or end. It is not a slot problem, so don't send it to
  your "offer another slot" branch. Show the message and point the user to their
  building admin; no other room, time or retry will work until the block is lifted.
- `403 KS003` on `POST /meetings` means the room or desk is **private**
  (`isPublic: false`) and the organizer is not a member of its project. Offer a
  public room or desk instead, or ask the building admin to add the user.
- Retry only network failures and `5xx` (and `429`), exponential backoff, max 3.
  Never auto-retry other `4xx`.
- **No idempotency key on create.** If `POST /meetings` times out, do **not**
  resend blindly — you may double-book. Reconcile with
  `GET /meetings?from=…&to=…&meetingRoomId=…` to see whether the booking landed,
  then decide.
- **`_id` is not unique in a `from`/`to` list response.** That endpoint expands a
  recurring series into one row per occurrence, all sharing the series master's
  `_id`. Key and de-duplicate on `occurrenceEventId` when it is present; keep
  using `_id` for check-in/cancel/detail. Note `GET /meetings/:id` returns the
  master, whose window rolls forward across the series, so it will not match the
  occurrence row you started from.

| HTTP | Code | Meaning |
| ---- | -------- | ------------------------------ |
| 400 | `KS001` | Validation failed (`message[]`) |
| 401 | `KS002` | Token missing/expired |
| 403 | `KS003` | No access to the resource |
| 404 | `KS005` | Not found |
| 409 / 410 | `KS006` | State conflict / target already gone |
| 409 | `KS012` | Time-slot conflict (recoverable) |
| 429 | `KS036` | Too many requests |
| 500 / 503 | `KS004` | Server / upstream error (retryable) |
| 503 | `KS234` | Authentication temporarily unavailable — retry with backoff, don't re-authenticate |
| 403 | `KS221` | `act_as` / partner members: not your linked member (or not a single-project app) |
| 401 | `KS223` | `act_as` / partner members: subject not resolved |

A `KSxxx` code is **not 1:1 with an HTTP status** (`KS006` spans `409`/`410`,
`KS004` spans `500`/`503`) — branch on the status, log the code. Full reference:
[error codes](/llms-full.txt#error-code-reference).

---

## 6. Timezones

Send every date-time in ISO 8601 **with an explicit offset**
(`2026-07-15T14:00:00+07:00`). Render times back to users in the **property's**
local timezone. `config` hours are property-local `HH:mm:ss`.

---

## 7. Receiving events on your server (webhooks)

Give your Keyspace contact an **HTTPS callback URL**, a **32+ character secret**
you generate, and the **event types** you want. Keyspace then POSTs each event
to your URL with these headers:

| Header | Meaning |
| --- | --- |
| `X-Keyspace-Event` | event type, e.g. `ROOM_CANCEL` |
| `X-Keyspace-Delivery` | unique delivery id — de-duplicate retries on it |
| `X-Keyspace-Timestamp` | epoch ms — reject if older than ~5 min (replay guard) |
| `X-Keyspace-Signature` | `sha256=HEX(HMAC-SHA256(secret, timestamp + "." + rawBody))` |

Rules of the road:

- **Verify the signature over the raw body bytes** before trusting anything
  (code sample in the [full reference](/llms-full.txt)).
- **Ack fast**: respond 2xx within 10 s; process async. Failures retry up to 5×
  with exponential backoff; 20 consecutive dead deliveries auto-disable your
  webhook until Keyspace re-enables it.
- The payload is a **signal**, not the record — re-fetch `GET /meetings/:id`
  for authoritative state; the payload never contains `pinCode`.
- Branch cancels on `data.cancelReason`: absent = user/admin cancel,
  `"No-show — auto-cancelled by system"`,
  `"Payment window lapsed — auto-cancelled by system"`, or
  `"Unpaid — back-office hold expired"`.

Event types: `ROOM_/DESK_BOOK`, `_MODIFY`, `_CANCEL`, `_REMINDER`,
`_NO_SHOW_WARNING`, `_ENDING_SOON`, `_PAYMENT_DUE`, plus visitor events `VMS_INVITE_SENT`,
`VMS_VISITOR_CHECKED_IN`, `VMS_HOST_CANCELLED` — full catalog + payload fields in
the [reference](/llms-full.txt). Visitor events ride the same channel; note
`VMS_INVITE_SENT` carries the visitor's access QR (`qrCardCode`) for you to relay
to the invitee — the one payload field you should treat as a credential. A visitor
invite may have no end time: its `endDateTime` is then `null`, and it is valid the
whole start day.

**If your endpoint needs its own auth** (beyond verifying our signature),
Keyspace can present a `bearer` token, a `custom-header` API key, or a
short-lived `jwt` (HS256 over the shared secret) — pick the scheme at
registration. Default is signature-only.

---

## 8. Sandbox testing checklist

Run against dev/UAT before you ship:

- [ ] Authenticate, then let the token expire and confirm your refresh path.
- [ ] List rooms; list again with a time window and confirm busy rooms drop out.
- [ ] `draft: true` create returns a price and creates nothing.
- [ ] Real create returns `active` (free room) or `unpaid` + `webPaymentUrl`
      (paid room).
- [ ] Second overlapping create fails `409 KS012` and your UI offers another slot.
- [ ] Update changes a field; cancel flips to `cancelled`.
- [ ] Your smoke test **cancels everything it created** (try/finally) — never
      leave live bookings behind.
- [ ] Confirm you never log `pinCode`, tokens, or `app_secret`.
- [ ] If using webhooks: your endpoint verifies the signature, acks within 10 s,
      and de-duplicates on `X-Keyspace-Delivery`.

---

## 9. Go-live checklist

- [ ] Production `app_id`/`app_secret` stored in a secrets manager, not in code.
- [ ] Token cached per process; proactive refresh + one-shot 401 recovery in place.
- [ ] `409 KS012` handled as a user-facing "pick another slot", not an error page.
- [ ] Timed-out creates reconcile via `GET /meetings`, never blind-retry.
- [ ] Paid-room flow polls to `active` before showing success; honours the 5-min
      window and `frontendRedirectUrl`.
- [ ] All date-times sent with an offset; times rendered in the property timezone.

---

## 10. Getting help & reporting discrepancies

If the API's observed behavior differs from these docs, note the exact endpoint,
what you expected, and what you saw, and send it to your Keyspace contact — the
docs are served from the API itself and we correct drift at the source.

- [Full API reference](/llms-full.txt)
- [OpenAPI 3.0 spec](/docs/openapi.json)
- [llms.txt index](/llms.txt)
