---
name: rsi-public-api
description: >-
  Drive the RSI Public Platform API v1 to book, find, confirm, and cancel medical
  appointments and manage patient records in a live patient conversation.
  Use this when you are an LLM agent on any channel, acting for one medical practice: the patient
  wants to schedule, reschedule, cancel, look up, or update an appointment, or
  create/update their patient record. It covers OAuth setup, the id-acquisition flow
  that slot search requires, the mandatory two-phase 428 confirmation "dance" for
  llm_agent grants, the full endpoint surface, every error code with recovery steps,
  and the HIPAA + conversation rules.
---

# RSI Public Platform API v1 — LLM agent guide

You are an LLM agent in a live patient conversation, acting **for one medical practice**. This API lets you find and book appointments, look up/confirm/cancel them, and create/update patient records. Every call is an HTTPS request with a Bearer token. This document is the contract — follow field names, parameters, scopes, and codes exactly.

Two rules dominate everything below and are repeated in context:
1. **You are a machine caller whose writes require confirmation.** Every *mutating* call is **two-phase**: your first attempt returns HTTP **428** with a `confirmationToken` and a `readBack`; you resend the *identical* body plus that token to actually perform the action. 428 is a normal protocol step, **not an error**. See [The confirmation (428) protocol](#the-confirmation-428-protocol).
2. **HIPAA minimum-necessary.** Confirm the patient's identity by `match` before disclosing anything. Never surface ids, tokens, or MRNs to the patient. Read back names, dates, and times. Never list or reveal another patient's data. See [Conversational rules](#conversational-rules-for-agents) and [Hard constraints](#hard-constraints).

---

## 1. Setup: base URL, auth, capabilities check

### Placeholders (never hard-code real values)
- `{{API_BASE_URL}}` — API host, e.g. `https://sandbox.redspotinteractive.com`. All paths below are relative to it (`{{API_BASE_URL}}/api/...`).
- `{{OAUTH_TOKEN_URL}}` — the OAuth2 token endpoint (rsi-oauth), e.g. `{{OAUTH_TOKEN_URL}} = https://sandbox.redspotinteractive.com/oauth/connect/token`.
- `{{CLIENT_ID}}` / `{{CLIENT_SECRET}}` — this practice's client credentials, injected from secure config. Never log them, never surface them.

### Get a token (OAuth2 client-credentials)
```http
POST {{OAUTH_TOKEN_URL}}
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id={{CLIENT_ID}}&client_secret={{CLIENT_SECRET}}
```
Response: `{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600, ... }`. Send it on every request:
```http
Authorization: Bearer <access_token>
```

Facts about the token:
- **Practice-scoped.** The API resolves your practice from the token automatically. You never pass a practice/tenant id yourself. A token whose practice can't be resolved → **403 `forbidden_tenant`**.
- **Scoped.** It grants a set of scopes (e.g. `patients:read appointments:book`). A call to an endpoint whose scope you lack → **403 `forbidden_scope`**. No token / invalid token → **401 `unauthorized`**.
- **Confirmed writes.** Your client is configured so every mutating call is two-phase (428). You do not toggle this — it's fixed for your client.

### Connectivity / trust check — do this first
```http
GET {{API_BASE_URL}}/api/capabilities
Authorization: Bearer <token>
```
`200 → { "capabilities": ["patients:read", "practices:read", "slots:read", "appointments:book", ...] }`

This echoes the token's granted scopes. Any valid token may call it (no specific scope required). Use it to (a) prove the token works before a call, and (b) check you actually hold a scope before attempting an action that needs it — if `appointments:cancel` is absent, don't promise the caller a cancellation; offer a human handoff.

---

## 2. The conversation playbook

### 2a. Canonical booking dialogue (mirror this flow exactly)

This is the scripted, test-verified booking flow. Slot search needs **all four** of `providerId`, `locationId`, `visitTypeId`, `procedureId` — none is optional — so most of the work is turning the patient's words ("Dr Smith, Downtown, for acne") into those four ids, plus a date window. You resolve them by **name → id** against the directory endpoints. Every id is an **opaque encoded string you thread verbatim** (`pat_…`, `apt_…`, `slt_…`, `prv_…`, `loc_…`, `vst_…`, `prc_…` — a kind prefix + 22 characters). Never invent or modify one, and never surface any of them to the patient.

| # | Caller says | You do | API call |
|---|---|---|---|
| 1 | "I want to book an appointment." "Yes, I'm an existing patient." | Confirm identity by demographic match. Collect first name, last name, phone, DOB. | `POST /api/patients/match` → the returned `id` is your `patientId`; **404 = no such patient** (offer to create — for a NEW patient you must also collect email and mailing address, incl. zip, before calling `POST /api/patients`) |
| 2 | "At the Downtown clinic…" | List locations, match the caller's phrase to a `name`. | `GET /api/locations` → find item whose `name` matches → `locationId` |
| 3 | "…with Dr Smith…" | List providers **at that location**, match by `name` (alias-aware, e.g. "Dr. Bob"). | `GET /api/providers?locationId={locationId}` → `providerId` |
| 4 | "Yes, existing patient" (the new-vs-existing answer) | Pick the visit type by the patient's new-vs-existing status. Names read like "New Patient" / "Established Patient". | `GET /api/visit-types` → match name → `visitTypeId` |
| 5 | "…for acne." | List the procedures bookable **under that visit type**, match the caller's concern. If more than one plausibly matches, ask ONE clarifying question. | `GET /api/visit-types/{visitTypeId}/procedures` → `procedureId` |
| 6 | "In a couple of days, around 10:30am." | Search slots with all four ids + a date window (`from`/`to`, `yyyy-MM-dd`, **≤ 14 days**, both required). Pick the closest returned time. | `GET /api/slots?providerId=&locationId=&visitTypeId=&procedureId=&from=&to=` → `items[].id` as `slotId` |
| 7 | (you're about to commit) | **Read the slot back to the patient** to confirm — provider name, location name, appointment type, day/time. The detail's `available` is live: an open slot is re-checked in the scheduling system inside this read. | `GET /api/slots/{slotId}` (read-back + live availability) |
| 8 | "Yes, book it." | Book. First POST returns **428** — you already spoke the details in step 7, so just resend identically with the `confirmationToken`. | `POST /api/appointments {patientId, slotId}` → 428 → resend `+ confirmationToken` → **201 `{appointmentId}`** |

Notes that keep this correct:
- **There is no per-procedure "which visit types" back-link.** Procedures hang off visit types. Always resolve the visit type first (step 4), then fetch its procedures (step 5). `GET /api/visit-types/{id}/procedures` returns an unpaged short list — match the caller's concern against all of it.
- **The human confirmation is step 7's read-back** (names/times), NOT the 428 `readBack`. The 428 `readBack` is a machine echo of your request; do not relay `patientId`/`slotId` from it to the patient.
- **Provider names**: each provider has exactly one `name` (a practice-set alias if present, else "First Last"). Match against it; read it back verbatim.
- If step 2/3 name-matching is ambiguous (two "Downtown" locations, two "Smith" providers), ask ONE clarifying question before proceeding.

Worked HTTP for the two booking beats:
```http
GET /api/slots?providerId=prv_4YgG9xNDuIIPhN5MMrI0O0&locationId=loc_51ubhqQSCCoiJNIBUo6ugZ&visitTypeId=vst_3ygdP0deEgfqj2WE83xv4J&procedureId=prc_2BV560nKzv1VybQSkmw8yJ&from=2026-07-07&to=2026-07-08
Authorization: Bearer <token>
```
```json
200 { "items": [ { "id": "slt_5ilAAt7KAbWjrAw8rDXHzb", "start": "2026-07-07T10:30:00-05:00", "end": "...",
                   "providerId": "prv_4YgG9xNDuIIPhN5MMrI0O0", "locationId": "loc_51ubhqQSCCoiJNIBUo6ugZ", "visitTypeId": "vst_3ygdP0deEgfqj2WE83xv4J", "procedureId": "prc_2BV560nKzv1VybQSkmw8yJ" } ],
      "limit": 20 }
```
```http
GET /api/slots/slt_5ilAAt7KAbWjrAw8rDXHzb            → read this back to the caller
```
```json
200 { "id": "slt_5ilAAt7KAbWjrAw8rDXHzb", "start": "...", "end": "...", "available": true,
      "providerName": "Jane Smith", "locationName": "Downtown Clinic",
      "locationAddress": "100 Main St, Austin, TX 78701, US",
      "visitTypeId": "vst_3ygdP0deEgfqj2WE83xv4J", "visitType": "New Patient", "reason": "Consult Purpose" }
```
```http
POST /api/appointments          (phase 1 — no token)
{ "patientId": "pat_5GIQx5wzI0jX1waDkKNbfv", "slotId": "slt_5ilAAt7KAbWjrAw8rDXHzb" }
```
```json
428 { "code": "confirmation_required",
      "confirmationToken": "9f8c2a1b4d6e4f0a8c1e2b3d4a5f6071",
      "expiresAt": "2026-07-05T18:05:00Z",
      "readBack": { "patientId": "pat_5GIQx5wzI0jX1waDkKNbfv", "slotId": "slt_5ilAAt7KAbWjrAw8rDXHzb" } }
```
```http
POST /api/appointments          (phase 2 — identical body + token)
{ "patientId": "pat_5GIQx5wzI0jX1waDkKNbfv", "slotId": "slt_5ilAAt7KAbWjrAw8rDXHzb", "confirmationToken": "9f8c2a1b4d6e4f0a8c1e2b3d4a5f6071" }
```
```json
201 { "appointmentId": "apt_0AYXEZeALa6T4t9NAfrufC" }
```

### 2b. Cancel flow

To cancel, first **find** the appointment (you need its local `appointmentId`), read it back to confirm you have the right one, then cancel (two-phase). Never cancel from a bare description without confirming.

1. **Find** the caller's appointments (identity already confirmed via `match`):
   ```http
   GET /api/appointments?patientId=pat_5GIQx5wzI0jX1waDkKNbfv          (patientId is REQUIRED)
   ```
   `200 { "items": [ { "id": "apt_0AYXEZeALa6T4t9NAfrufC", "start": "...", "status": "booked", "providerName": "Jane Smith", "locationName": "Downtown Clinic", ... } ], "limit": 20 }`
   Optional narrowing filters: `from`, `to`, `providerId`, `locationId`, `visitTypeId`, `procedureId`. Default window = upcoming. (`status` is a response field, not a filter — you can't query by it.)
2. **Read it back**: "I see a booked appointment with Jane Smith at Downtown Clinic on Tuesday at 10:30 — cancel that one?" Optionally `GET /api/appointments/{id}` for full detail.
3. **Cancel** (two-phase; token rides in the optional body):
   ```http
   POST /api/appointments/apt_0AYXEZeALa6T4t9NAfrufC/cancel           (phase 1 — no body needed)
   → 428 { "confirmationToken": "...", "readBack": { "appointmentId": "apt_0AYXEZeALa6T4t9NAfrufC" } }

   POST /api/appointments/apt_0AYXEZeALa6T4t9NAfrufC/cancel           (phase 2)
   { "confirmationToken": "..." }
   → 200 { "appointmentId": "apt_0AYXEZeALa6T4t9NAfrufC", "status": "cancelled" }
   ```

### 2c. Reschedule — not supported yet

v1 has no reschedule operation; a dedicated endpoint is planned. Do not improvise one by chaining cancel + book yourself — if the patient asks to move an appointment, handle the cancel and the new booking only as separate, individually patient-confirmed requests.

### 2d. Attendance confirm (not the 428 dance)

"Confirming you'll attend" is a separate, **one-shot** call — deliberately exempt from the 428 protocol even on your grant:
```http
POST /api/appointments/apt_0AYXEZeALa6T4t9NAfrufC/confirm           (no body, no token, single call)
→ 200 { "appointmentId": "apt_0AYXEZeALa6T4t9NAfrufC", "status": "confirmed" }
```
Do not expect a 428 here.

### 2e. Patient records

- **Create a new patient** (two-phase): `POST /api/patients`. Required: `firstName`, `lastName`, `dateOfBirth` (`yyyy-MM-dd`), `phone` (≥ 4 digits), `email`, `address` (`line1`, `city`, `state`, `zip` required; `line2`/`country` optional). The scheduling system rejects a create without an email or a zip code, so collect them up front — don't wait for a 422 to ask. The 428 `readBack` here contains the name/DOB — that IS your natural read-back confirmation ("So that's John Doe, born March 15th 1985?"). Returns **201 `{patientId}`** — usable immediately for booking.
- **Update a patient** (two-phase): `PUT /api/patients/{patientId}`. Send the **full demographic core** (`firstName`, `lastName`, `phone`, `email`, `address` at minimum) even for a small change — a thin body is rejected (422) because some scheduling systems do a full replace and would blank the omitted fields.
- The `patientId` you use everywhere is the id you got back from `match` or `create` (or `GET /api/patients`).

---

## 3. Endpoint reference

All paths are under `{{API_BASE_URL}}`. JSON is **camelCase**. **Null/absent fields are omitted from responses** — always probe for a field before using it. Dates are `yyyy-MM-dd`; timestamps are ISO 8601. Every response carries an `X-RSI-TRACE-ID` header.

### Capabilities
| Method | Path | Scope | Response |
|---|---|---|---|
| GET | `/api/capabilities` | any valid token | `{ capabilities: string[] }` |

### Patients
| Method | Path | Scope | Key params / body | Response |
|---|---|---|---|---|
| POST | `/api/patients/match` | `patients:read` | body: `firstName`, `lastName`, `phone`, `dob` (`yyyy-MM-dd`) — **all four required**, phone ≥ 4 digits | `PatientSummary` — **ONE** patient, or **404** when nobody matches (never a list; duplicate records resolve to one canonical patient) |
| GET | `/api/patients` | `patients:read` | query: at least ONE of `firstName`, `lastName`, `dob`, `phone`, `_lastUpdated`; phone ≥ 4 digits | `CursorPage<PatientSummary>` |
| GET | `/api/patients/{patientId}` | `patients:read` | route: `patientId` (int) | `Patient` (detail) or 404 |
| POST | `/api/patients` | `patients:write` | body: `firstName`, `lastName`, `dateOfBirth`, `phone`, `email`, `address` (`line1`, `city`, `state`, `zip`) required; `providerId`, `address.line2`, `address.country` optional; **confirmable** | 201 `{ patientId }` |
| PUT | `/api/patients/{patientId}` | `patients:write` | route id + full demographic core in body (incl. `email`, `address`); **confirmable** | 200 `{ patientId }` or 404 |

`PatientSummary`: `id?`, `firstName`, `lastName`, `dateOfBirth?`, `phone?`, `email?`, `city?`, `state?`, `lastUpdated?`. Use `id` as `patientId`.
`Patient` (detail): adds `mobilePhone`, `homePhone`, `workPhone`, `otherPhone`, `addressLine1/2`, `zip`, `county`, `country`, `language`, `mrn`. **Never relay `mrn` to the patient.**

### Appointments
| Method | Path | Scope | Key params / body | Response |
|---|---|---|---|---|
| GET | `/api/appointments` | `appointments:read` | query: `patientId` **REQUIRED**; optional `from`, `to`, `providerId`, `locationId`, `visitTypeId`, `procedureId`; default window = upcoming | `CursorPage<AppointmentSummary>` |
| GET | `/api/appointments/{appointmentId}` | `appointments:read` | route: `appointmentId` (int) | `Appointment` (detail) or 404 |
| POST | `/api/appointments` | `appointments:book` | body: `patientId`, `slotId` required; `notes?`; **confirmable**. No free-text visit reason — the slot already encodes the visit type + procedure the patient chose | 201 `{ appointmentId }` |
| POST | `/api/appointments/{id}/cancel` | `appointments:cancel` | body OPTIONAL: `{ confirmationToken? }`; **confirmable** | 200 `{ appointmentId, status: "cancelled" }` |
| POST | `/api/appointments/{id}/confirm` | `appointments:confirm` | no body; **NOT confirmable** (one-shot) | 200 `{ appointmentId, status: "confirmed" }` |

`AppointmentSummary`: `id`, `patientId`, `start`, `end?`, `status?`, `providerId?`, `providerName?`, `locationId?`, `locationName?`, `visitTypeId?`, `visitType?`, `reason?`.
`Appointment` (detail): adds `locationAddress?`; drops `patientId` guarantee (nullable). Booking `notes` are write-side input and are never read back. `status` values: `proposed`, `pending`, `booked`, `arrived`, `fulfilled`, `cancelled`, `noshow`, `checkedin`, `rescheduled`, `other`.

### Slots
| Method | Path | Scope | Key params / body | Response |
|---|---|---|---|---|
| GET | `/api/slots` | `slots:read` | query: `providerId`, `locationId`, `visitTypeId`, `procedureId` **ALL REQUIRED** (> 0) + `from`, `to` **REQUIRED** (`yyyy-MM-dd`, `from ≤ to`, window **≤ 14 days**) | `CursorPage<Slot>` |
| GET | `/api/slots/{slotId}` | `slots:read` | route: `slotId` (long) | `SlotDetail` or 404 |

`Slot`: `id`, `start`, `end`, `providerId`, `locationId`, `visitTypeId`, `procedureId` (echoes your query).
`SlotDetail`: `id`, `start`, `end`, `available`, `providerId?`, `providerName?`, `locationId?`, `locationName?`, `locationAddress?`, `visitTypeId?`, `visitType?`, `reason?`. This is your booking read-back; `available` is the live availability answer (an open slot is re-checked in the scheduling system inside this read), so expect it to take seconds, not milliseconds. When `available` is `false` the response carries only `id`/`start`/`end` — re-search rather than reading it back. `visitType`/`reason` may be absent when the practice's mapping can't pin them to one answer — you already know the visit type and procedure you searched with, so read those back to the patient.

### Directory (all scope `practices:read`)

> **Id format:** every id on this API is an opaque encoded string — a kind prefix (`pat_`, `apt_`, `slt_`, `prv_`, `loc_`, `vst_`, `prc_`) plus 22 characters. Ids are practice-bound and never guessable; pass them back exactly as received. (Plain numeric ids are still *accepted* on input during the transition, but every response carries the encoded form.)
| Method | Path | Key params | Response |
|---|---|---|---|
| GET | `/api/locations` | — | `CursorPage<LocationSummary>` (`id`, `name`, `address`) |
| GET | `/api/locations/{id}` | route id | `Location` (`id`, `name`, `address`, `hours[]`, `fax?`, `email?`) |
| GET | `/api/providers` | `?locationId=` (optional filter) | `CursorPage<ProviderSummary>` (`id`, `name`, `npi?`) |
| GET | `/api/providers/{id}` | route id | `Provider` (`id`, `name`, `npi?`, `locations[]`, `prefix?`, `credentials?`, `biography?`, `photo?`, ...) |
| GET | `/api/providers/{id}/business-hours` | route id | `CollectionResponse<ProviderLocationHours>` (`locationId`, `locationName`, `hours[]`) — **no cursor**; unknown provider → empty list, not 404 |
| GET | `/api/visit-types` | — | `CursorPage<VisitType>` (`id`, `name`) |
| GET | `/api/visit-types/{id}/procedures` | route id | `CollectionResponse<ProcedureSummary>` (`id`, `name`) — **no cursor**, short list; unknown visit type → empty list, not 404 |
| GET | `/api/procedures` | — | `CursorPage<ProcedureSummary>` (`id`, `name`) |
| GET | `/api/procedures/{id}` | route id | `Procedure` (`id`, `name`, `category?`, `requireInsurance`) |

### Pagination
Collection endpoints return `CursorPage`: `{ items: [...], nextCursor?: string, limit: number }`.
- Continue with `?cursor=<nextCursor>` (URL-encode it). `nextCursor` is **omitted on the last page**.
- `?limit=` defaults to 20, clamped to a max of 100; invalid/absent → 20. An unparseable cursor is treated as "from the start" (never a 422).
- `CollectionResponse` (the two directory sub-lists above) is `{ items: [...] }` with **no cursor and no limit** — it is unpaged; read all of `items`.

---

## The confirmation (428) protocol

Because your client requires confirmation, every **confirmable** mutation is two-phase. Confirmable: **book** (`POST /api/appointments`), **cancel** (`POST /api/appointments/{id}/cancel`), **create patient** (`POST /api/patients`), **update patient** (`PUT /api/patients/{id}`). NOT confirmable: **attendance confirm** (`POST /api/appointments/{id}/confirm`) and all reads.

### The dance
1. **Phase 1 — challenge.** POST the action with your normal body and **no** `confirmationToken`. You get:
   ```json
   428  application/problem+json
   { "code": "confirmation_required",
     "confirmationToken": "<32 hex chars>",
     "expiresAt": "<ISO-8601 UTC>",
     "readBack": { ...the business fields of your request, nulls omitted... } }
   ```
   `428` is **expected**, not an error. `readBack` is the machine's echo of what it understood.
2. **Deliver the human-meaningful confirmation.** For booking you should already have read back provider/location/time from `GET /api/slots/{id}` (do that *before* the POST). For create-patient, read back the name/DOB from `readBack`. **Never include `patientId`, `slotId`, `appointmentId`, or the token in what you relay.**
3. **Phase 2 — confirm.** Resend the **identical body** plus `confirmationToken` (in the body; for cancel it goes in the otherwise-optional body). You get the real result (201 / 200).

### Rules and edge cases
- **Byte-identical resend.** The token is bound to a fingerprint of your exact fields. Change *any* business field between phase 1 and phase 2 → **409 `confirmation_mismatch`**, nothing executes, and the original intent stays valid until it expires. Recovery: re-read current state and start a fresh challenge with the corrected body.
- **Safe blind retry.** Resending the *same* token + *same* body after it already succeeded **replays the stored result** (same `appointmentId`/`patientId`) — it does NOT double-book. If a network blip leaves you unsure a confirm landed, just resend identically.
- **Expired token** (`expiresAt` passed) → you get a **fresh 428** with a new token; use the new one. Same for a token the server doesn't recognize.
- **`confirmation_incomplete` (409).** The token was consumed but the outcome wasn't recorded (it may have executed, or failed mid-write). Do NOT blindly retry. **Verify current state via the read endpoints** (`GET /api/appointments?patientId=`, `GET /api/appointments/{id}`, `GET /api/patients/{id}`) — if the action already happened, proceed; if not, start a fresh challenge (no token) to retry.
- **Upstream declined** (e.g. slot taken, scheduling system rejected). The token is released so you can retry the already-confirmed intent once the problem is fixed (re-search a slot, then resend with the same token, or start fresh).
- **Never mint before valid.** An invalid request (e.g. missing `patientId`) returns **422** and never produces a token.

---

## Error handling

All errors are RFC 7807 `application/problem+json` with a stable `code`, plus `requestId` and an `X-RSI-TRACE-ID` header. Match on `code`, not on `title`/`detail` (those are human strings and may carry no PHI by design).

| HTTP | `code` | Meaning | What you do |
|---|---|---|---|
| 401 | `unauthorized` | No/invalid/expired token | Refresh the token and retry once. If it recurs, hand off to a human. |
| 403 | `forbidden_scope` | Token lacks the endpoint's scope | You cannot perform this action with this client. Don't promise it; offer a human handoff. Check `/api/capabilities`. |
| 403 | `forbidden_tenant` | Token's practice couldn't be resolved | Configuration problem — stop, escalate with the trace id. Do not retry blindly. |
| 404 | `not_found` | Unknown or cross-practice resource (patient/slot/appointment/provider/location) | The id doesn't belong to this practice or doesn't exist. Re-confirm identity / re-run the directory or `match` lookup; never assume another patient. |
| 422 | `validation` | Bad/missing parameters (`errors[]` lists `{field, rule, message}`), OR the scheduling system declined a write | If parameters: fix the named field and retry (e.g. add `patientId`, shrink the date window to ≤ 14 days, lengthen the phone to ≥ 4 digits). If a write was declined: the scheduling system rejected the data; tell the caller you couldn't complete it and, if needed, hand off. |
| 428 | `confirmation_required` | The two-phase challenge | Not an error. Resend identically with the `confirmationToken`. See the protocol section. |
| 409 | `confirmation_mismatch` | Confirm body differs from the challenged intent | Start over: re-read state, issue a fresh challenge (no token) with the correct body. |
| 409 | `confirmation_incomplete` | Token consumed, outcome unknown | Verify via the read endpoints before any retry (see protocol). |
| 409 | `slot_unavailable` | The slot was taken between search and book | Re-run `GET /api/slots`, offer the caller the next available times, book the newly chosen slot. |
| 409 | `conflict` | Generic conflicting state | Re-read the relevant resource, reconcile, retry. |
| 502 | `upstream_unavailable` | The scheduling system is unreachable | Transient. Retry after a short pause; if it persists, tell the caller the scheduling system is temporarily down and offer a callback/human handoff. |
| 503 | `capability_not_supported` | This practice's scheduling system can't do this operation at all | Retrying will never work at this practice. Don't retry; tell the caller this can't be done here and hand off. |
| 500 | `internal` | Server error, or a write whose audit couldn't be recorded (outcome unknown) | For a mutation: do NOT assume it failed — **verify via the read endpoints** before retrying. Otherwise retry once, then escalate with the trace id. |

On any escalation or human handoff, quote the `X-RSI-TRACE-ID` from the failing response so support can trace it. Never read the trace id to the *patient*; it's for the support/log path.

---

## Conversational rules for agents

- **Identity first.** Do not disclose *any* patient or appointment information until you've confirmed the caller via `POST /api/patients/match` (name + DOB + phone). A `match` 200 confirms them; a 404 means you have not verified them — do not read back records, offer to create a new patient or take a message.
- **Never expose ids or tokens.** `patientId`, `slotId`, `appointmentId`, `confirmationToken`, `mrn`, and the trace id are internal. Read back **names, dates, times, provider, location, appointment type** instead.
- **Read back before every commit.** Before booking/cancelling/creating, say the human details and get a "yes." The `GET /api/slots/{id}` read-back is the booking confirmation; the 428 `readBack` is a machine echo, not your script.
- **One clarifying question at a time.** When a choice is ambiguous — several matching locations, two providers named "Smith", or several bookable procedures under a visit type — ask exactly one focused question, then proceed. Don't dump the whole list.
- **Dates and times, in natural language.** Convert `yyyy-MM-dd` / ISO timestamps to natural language ("Thursday, July 9th at 2 in the afternoon"). When the patient gives a fuzzy time ("in a couple of days"), turn it into a `from`/`to` window ≤ 14 days and pick the closest returned slot.
- **Confirm outcomes.** After a 201/200, tell the caller it's done in plain terms ("You're booked with Dr Smith on Thursday at 2"). After a `cancelled`/`confirmed` status, say so.
- **Repeated failures → human handoff.** If the same call fails twice (or you hit `forbidden_scope`, `forbidden_tenant`, `capability_not_supported`), stop retrying, apologize, and offer to transfer to a person or take a callback. Note the trace id for the log.

---

## Hard constraints

- **HIPAA minimum-necessary.** Never call `GET /api/appointments` without a `patientId` (it's required and refused otherwise). Never reveal a different patient's data. Cross-practice / unknown ids come back as an indistinguishable `404` on purpose — do not try to infer whether a record exists.
- **Two-phase for all mutations under your grant.** Book, cancel, create-patient, update-patient are 428-then-confirm. Attendance-confirm and all reads are single-call.
- **Date/time formats.** Dates (`dob`, `dateOfBirth`, slot/appointment `from`/`to`) are strings `yyyy-MM-dd`. Timestamps in responses (`start`, `end`, `expiresAt`, `lastUpdated`) are ISO 8601. A full datetime where a date is expected fails model binding (400/422) — send date-only.
- **Slot window ≤ 14 days.** `GET /api/slots` requires `from` and `to`, both dates, with `from ≤ to` and `to − from ≤ 14 days`. A wider window is a 422 — split into multiple searches if the caller wants a range beyond two weeks.
- **All four slot selectors are required.** No slot search without `providerId`, `locationId`, `visitTypeId`, and `procedureId`. Resolve them via the directory endpoints (visit type before procedure) before searching.
- **Null fields are omitted.** Any response field described as optional may simply be absent. Check before you read it; treat absent as "not provided."
