openapi: 3.1.0

info:
  title: PlaySpace Partner Platform API
  version: 1.0.0
  summary: Bring therapeutic play into your platform.
  description: |
    The PlaySpace Partner Platform API lets a practice-management or electronic
    health record platform embed PlaySpace's interactive therapy surfaces —
    sandtray, dollhouse, whiteboard, games — and its generated content —
    storybooks, worksheets, forms, three-dimensional models — into its own
    product.

    ## Authentication

    Exchange your client credentials for a bearer token at `POST /oauth/token`,
    then send it as `Authorization: Bearer <token>` on every request. Access
    tokens live one hour. Client credentials are server credentials and must
    never reach a browser: to render a surface in a browser, mint a short-lived
    session token at `POST /tokens` and hand that to the front end instead.

    ## Identity is a pointer, not a copy

    Your platform is the record of truth for people. Every clinic, practitioner
    and patient carries an `external_id` — your identifier — and every path that
    accepts a PlaySpace identifier also accepts yours, prefixed `ext:`.

    ```
    GET /v1/patients/pt_9Kd2mXwF
    GET /v1/patients/ext:client_55130
    ```

    Write verbs on identity resources are upserts keyed on `external_id`. They
    create on first sight and update thereafter, so they are safe to call on
    every synchronisation pass and there is nothing to reconcile.

    ## Envelopes

    Every success response is `{ "data": ..., "meta": ... }`. `meta` always
    carries `request_id` and `generated_at`, and carries `pagination` on list
    responses. Every failure is an RFC 9457 problem document served as
    `application/problem+json`.

    ## Idempotency

    `POST`, `PATCH` and `DELETE` require an `Idempotency-Key` header. A repeat
    with the same key and the same body replays the original response byte for
    byte. The same key with a different body is rejected with
    `422 idempotency-key-mismatch`. Keys are retained 24 hours.

    ## Pagination

    Cursor-based. Pass `limit` up to 100 and follow `meta.pagination.next_cursor`.
    Offsets are not supported.

    ## Rate limits

    Two windows per organisation, minute and hour, with the more restrictive one
    winning. Every response carries the remaining count and reset time for both.

    ## Protected health information

    No person's name and no free-text search term ever travels in a URL, in
    either direction. Person search is `POST /patients/search` with a request
    body, and there is no `GET` equivalent. Change-feed payloads and error
    documents carry identifiers, statuses, counts and timestamps only.

    ## Deletion

    Deletion is soft and unenumerable. A `DELETE` archives a record rather than
    destroying it, and reading something absent, archived, or belonging to
    another organisation all return the same `404`.
  termsOfService: https://playspace.health/partner-terms
  contact:
    name: PlaySpace Partner Engineering
    url: https://developers.playspace.health/support
    email: partner-engineering@playspace.health
  license:
    name: Proprietary
    url: https://playspace.health/partner-terms

externalDocs:
  description: Developer portal
  url: https://developers.playspace.health

servers:
  - url: https://api.playspace.health/v1
    description: Production
  - url: https://api.sandbox.playspace.health/v1
    description: Sandbox — synthetic data, weekly reset, no outbound messages

security:
  - bearerAuth: []

tags:
  - name: Organisation
    description: Your organisation, the surfaces it is licensed for, its practice hierarchy.
  - name: Identity
    description: |
      Clinics, practitioners, patients and caregiver relationships. Write verbs are
      upserts keyed on `external_id`.
  - name: Sessions
    description: |
      A session is one appointment's worth of PlaySpace. Everything produced inside
      one is attributed to it.
  - name: Tokens
    description: |
      Short-lived, role-scoped, single-person tokens that authorise a browser
      surface. The token carries the role, so components need no role parameter.
  - name: Content
    description: Playrooms, toolkits, games, storybooks, worksheets and forms.
  - name: Generation
    description: |
      Creating new content. Every operation here is asynchronous, returns a job, and
      is metered against the organisation's quota.
  - name: Jobs
    description: Status of asynchronous work.
  - name: Artifacts
    description: |
      The single index over everything PlaySpace produced and kept, attributed to a
      clinician, optionally a client, and optionally a session.
  - name: Exports
    description: Packaging a person's or an organisation's record for portability.
  - name: Notes
    description: |
      Clinical documentation, read-only, available only to organisations with the
      notes capability enabled.
  - name: Changes
    description: One ordered, resumable feed of everything that changed.
  - name: Usage
    description: Generation quota consumption.

paths:

  # ─── Authentication ──────────────────────────────────────────────────────────

  /oauth/token:
    post:
      operationId: createAccessToken
      summary: Exchange client credentials for an access token
      tags: [Organisation]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, client_secret, grant_type]
              properties:
                client_id:
                  type: string
                  examples: [psc_live_4f81c2a9]
                client_secret:
                  type: string
                  format: password
                grant_type:
                  type: string
                  const: client_credentials
                scope:
                  type: string
                  description: Space-delimited. Omit to receive every scope granted to your organisation.
                  examples: ["identity:write sessions:write content:read generation:write"]
      responses:
        '200':
          description: An access token.
          headers:
            Cache-Control:
              schema: { type: string, const: no-store }
          content:
            application/json:
              schema:
                type: object
                required: [access_token, token_type, expires_in]
                properties:
                  access_token: { type: string }
                  token_type: { type: string, const: Bearer }
                  expires_in: { type: integer, examples: [3600] }
                  scope: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ─── Organisation ────────────────────────────────────────────────────────────

  /organisation:
    get:
      operationId: getOrganisation
      summary: Retrieve your organisation
      tags: [Organisation]
      responses:
        '200':
          description: Your organisation.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data: { $ref: '#/components/schemas/Organisation' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /organisation/surfaces:
    get:
      operationId: listLicensedSurfaces
      summary: List the surfaces this organisation is licensed for
      description: |
        Render your own entry points from this list. A surface absent here will be
        refused at token mint with `entitlement-denied`, so filtering ahead of time
        keeps a clinician from clicking something that cannot work.
      tags: [Organisation]
      responses:
        '200':
          description: Licensed surfaces.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            surface: { $ref: '#/components/schemas/Surface' }
                            licensed: { type: boolean }
                            embeddable:
                              type: boolean
                              description: Whether this surface can render in a partner-hosted frame.
        '401': { $ref: '#/components/responses/Unauthorized' }

  /organisation/groups:
    get:
      operationId: listPracticeGroups
      summary: List practice groups
      description: The optional hierarchy level above the clinic, for multi-location practices.
      tags: [Organisation]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Practice groups.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/PracticeGroup' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # ─── Identity: clinics ───────────────────────────────────────────────────────

  /clinics:
    get:
      operationId: listClinics
      summary: List clinics
      tags: [Identity]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: group
          in: query
          schema: { type: string }
          description: Restrict to one practice group.
        - $ref: '#/components/parameters/includeArchived'
      responses:
        '200':
          description: Clinics.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Clinic' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: upsertClinic
      summary: Create or update a clinic
      description: Keyed on `external_id`. Creates on first sight, updates thereafter.
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClinicWrite' }
      responses:
        '200':
          description: The clinic was updated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Clinic' } }
        '201':
          description: The clinic was created.
          headers:
            Location: { schema: { type: string, format: uri } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Clinic' } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /clinics/{clinicId}:
    parameters: [{ $ref: '#/components/parameters/clinicId' }]
    get:
      operationId: getClinic
      summary: Retrieve a clinic
      tags: [Identity]
      responses:
        '200':
          description: The clinic.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Clinic' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateClinic
      summary: Update a clinic
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ClinicWrite' }
      responses:
        '200':
          description: The updated clinic.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Clinic' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      operationId: archiveClinic
      summary: Archive a clinic
      description: |
        Soft. The clinic becomes unreadable rather than being destroyed. A clinic with
        active practitioners or patients is refused with `clinic-not-empty`.
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  # ─── Identity: practitioners ─────────────────────────────────────────────────

  /practitioners:
    get:
      operationId: listPractitioners
      summary: List practitioners
      tags: [Identity]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: clinic
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/includeArchived'
      responses:
        '200':
          description: Practitioners.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Practitioner' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: upsertPractitioner
      summary: Create or update a practitioner
      description: |
        Keyed on `external_id`. A practitioner your platform vouches for is a
        practitioner PlaySpace accepts — no PlaySpace account, invitation or login is
        created, and identity flows one way, from you to us.
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PractitionerWrite' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Practitioner' } }
        '201':
          description: Created.
          headers:
            Location: { schema: { type: string, format: uri } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Practitioner' } }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /practitioners/{practitionerId}:
    parameters: [{ $ref: '#/components/parameters/practitionerId' }]
    get:
      operationId: getPractitioner
      summary: Retrieve a practitioner
      tags: [Identity]
      responses:
        '200':
          description: The practitioner.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Practitioner' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updatePractitioner
      summary: Update a practitioner
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PractitionerWrite' }
      responses:
        '200':
          description: The updated practitioner.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Practitioner' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      operationId: archivePractitioner
      summary: Archive a practitioner
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Identity: patients ──────────────────────────────────────────────────────

  /patients:
    get:
      operationId: listPatients
      summary: List patients
      tags: [Identity]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: clinic
          in: query
          schema: { type: string }
        - name: practitioner
          in: query
          schema: { type: string }
          description: Restrict to one clinician's caseload.
        - $ref: '#/components/parameters/includeArchived'
      responses:
        '200':
          description: Patients.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Patient' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: upsertPatient
      summary: Create or update a patient
      description: |
        Keyed on `external_id`. Send the minimum that lets a clinician recognise the
        right person in a list — a first initial and a last initial are a complete and
        acceptable name.
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatientWrite' }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Patient' } }
        '201':
          description: Created.
          headers:
            Location: { schema: { type: string, format: uri } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Patient' } }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /patients/search:
    post:
      operationId: searchPatients
      summary: Search patients
      description: |
        A `POST` deliberately, and there is no `GET` equivalent. A person's name or a
        free-text term in a query string is captured by platform request logs before
        anything at the application layer can act on it, so the term travels in a body
        instead. This is a routing decision, not a redaction one.
      tags: [Identity]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string, minLength: 1, maxLength: 120 }
                clinic: { type: string }
                practitioner: { type: string }
                limit: { type: integer, minimum: 1, maximum: 100, default: 25 }
      responses:
        '200':
          description: Matches, ranked by relevance.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Patient' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /patients/{patientId}:
    parameters: [{ $ref: '#/components/parameters/patientId' }]
    get:
      operationId: getPatient
      summary: Retrieve a patient
      tags: [Identity]
      responses:
        '200':
          description: The patient.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Patient' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updatePatient
      summary: Update a patient
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PatientWrite' }
      responses:
        '200':
          description: The updated patient.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Patient' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }
    delete:
      operationId: archivePatient
      summary: Archive a patient
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  /patients/{patientId}/caregivers:
    parameters: [{ $ref: '#/components/parameters/patientId' }]
    get:
      operationId: listPatientCaregivers
      summary: List a patient's caregivers
      tags: [Identity]
      responses:
        '200':
          description: Caregiver links.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/CaregiverLink' }
        '404': { $ref: '#/components/responses/NotFound' }

  /caregivers/link:
    post:
      operationId: linkCaregiver
      summary: Link a dependent to a caregiver
      description: |
        The relationship that decides who receives a session link, who may join
        alongside a child, and who may request an export. In a paediatric caseload it
        is load-bearing rather than decorative.
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [patient, caregiver, relationship]
              properties:
                patient: { $ref: '#/components/schemas/Ref' }
                caregiver: { $ref: '#/components/schemas/Ref' }
                relationship: { $ref: '#/components/schemas/CaregiverRelationship' }
                receives_session_links: { type: boolean, default: true }
                can_request_export: { type: boolean, default: false }
      responses:
        '201':
          description: Linked.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/CaregiverLink' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /caregivers/link/{linkId}:
    parameters:
      - name: linkId
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: unlinkCaregiver
      summary: Remove a caregiver link
      tags: [Identity]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Unlinked. }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Sessions ────────────────────────────────────────────────────────────────

  /sessions:
    get:
      operationId: listSessions
      summary: List sessions
      tags: [Sessions]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/SessionStatus' }
        - name: clinician
          in: query
          schema: { type: string }
        - name: patient
          in: query
          schema: { type: string }
        - name: scheduled_after
          in: query
          schema: { type: string, format: date-time }
        - name: scheduled_before
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Sessions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Session' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createSession
      summary: Create a session
      description: |
        Created against an appointment that lives in your scheduler. PlaySpace does not
        need the appointment itself, only your identifier for it, so that everything the
        session produces can be traced back to it.

        `surfaces` is the ceiling for this session. A surface absent from it cannot be
        opened by anyone in the session, whatever a token or a front end asks for.
      tags: [Sessions]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionCreate' }
            examples:
              virtual:
                summary: A virtual appointment with one child
                value:
                  appointment: { external_id: appt_11923, scheduled_at: '2026-09-02T15:00:00Z' }
                  clinician: { external_id: staff_8842 }
                  participants: [{ patient: { external_id: client_55130 } }]
                  playroom: child-default
                  surfaces: [sandtray, dollhouse, whiteboard, games]
                  notify: { patient: true, clinician: false }
              homework:
                summary: An asynchronous container for work done between appointments
                value:
                  appointment: { external_id: homework_2026_w36 }
                  clinician: { external_id: staff_8842 }
                  participants: [{ patient: { external_id: client_55130 } }]
                  surfaces: [worksheets, storybooks]
      responses:
        '201':
          description: The session, with both role-scoped hosted links.
          headers:
            Location: { schema: { type: string, format: uri } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Session' } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sessions/{sessionId}:
    parameters: [{ $ref: '#/components/parameters/sessionId' }]
    get:
      operationId: getSession
      summary: Retrieve a session
      tags: [Sessions]
      responses:
        '200':
          description: The session. Hosted links are omitted; re-mint them explicitly.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Session' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateSession
      summary: Update a session
      description: Reschedule, change the playroom, or narrow the permitted surfaces.
      tags: [Sessions]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                scheduled_at: { type: string, format: date-time }
                playroom: { type: string }
                surfaces:
                  type: array
                  items: { $ref: '#/components/schemas/Surface' }
                notify: { $ref: '#/components/schemas/NotifyFlags' }
      responses:
        '200':
          description: The updated session.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Session' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /sessions/{sessionId}/end:
    parameters: [{ $ref: '#/components/parameters/sessionId' }]
    post:
      operationId: endSession
      summary: End a session
      description: Moves the session to `complete` and finalises its summary.
      tags: [Sessions]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '200':
          description: The completed session.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Session' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /sessions/{sessionId}/cancel:
    parameters: [{ $ref: '#/components/parameters/sessionId' }]
    post:
      operationId: cancelSession
      summary: Cancel a session
      description: Revokes every outstanding token and link for the session.
      tags: [Sessions]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 200 }
                notify: { $ref: '#/components/schemas/NotifyFlags' }
      responses:
        '200':
          description: The cancelled session.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Session' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /sessions/{sessionId}/summary:
    parameters: [{ $ref: '#/components/parameters/sessionId' }]
    get:
      operationId: getSessionSummary
      summary: Retrieve what happened in a session
      tags: [Sessions]
      responses:
        '200':
          description: |
            Duration, participants, surfaces used, artifacts produced and games played.

            Three caveats before building a report from `games_played`. Only the
            clinician's play bracket is persisted, because a participant on a session
            token holds no account identity. Every duration is an upper bound, since a
            backgrounded tab keeps accruing. And a remount can split one continuous play
            into two entries, so sum by game rather than assuming one entry per play.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/SessionSummary' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /sessions/{sessionId}/links:
    parameters: [{ $ref: '#/components/parameters/sessionId' }]
    post:
      operationId: createSessionLinks
      summary: Re-mint both hosted session links
      description: |
        Hosted links are ephemeral and are never stored. Mint them when you are about to
        put them in front of somebody.
      tags: [Sessions]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '201':
          description: Freshly minted links.
          headers:
            Cache-Control: { schema: { type: string, const: no-store } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/SessionLinks' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: |
            The session is not link-eligible — for example an in-person session with no
            virtual room.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  # ─── Tokens ──────────────────────────────────────────────────────────────────

  /tokens:
    post:
      operationId: issueSessionToken
      summary: Issue a session token for one person
      description: |
        The token carries the role, so a browser component needs no role parameter and
        cannot be handed the wrong one.

        A `clinician` token renders the facilitation side of every surface — the figure
        library, tool palette, navigation, saves and session controls. A `patient` token
        renders the participation side only.

        Three gates apply at mint, and all three fail loudly rather than issuing a token
        that will not work: the requested surfaces must be within the session's ceiling,
        every origin must be one a browser will honour as a frame source, and the
        organisation must be licensed for each surface.
      tags: [Tokens]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TokenCreate' }
            examples:
              clinician:
                summary: The clinician's token
                value:
                  session: sess_2Nk8pQvR7xLm
                  subject: { external_id: staff_8842 }
                  role: clinician
                  origins: ['https://app.example-practice.com']
                  ttl: 15m
              patient:
                summary: The child's token, narrowed to one surface
                value:
                  session: sess_2Nk8pQvR7xLm
                  subject: { external_id: client_55130 }
                  role: patient
                  surfaces: [sandtray]
                  origins: ['https://app.example-practice.com']
                  ttl: 60m
      responses:
        '201':
          description: The token.
          headers:
            Cache-Control: { schema: { type: string, const: no-store } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/SessionToken' } }
        '403':
          description: |
            The organisation is not licensed for a requested surface
            (`entitlement-denied`).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: |
            A requested surface exceeds the session's ceiling (`capability-missing`), or
            an origin is not one a browser will honour (`origin-not-allowed`).
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /tokens/{tokenId}:
    parameters:
      - name: tokenId
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: revokeSessionToken
      summary: Revoke a session token immediately
      description: Useful when a clinician signs out of your application mid-session.
      tags: [Tokens]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Revoked. }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Content: playrooms and toolkits ─────────────────────────────────────────

  /playrooms:
    get:
      operationId: listPlayrooms
      summary: List playrooms
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/PlayroomType' }
      responses:
        '200':
          description: Playrooms.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Playroom' }
    post:
      operationId: createPlayroom
      summary: Create a playroom
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PlayroomWrite' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Playroom' } }
        '422': { $ref: '#/components/responses/ValidationError' }

  /playrooms/{playroomId}:
    parameters:
      - name: playroomId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getPlayroom
      summary: Retrieve a playroom
      tags: [Content]
      responses:
        '200':
          description: The playroom, with its ordered contents.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Playroom' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updatePlayroom
      summary: Update a playroom
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PlayroomWrite' }
      responses:
        '200':
          description: The updated playroom.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Playroom' } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: archivePlayroom
      summary: Archive a playroom
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  /playrooms/{playroomId}/contents:
    parameters:
      - name: playroomId
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: attachPlayroomContent
      summary: Attach content to a playroom
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContentRef' }
      responses:
        '201':
          description: Attached.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/PlayroomContent' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /playrooms/{playroomId}/contents/{contentId}:
    parameters:
      - name: playroomId
        in: path
        required: true
        schema: { type: string }
      - name: contentId
        in: path
        required: true
        schema: { type: string }
    delete:
      operationId: detachPlayroomContent
      summary: Detach content from a playroom
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Detached. }
        '404': { $ref: '#/components/responses/NotFound' }

  /toolkits:
    get:
      operationId: listToolkits
      summary: List toolkits
      description: A toolkit is a lighter-weight curated set of the same content a playroom holds.
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Toolkits.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Toolkit' }
    post:
      operationId: createToolkit
      summary: Create a toolkit
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ToolkitWrite' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Toolkit' } }

  /toolkits/{toolkitId}:
    parameters:
      - name: toolkitId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getToolkit
      summary: Retrieve a toolkit
      tags: [Content]
      responses:
        '200':
          description: The toolkit.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Toolkit' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateToolkit
      summary: Update a toolkit
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ToolkitWrite' }
      responses:
        '200':
          description: The updated toolkit.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Toolkit' } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: archiveToolkit
      summary: Archive a toolkit
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Content: games ──────────────────────────────────────────────────────────

  /games:
    get:
      operationId: listGames
      summary: List the game catalog
      description: |
        The catalog this organisation is licensed for, across every provider. Filter by
        therapeutic skill, player count, age range or category.
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: skills
          in: query
          description: Comma-delimited. Matches a game developing any of them.
          schema: { type: string, examples: ['emotional-regulation,turn-taking'] }
        - name: players
          in: query
          schema: { type: integer, minimum: 1 }
        - name: age_min
          in: query
          schema: { type: integer }
        - name: age_max
          in: query
          schema: { type: integer }
        - name: category
          in: query
          schema: { type: string }
        - name: provider
          in: query
          schema: { $ref: '#/components/schemas/GameProvider' }
      responses:
        '200':
          description: Games.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Game' }

  /games/{gameSlug}:
    parameters:
      - name: gameSlug
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getGame
      summary: Retrieve one game
      tags: [Content]
      responses:
        '200':
          description: The game.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Game' } }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Content: storybooks ─────────────────────────────────────────────────────

  /storybooks:
    get:
      operationId: listStorybooks
      summary: List storybooks
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - $ref: '#/components/parameters/filterCreatedBy'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/ContentStatus' }
      responses:
        '200':
          description: Storybooks.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Storybook' }

  /storybooks/generate:
    post:
      operationId: generateStorybook
      summary: Generate a storybook
      description: |
        Asynchronous. Returns a job; poll it or watch the change feed for
        `artifact.ready`. Eight pages typically takes two to four minutes.

        Naming characters up front produces a visual description carried into every
        page's image generation, so a character looks the same on page eight as on page
        one. Omit `characters` and they are inferred from the prompt and locked after the
        first page.
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/StorybookGenerate' }
            examples:
              personal:
                summary: A story for one child
                value:
                  clinician: { external_id: staff_8842 }
                  subject: { external_id: client_55130 }
                  session: { external_id: appt_11923 }
                  prompt: >-
                    A story about starting at a new school, for a child who is worried
                    nobody will sit with them at lunch.
                  pages: 8
                  age_group: '5-8'
                  style: watercolour
                  reading_level: grade-2
                  characters:
                    - name: Nia
                      role: protagonist
                      description: seven years old, box braids, yellow raincoat
                    - name: Pip
                      role: supporting
                      description: a small grey cat with one white ear
      responses:
        '202':
          description: Accepted. A job is running.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /storybooks/{storybookId}:
    parameters: [{ $ref: '#/components/parameters/storybookId' }]
    get:
      operationId: getStorybook
      summary: Retrieve a storybook
      tags: [Content]
      responses:
        '200':
          description: The storybook, with its ordered pages.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Storybook' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateStorybook
      summary: Update a storybook
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                published: { $ref: '#/components/schemas/PublishTarget' }
      responses:
        '200':
          description: The updated storybook.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Storybook' } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: archiveStorybook
      summary: Archive a storybook
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  /storybooks/{storybookId}/pages:
    parameters: [{ $ref: '#/components/parameters/storybookId' }]
    get:
      operationId: listStorybookPages
      summary: List a storybook's pages
      tags: [Content]
      responses:
        '200':
          description: Pages, in reading order.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/StorybookPage' }
        '404': { $ref: '#/components/responses/NotFound' }

  /storybooks/pages/{pageId}:
    parameters:
      - name: pageId
        in: path
        required: true
        schema: { type: string }
    patch:
      operationId: updateStorybookPage
      summary: Edit a page's text
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, maxLength: 2000 }
      responses:
        '200':
          description: The updated page.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/StorybookPage' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /storybooks/pages/{pageId}/regenerate:
    parameters:
      - name: pageId
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: regenerateStorybookPage
      summary: Regenerate one page's illustration
      description: Leaves every other page untouched and preserves character consistency.
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                prompt:
                  type: string
                  description: An additional instruction. Omit to regenerate from the existing prompt.
      responses:
        '202':
          description: Accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /storybooks/{storybookId}/assign:
    parameters: [{ $ref: '#/components/parameters/storybookId' }]
    post:
      operationId: assignStorybook
      summary: Assign a storybook to a client
      description: |
        Creates a personal copy that tracks reading position, so a child can read it
        between appointments and the clinician can see how far they got.
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              properties:
                subject: { $ref: '#/components/schemas/Ref' }
                notify_caregiver: { type: boolean, default: false }
      responses:
        '201':
          description: The assigned copy.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Artifact' } }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Content: worksheets ─────────────────────────────────────────────────────

  /worksheets:
    get:
      operationId: listWorksheets
      summary: List worksheets
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - $ref: '#/components/parameters/filterCreatedBy'
      responses:
        '200':
          description: Worksheets.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Worksheet' }
    post:
      operationId: createWorksheet
      summary: Create a worksheet
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorksheetWrite' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Worksheet' } }

  /worksheets/generate:
    post:
      operationId: generateWorksheet
      summary: Generate a worksheet
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorksheetGenerate' }
            examples:
              default:
                value:
                  clinician: { external_id: staff_8842 }
                  subject: { external_id: client_55130 }
                  prompt: A feelings-identification worksheet using animals rather than faces.
                  pages: 3
                  age_group: '5-8'
                  include_imagery: true
      responses:
        '202':
          description: Accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /worksheets/{worksheetId}:
    parameters: [{ $ref: '#/components/parameters/worksheetId' }]
    get:
      operationId: getWorksheet
      summary: Retrieve a worksheet
      tags: [Content]
      responses:
        '200':
          description: The worksheet, with its pages.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Worksheet' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateWorksheet
      summary: Update a worksheet
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WorksheetWrite' }
      responses:
        '200':
          description: The updated worksheet.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Worksheet' } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: archiveWorksheet
      summary: Archive a worksheet
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  /worksheets/{worksheetId}/assign:
    parameters: [{ $ref: '#/components/parameters/worksheetId' }]
    post:
      operationId: assignWorksheet
      summary: Assign a worksheet as homework
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              properties:
                subject: { $ref: '#/components/schemas/Ref' }
                due_at: { type: string, format: date-time }
                notify_caregiver: { type: boolean, default: false }
      responses:
        '201':
          description: The assigned copy.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Artifact' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /worksheets/copies:
    get:
      operationId: listWorksheetCopies
      summary: List completed worksheet copies
      description: |
        A completed copy is its own artifact, distinct from the blank worksheet it came
        from. The blank is library content with no subject; the copy has one.
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - name: worksheet
          in: query
          schema: { type: string }
        - name: status
          in: query
          schema: { type: string, enum: [assigned, in_progress, complete] }
      responses:
        '200':
          description: Copies.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WorksheetCopy' }

  # ─── Content: forms ──────────────────────────────────────────────────────────

  /forms:
    get:
      operationId: listForms
      summary: List forms
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterCreatedBy'
      responses:
        '200':
          description: Forms.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Form' }
    post:
      operationId: createForm
      summary: Create a form
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FormWrite' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Form' } }

  /forms/generate:
    post:
      operationId: generateForm
      summary: Generate a form from a prompt
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clinician, prompt]
              properties:
                clinician: { $ref: '#/components/schemas/Ref' }
                subject: { $ref: '#/components/schemas/Ref' }
                session: { $ref: '#/components/schemas/Ref' }
                prompt: { type: string, minLength: 10, maxLength: 2000 }
                max_fields: { type: integer, minimum: 1, maximum: 60, default: 20 }
                scored: { type: boolean, default: false }
      responses:
        '202':
          description: Accepted. Typically under thirty seconds.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /forms/extract:
    post:
      operationId: extractForm
      summary: Build a form from an uploaded document
      description: |
        Reads an existing portable-document or image intake sheet and produces a
        structured, fillable form from it. The single most common request from clinics
        with twenty years of paper.
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [clinician, file]
              properties:
                clinician:
                  type: string
                  description: A PlaySpace or `ext:`-prefixed practitioner identifier.
                file:
                  type: string
                  format: binary
                  description: Portable document, PNG or JPEG. Maximum 20 megabytes.
      responses:
        '202':
          description: Accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /forms/{formId}:
    parameters: [{ $ref: '#/components/parameters/formId' }]
    get:
      operationId: getForm
      summary: Retrieve a form
      tags: [Content]
      responses:
        '200':
          description: The form, with its field definitions.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Form' } }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateForm
      summary: Update a form
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/FormWrite' }
      responses:
        '200':
          description: The updated form.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Form' } }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: archiveForm
      summary: Archive a form
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '204': { description: Archived. }
        '404': { $ref: '#/components/responses/NotFound' }

  /forms/{formId}/assign:
    parameters: [{ $ref: '#/components/parameters/formId' }]
    post:
      operationId: assignForm
      summary: Assign a form for completion
      description: |
        Set `route_to_caregiver` for a paediatric client so the link reaches the adult
        who holds consent rather than the child.
      tags: [Content]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subject]
              properties:
                subject: { $ref: '#/components/schemas/Ref' }
                route_to_caregiver: { type: boolean, default: false }
                due_at: { type: string, format: date-time }
      responses:
        '201':
          description: The assignment.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Artifact' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /forms/responses:
    get:
      operationId: listFormResponses
      summary: List form responses
      description: Structured answers, never a rendered blob.
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - name: form
          in: query
          schema: { type: string }
        - name: submitted_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Responses.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/FormResponse' }

  /forms/responses/{responseId}:
    parameters:
      - name: responseId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getFormResponse
      summary: Retrieve one form response
      tags: [Content]
      responses:
        '200':
          description: The response.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/FormResponse' } }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Generation: studio and models ───────────────────────────────────────────

  /studio/generate:
    post:
      operationId: generateGame
      summary: Generate a playable game
      description: |
        A clinician describes a game and gets a real playable application, launchable in
        a session like any catalog game. Four to eight minutes.
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clinician, prompt]
              properties:
                clinician: { $ref: '#/components/schemas/Ref' }
                subject: { $ref: '#/components/schemas/Ref' }
                session: { $ref: '#/components/schemas/Ref' }
                prompt: { type: string, minLength: 20, maxLength: 2000 }
                players: { type: integer, minimum: 1, maximum: 4, default: 2 }
                duration: { type: string, enum: [short, medium, long], default: short }
            examples:
              default:
                value:
                  clinician: { external_id: staff_8842 }
                  subject: { external_id: client_55130 }
                  prompt: >-
                    A two-player cooperative game where you take turns naming a feeling to
                    move a boat across a river.
                  players: 2
                  duration: short
      responses:
        '202':
          description: Accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /studio/games:
    get:
      operationId: listGeneratedGames
      summary: List generated games
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - $ref: '#/components/parameters/filterCreatedBy'
      responses:
        '200':
          description: Generated games.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/GeneratedGame' }

  /models/generate:
    post:
      operationId: generateModel
      summary: Generate a three-dimensional model
      description: |
        A figure that is not in the sandtray library, from a description or a photograph,
        usable in the tray immediately. Three to six minutes.

        An uploaded photograph is used to produce the model and then discarded. It is not
        retained, not used for training, and never appears in an export. The capability
        exists for objects, pets and places — do not upload an image containing a person.
      tags: [Generation]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [clinician, source, prompt]
              properties:
                clinician: { $ref: '#/components/schemas/Ref' }
                session: { $ref: '#/components/schemas/Ref' }
                source: { type: string, const: text }
                prompt: { type: string, minLength: 3, maxLength: 500 }
            examples:
              text:
                value:
                  clinician: { external_id: staff_8842 }
                  source: text
                  prompt: a small brown terrier with a red collar, sitting
          multipart/form-data:
            schema:
              type: object
              required: [clinician, source, file]
              properties:
                clinician: { type: string }
                source: { type: string, const: image }
                file:
                  type: string
                  format: binary
                  description: PNG or JPEG. Maximum 10 megabytes. Discarded after generation.
      responses:
        '202':
          description: Accepted.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/QuotaExceeded' }

  /models:
    get:
      operationId: listModels
      summary: List generated three-dimensional models
      tags: [Content]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterCreatedBy'
      responses:
        '200':
          description: Models.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Model' }

  # ─── Jobs ────────────────────────────────────────────────────────────────────

  /jobs:
    get:
      operationId: listJobs
      summary: List jobs
      tags: [Jobs]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/JobStatus' }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/JobType' }
      responses:
        '200':
          description: Jobs.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Job' }

  /jobs/{jobId}:
    parameters:
      - name: jobId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getJob
      summary: Retrieve a job
      description: |
        A failed job carries a `reason` written for a clinician rather than an engineer,
        and does not consume quota.
      tags: [Jobs]
      responses:
        '200':
          description: The job, with its result when complete.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /jobs/{jobId}/cancel:
    parameters:
      - name: jobId
        in: path
        required: true
        schema: { type: string }
    post:
      operationId: cancelJob
      summary: Cancel a job
      tags: [Jobs]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      responses:
        '200':
          description: The cancelled job.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Job' } }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: The job already reached a terminal state.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  # ─── Artifacts ───────────────────────────────────────────────────────────────

  /artifacts:
    get:
      operationId: listArtifacts
      summary: List artifacts
      description: |
        The single index over everything PlaySpace produced and kept. Queryable from any
        end of the attribution triple.
      tags: [Artifacts]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - $ref: '#/components/parameters/filterCreatedBy'
        - name: session
          in: query
          schema: { type: string }
        - name: type
          in: query
          schema: { $ref: '#/components/schemas/ArtifactType' }
        - name: created_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Artifacts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Artifact' }

  /artifacts/{artifactId}:
    parameters:
      - name: artifactId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getArtifact
      summary: Retrieve an artifact
      tags: [Artifacts]
      responses:
        '200':
          description: The artifact.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Artifact' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /artifacts/{artifactId}/download:
    parameters:
      - name: artifactId
        in: path
        required: true
        schema: { type: string }
      - name: format
        in: query
        required: true
        schema: { $ref: '#/components/schemas/DownloadFormat' }
    get:
      operationId: downloadArtifact
      summary: Download an artifact
      description: |
        Returns a short-lived, single-use link. Request one when you are ready to stream
        it, not in advance.

        `json` is available for every artifact type and is always complete: no rendered
        format carries information its structured form omits.
      tags: [Artifacts]
      responses:
        '200':
          description: A download link.
          headers:
            Cache-Control: { schema: { type: string, const: no-store } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: object
                        properties:
                          url: { type: string, format: uri }
                          format: { $ref: '#/components/schemas/DownloadFormat' }
                          size_bytes: { type: integer }
                          expires_at: { type: string, format: date-time }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: That format is not available for this artifact type.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  # ─── Exports ─────────────────────────────────────────────────────────────────

  /exports:
    get:
      operationId: listExports
      summary: List exports
      description: Every export is a disclosure and is retained in the audit history.
      tags: [Exports]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
      responses:
        '200':
          description: Exports.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Export' }
    post:
      operationId: createExport
      summary: Create an export
      description: |
        `requested_by` must resolve to somebody with standing: a clinician for their own
        clients, an administrator for any client in their clinic, or a caregiver for a
        dependent whose link carries `can_request_export`.

        Clinical notes are included only when the organisation has the notes capability,
        `notes` is named explicitly in `include`, and the requester is a clinician or an
        administrator. A caregiver-initiated export never contains notes.
      tags: [Exports]
      parameters: [{ $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExportCreate' }
            examples:
              person:
                summary: Everything one client has made
                value:
                  subject: { external_id: client_55130 }
                  include: [storybooks, worksheets, form_responses, sandtray_saves, session_summaries]
                  format: bundle
                  requested_by: { external_id: staff_8842 }
                  reason: client_request
              organisation:
                summary: A whole-organisation export for a migration
                value:
                  scope: organisation
                  include: ['*']
                  format: bundle
                  requested_by: { external_id: staff_0001 }
                  reason: migration
      responses:
        '202':
          description: Accepted. Exports are jobs.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Export' } }
        '403':
          description: The requester has no standing to make this disclosure.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422': { $ref: '#/components/responses/ValidationError' }

  /exports/{exportId}:
    parameters:
      - name: exportId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: getExport
      summary: Retrieve an export
      tags: [Exports]
      responses:
        '200':
          description: The export.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Export' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /exports/{exportId}/download:
    parameters:
      - name: exportId
        in: path
        required: true
        schema: { type: string }
    get:
      operationId: downloadExport
      summary: Download a completed export
      description: |
        Available for seven days after completion, then deleted. A large
        organisation-scoped export is delivered as independently downloadable parts,
        listed in the top-level manifest.
      tags: [Exports]
      responses:
        '200':
          description: One or more download links.
          headers:
            Cache-Control: { schema: { type: string, const: no-store } }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: object
                        properties:
                          parts:
                            type: array
                            items:
                              type: object
                              properties:
                                url: { type: string, format: uri }
                                part: { type: integer }
                                size_bytes: { type: integer }
                          expires_at: { type: string, format: date-time }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The export is not complete yet.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  # ─── Notes ───────────────────────────────────────────────────────────────────

  /notes:
    get:
      operationId: listNotes
      summary: List clinical notes
      description: |
        Read-only, and available only to organisations with the notes capability enabled.
        There is no write endpoint on this API. That is a product decision, not a gap:
        your platform owns clinical documentation.
      tags: [Notes]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterSubject'
        - name: session
          in: query
          schema: { type: string }
        - name: finalised_after
          in: query
          schema: { type: string, format: date-time }
      responses:
        '200':
          description: Notes.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Note' }
        '403':
          description: The notes capability is not enabled for this organisation.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /notes/templates:
    get:
      operationId: listNoteTemplates
      summary: List documentation styles
      tags: [Notes]
      responses:
        '200':
          description: Templates.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/NoteTemplate' }

  /notes/{noteId}:
    parameters: [{ $ref: '#/components/parameters/noteId' }]
    get:
      operationId: getNote
      summary: Retrieve the active version of a note
      tags: [Notes]
      responses:
        '200':
          description: The note.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Note' } }
        '404': { $ref: '#/components/responses/NotFound' }

  /notes/{noteId}/versions:
    parameters: [{ $ref: '#/components/parameters/noteId' }]
    get:
      operationId: listNoteVersions
      summary: List a note's version history
      tags: [Notes]
      responses:
        '200':
          description: Versions, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/NoteVersion' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Change feed ─────────────────────────────────────────────────────────────

  /changes:
    get:
      operationId: listChanges
      summary: Read the change feed
      description: |
        One ordered, resumable feed of everything that changed in your organisation.
        There are no webhooks; you poll this on whatever interval suits you.

        The cursor is opaque and monotonic — store it, resume from it, and you cannot
        miss an entry or process one twice. Entries are retained thirty days. A poll that
        finds nothing is cheap and does not count against your rate limit.

        Payloads carry identifiers, statuses, counts and timestamps. Never a name, never
        clinical content, never a page of story text or a form answer. Fetch the object
        if you need it.
      tags: [Changes]
      parameters:
        - name: since
          in: query
          description: The cursor from your last read. Omit to start from now.
          schema: { type: string }
        - $ref: '#/components/parameters/limit'
        - name: types
          in: query
          description: Comma-delimited change types to include. Omit for all.
          schema: { type: string, examples: ['artifact.ready,session.complete'] }
      responses:
        '200':
          description: Changes, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Change' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: The cursor is unrecognised or older than the retention window.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  # ─── Usage ───────────────────────────────────────────────────────────────────

  /usage:
    get:
      operationId: getUsage
      summary: Retrieve generation quota consumption
      description: |
        Build a warning into your own interface at eighty percent. A clinician who
        discovers an exhausted quota halfway through a session with a seven-year-old will
        contact you, not us.
      tags: [Usage]
      responses:
        '200':
          description: This period's consumption.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties: { data: { $ref: '#/components/schemas/Usage' } }

  /usage/by-clinician:
    get:
      operationId: getUsageByClinician
      summary: Retrieve per-clinician generation consumption
      tags: [Usage]
      parameters:
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/filterCreatedBy'
      responses:
        '200':
          description: Consumption per clinician.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginatedEnvelope'
                  - properties:
                      data:
                        type: array
                        items:
                          type: object
                          properties:
                            clinician: { $ref: '#/components/schemas/Reference' }
                            counts:
                              type: object
                              additionalProperties: { type: integer }

  /usage/history:
    get:
      operationId: getUsageHistory
      summary: Retrieve previous periods' consumption
      tags: [Usage]
      parameters:
        - name: periods
          in: query
          schema: { type: integer, minimum: 1, maximum: 24, default: 6 }
      responses:
        '200':
          description: Historical consumption, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Usage' }

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        An access token from `POST /oauth/token`. Server-side only — a browser surface is
        authorised by a session token from `POST /tokens` instead.

  parameters:
    cursor:
      name: cursor
      in: query
      description: From `meta.pagination.next_cursor` on the previous page.
      schema: { type: string }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    includeArchived:
      name: include_archived
      in: query
      description: Archived records are excluded by default.
      schema: { type: boolean, default: false }
    idempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        A unique key for this operation, typically a version-4 identifier. A repeat with
        the same key and body replays the original response; the same key with a
        different body is rejected. Retained 24 hours.
      schema: { type: string, minLength: 8, maxLength: 255 }
    filterSubject:
      name: subject
      in: query
      description: A PlaySpace or `ext:`-prefixed patient identifier.
      schema: { type: string }
    filterCreatedBy:
      name: created_by
      in: query
      description: A PlaySpace or `ext:`-prefixed practitioner identifier.
      schema: { type: string }
    clinicId:
      name: clinicId
      in: path
      required: true
      description: A PlaySpace identifier, or yours prefixed `ext:`.
      schema: { type: string, examples: [clin_5Yt2Rm, 'ext:location_northgate'] }
    practitionerId:
      name: practitionerId
      in: path
      required: true
      schema: { type: string, examples: [prac_4Kx8Wq, 'ext:staff_8842'] }
    patientId:
      name: patientId
      in: path
      required: true
      schema: { type: string, examples: [pt_9Kd2mXwF, 'ext:client_55130'] }
    sessionId:
      name: sessionId
      in: path
      required: true
      schema: { type: string, examples: [sess_2Nk8pQvR7xLm, 'ext:appt_11923'] }
    storybookId:
      name: storybookId
      in: path
      required: true
      schema: { type: string }
    worksheetId:
      name: worksheetId
      in: path
      required: true
      schema: { type: string }
    formId:
      name: formId
      in: path
      required: true
      schema: { type: string }
    noteId:
      name: noteId
      in: path
      required: true
      schema: { type: string }

  headers:
    XRequestId:
      description: Log this. Quoting it addresses a single request in our audit history.
      schema: { type: string }
    RateLimitMinuteRemaining:
      schema: { type: integer }
    RateLimitMinuteReset:
      schema: { type: string, format: date-time }
    RateLimitHourRemaining:
      schema: { type: integer }
    RateLimitHourReset:
      schema: { type: string, format: date-time }
    RetryAfter:
      description: Seconds to wait.
      schema: { type: integer }

  responses:
    Unauthorized:
      description: Missing, malformed or expired credentials.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://api.playspace.health/problems/unauthorized
            title: Unauthorized
            status: 401
            request_id: req_8Kp2mQ
    Forbidden:
      description: |
        Authenticated but not permitted — a missing scope, an insufficient role, a
        surface the organisation is not licensed for, or a suspended organisation.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: |
        Absent, archived, or belonging to another organisation. The three are
        deliberately indistinguishable so a caller cannot map what it cannot reach.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Conflict:
      description: An idempotency conflict, or a genuine collision such as a double-booked room.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    ValidationError:
      description: The request was well-formed but its contents are not acceptable.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://api.playspace.health/problems/validation-error
            title: Validation failed
            status: 422
            detail: One or more fields are invalid.
            fields:
              - path: pages
                message: must be between 4 and 24
            request_id: req_8Kp2mQ
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Minute-Remaining: { $ref: '#/components/headers/RateLimitMinuteRemaining' }
        X-RateLimit-Hour-Remaining: { $ref: '#/components/headers/RateLimitHourRemaining' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    QuotaExceeded:
      description: |
        The generation allowance for this period is exhausted. Distinct from a rate limit:
        waiting will not help until `resets_at`.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
          example:
            type: https://api.playspace.health/problems/quota-exceeded
            title: Generation quota exceeded
            status: 429
            detail: The storybook generation allowance for this period is exhausted.
            resource: storybooks
            resets_at: '2026-10-01T00:00:00Z'
            request_id: req_8Kp2mQ

  schemas:

    Problem:
      type: object
      description: RFC 9457. Extensions are spread onto the top level.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: |
            A stable identifier under `https://api.playspace.health/problems/`. The set
            only grows and an existing slug never changes meaning, so it is safe to
            branch on.
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
        request_id: { type: string }
        fields:
          type: array
          description: Present on `validation-error`.
          items:
            type: object
            properties:
              path: { type: string }
              message: { type: string }
        resets_at:
          type: [string, 'null']
          format: date-time
          description: Present on `quota-exceeded`.
      additionalProperties: true

    Envelope:
      type: object
      required: [data, meta]
      properties:
        data: {}
        meta: { $ref: '#/components/schemas/Meta' }

    PaginatedEnvelope:
      type: object
      required: [data, meta]
      properties:
        data: { type: array, items: {} }
        meta:
          allOf:
            - $ref: '#/components/schemas/Meta'
            - type: object
              required: [pagination]
              properties:
                pagination: { $ref: '#/components/schemas/Pagination' }

    Meta:
      type: object
      required: [request_id, generated_at]
      properties:
        request_id: { type: string, examples: [req_8Kp2mQ] }
        generated_at: { type: string, format: date-time }
        pagination: { $ref: '#/components/schemas/Pagination' }

    Pagination:
      type: object
      required: [limit, has_more]
      properties:
        cursor: { type: [string, 'null'] }
        next_cursor: { type: [string, 'null'] }
        limit: { type: integer }
        has_more: { type: boolean }

    Ref:
      description: |
        A reference to a resource by either identifier. Supply exactly one of `id` or
        `external_id`.
      oneOf:
        - type: object
          required: [id]
          properties:
            id: { type: string }
          additionalProperties: false
        - type: object
          required: [external_id]
          properties:
            external_id: { type: string }
          additionalProperties: false

    Reference:
      type: object
      description: A resolved pointer, returned on every object that references another.
      properties:
        id: { type: string }
        external_id: { type: [string, 'null'] }

    Surface:
      type: string
      enum: [sandtray, dollhouse, whiteboard, games, playroom, storybooks, worksheets, forms, studio, models]

    Role:
      type: string
      enum: [clinician, patient]

    PractitionerRole:
      type: string
      enum: [member, administrator, owner]

    CaregiverRelationship:
      type: string
      enum: [parent, guardian, foster_carer, grandparent, sibling, other]

    PlayroomType:
      type: string
      enum: [child, teen, adult]

    SessionItem:
      type: string
      enum: [activity_shelf, multiplayer_games, single_player_games, wall_posters, whiteboard, sand_tray, dollhouse]

    GameProvider:
      type: string
      enum: [playspace, foony, papergames, studio]

    ContentStatus:
      type: string
      enum: [generating, ready, failed, archived]

    PublishTarget:
      type: string
      enum: ['false', playroom_toolkit, clinic_library, community]

    SessionStatus:
      type: string
      enum: [pending, live, complete, cancelled]

    JobStatus:
      type: string
      enum: [queued, running, complete, failed, cancelled]

    JobType:
      type: string
      enum: [storybook, storybook_page, worksheet, form, form_extraction, game, model, export]

    ArtifactType:
      type: string
      enum:
        - storybook
        - storybook_copy
        - worksheet
        - worksheet_copy
        - form
        - form_response
        - sandtray_save
        - dollhouse_save
        - whiteboard_snapshot
        - generated_game
        - model
        - session_summary
        - note

    DownloadFormat:
      type: string
      enum: [pdf, epub, png, svg, glb, zip, csv, json]

    NotifyFlags:
      type: object
      description: |
        Not persisted. Controls whether this write sends an email for this write only.
      properties:
        patient: { type: boolean, default: false }
        clinician: { type: boolean, default: false }

    Organisation:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, suspended, revoked] }
        capabilities:
          type: array
          description: Product areas enabled for this organisation, such as `notes`.
          items: { type: string }
        created_at: { type: string, format: date-time }

    PracticeGroup:
      type: object
      properties:
        id: { type: string }
        external_id: { type: [string, 'null'] }
        name: { type: string }
        parent: { $ref: '#/components/schemas/Reference' }
        clinic_count: { type: integer }

    Clinic:
      type: object
      properties:
        id: { type: string, examples: [clin_5Yt2Rm] }
        external_id: { type: [string, 'null'], examples: [location_northgate] }
        name: { type: string }
        country: { type: string, description: Two-letter country code. }
        timezone: { type: [string, 'null'] }
        group: { $ref: '#/components/schemas/Reference' }
        created_at: { type: string, format: date-time }
        archived_at: { type: [string, 'null'], format: date-time }

    ClinicWrite:
      type: object
      required: [external_id, name, country]
      properties:
        external_id: { type: string }
        name: { type: string, maxLength: 200 }
        country: { type: string, minLength: 2, maxLength: 2 }
        timezone: { type: string }
        group: { $ref: '#/components/schemas/Ref' }

    Practitioner:
      type: object
      properties:
        id: { type: string, examples: [prac_4Kx8Wq] }
        external_id: { type: [string, 'null'], examples: [staff_8842] }
        clinic: { $ref: '#/components/schemas/Reference' }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: [string, 'null'], format: email }
        role: { $ref: '#/components/schemas/PractitionerRole' }
        country: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        archived_at: { type: [string, 'null'], format: date-time }

    PractitionerWrite:
      type: object
      required: [external_id, clinic, first_name, last_name]
      properties:
        external_id: { type: string }
        clinic: { $ref: '#/components/schemas/Ref' }
        first_name: { type: string, maxLength: 100 }
        last_name: { type: string, maxLength: 100 }
        email: { type: string, format: email }
        role: { $ref: '#/components/schemas/PractitionerRole' }
        country:
          type: string
          minLength: 2
          maxLength: 2
          description: |
            Required for regional availability of some surfaces. Omit it and those
            surfaces fail closed for this practitioner.

    Patient:
      type: object
      properties:
        id: { type: string, examples: [pt_9Kd2mXwF] }
        external_id: { type: [string, 'null'], examples: [client_55130] }
        clinic: { $ref: '#/components/schemas/Reference' }
        first_name: { type: string }
        last_name: { type: string }
        date_of_birth: { type: [string, 'null'], format: date }
        caregivers:
          type: array
          items: { $ref: '#/components/schemas/CaregiverLink' }
        created_at: { type: string, format: date-time }
        archived_at: { type: [string, 'null'], format: date-time }

    PatientWrite:
      type: object
      required: [external_id, clinic, first_name, last_name]
      properties:
        external_id: { type: string }
        clinic: { $ref: '#/components/schemas/Ref' }
        first_name:
          type: string
          maxLength: 100
          description: An initial is acceptable. Send the minimum a clinician needs to recognise the right person.
        last_name: { type: string, maxLength: 100 }
        date_of_birth: { type: string, format: date }
        practitioners:
          type: array
          description: Clinicians whose caseload this client is on.
          items: { $ref: '#/components/schemas/Ref' }
        caregivers:
          type: array
          items:
            type: object
            required: [external_id, relationship]
            properties:
              external_id: { type: string }
              relationship: { $ref: '#/components/schemas/CaregiverRelationship' }

    CaregiverLink:
      type: object
      properties:
        id: { type: string }
        patient: { $ref: '#/components/schemas/Reference' }
        caregiver: { $ref: '#/components/schemas/Reference' }
        relationship: { $ref: '#/components/schemas/CaregiverRelationship' }
        receives_session_links: { type: boolean }
        can_request_export: { type: boolean }

    Session:
      type: object
      properties:
        id: { type: string, examples: [sess_2Nk8pQvR7xLm] }
        external_id: { type: [string, 'null'], examples: [appt_11923] }
        status: { $ref: '#/components/schemas/SessionStatus' }
        clinician: { $ref: '#/components/schemas/Reference' }
        participants:
          type: array
          items:
            type: object
            properties:
              patient: { $ref: '#/components/schemas/Reference' }
              joined_at: { type: [string, 'null'], format: date-time }
        playroom: { type: [string, 'null'] }
        surfaces:
          type: array
          items: { $ref: '#/components/schemas/Surface' }
        scheduled_at: { type: [string, 'null'], format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        ended_at: { type: [string, 'null'], format: date-time }
        links:
          description: Present on create and on an explicit re-mint. Never stored, never returned on a plain get.
          oneOf:
            - $ref: '#/components/schemas/SessionLinks'
            - type: 'null'
        created_at: { type: string, format: date-time }

    SessionCreate:
      type: object
      required: [appointment, clinician, participants]
      properties:
        appointment:
          type: object
          required: [external_id]
          properties:
            external_id: { type: string }
            scheduled_at: { type: string, format: date-time }
        clinician: { $ref: '#/components/schemas/Ref' }
        participants:
          type: array
          minItems: 1
          items:
            type: object
            required: [patient]
            properties:
              patient: { $ref: '#/components/schemas/Ref' }
        playroom:
          type: string
          description: A playroom identifier or slug. Omit for the clinic's default.
        surfaces:
          type: array
          description: The ceiling for this session. Omit for every licensed surface.
          items: { $ref: '#/components/schemas/Surface' }
        notify: { $ref: '#/components/schemas/NotifyFlags' }

    SessionLinks:
      type: object
      properties:
        clinician: { type: string, format: uri }
        patient: { type: string, format: uri }
        expires_at: { type: string, format: date-time }

    SessionSummary:
      type: object
      properties:
        session: { $ref: '#/components/schemas/Reference' }
        duration_ms: { type: integer }
        participants:
          type: array
          items:
            type: object
            properties:
              role: { $ref: '#/components/schemas/Role' }
              joined_at: { type: string, format: date-time }
              left_at: { type: [string, 'null'], format: date-time }
        surfaces_used:
          type: array
          items:
            type: object
            properties:
              surface: { $ref: '#/components/schemas/Surface' }
              duration_ms: { type: integer }
        games_played:
          type: array
          items:
            type: object
            properties:
              title: { type: string, description: The catalog product name. Never patient text, never a URL. }
              duration_ms: { type: integer, description: An upper bound — a backgrounded tab keeps accruing. }
              plays: { type: integer }
        artifacts:
          type: array
          items: { $ref: '#/components/schemas/Artifact' }

    TokenCreate:
      type: object
      required: [session, subject, role, origins]
      properties:
        session: { type: string }
        subject: { $ref: '#/components/schemas/Ref' }
        role: { $ref: '#/components/schemas/Role' }
        surfaces:
          type: array
          description: Never wider than the session's ceiling. Omit to inherit it.
          items: { $ref: '#/components/schemas/Surface' }
        origins:
          type: array
          minItems: 1
          description: |
            Host origins allowed to frame this surface. Validated at mint, so a typo is a
            readable error rather than a blank frame in production.
          items: { type: string, format: uri }
        ttl:
          type: string
          default: 15m
          description: Maximum `60m`. Short by design; the SDK renews silently.
          examples: [15m, 60m]

    SessionToken:
      type: object
      properties:
        id: { type: string }
        value: { type: string }
        role: { $ref: '#/components/schemas/Role' }
        surfaces:
          type: array
          items: { $ref: '#/components/schemas/Surface' }
        expires_at: { type: string, format: date-time }

    Playroom:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        type: { $ref: '#/components/schemas/PlayroomType' }
        theme: { type: [string, 'null'] }
        items:
          type: array
          items: { $ref: '#/components/schemas/SessionItem' }
        colour_palette: { type: [string, 'null'] }
        contents:
          type: array
          items: { $ref: '#/components/schemas/PlayroomContent' }
        created_at: { type: string, format: date-time }

    PlayroomWrite:
      type: object
      required: [title, type]
      properties:
        title: { type: string, maxLength: 200 }
        type: { $ref: '#/components/schemas/PlayroomType' }
        theme: { type: string }
        items:
          type: array
          items: { $ref: '#/components/schemas/SessionItem' }
        colour_palette: { type: string }
        contents:
          type: array
          items: { $ref: '#/components/schemas/ContentRef' }

    PlayroomContent:
      type: object
      properties:
        id: { type: string }
        type: { type: string, enum: [storybook, worksheet, form, game, generated_game] }
        ref: { $ref: '#/components/schemas/Reference' }
        title: { type: string }
        order: { type: integer }

    ContentRef:
      type: object
      required: [type]
      properties:
        type: { type: string, enum: [storybook, worksheet, form, game, generated_game] }
        id: { type: string }
        slug: { type: string, description: For catalog games. }
        order: { type: integer }

    Toolkit:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        description: { type: [string, 'null'] }
        contents:
          type: array
          items: { $ref: '#/components/schemas/PlayroomContent' }
        created_at: { type: string, format: date-time }

    ToolkitWrite:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 200 }
        description: { type: string }
        contents:
          type: array
          items: { $ref: '#/components/schemas/ContentRef' }

    Game:
      type: object
      properties:
        slug: { type: string, examples: [worry-pet] }
        title: { type: string }
        description: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        provider: { $ref: '#/components/schemas/GameProvider' }
        min_players: { type: integer }
        max_players: { type: integer }
        skills:
          type: array
          description: Therapeutic skills the game develops.
          items: { type: string }
        age_range:
          type: array
          maxItems: 2
          minItems: 2
          items: { type: integer }
        thumbnail_url: { type: [string, 'null'], format: uri }
        embeddable: { type: boolean }

    Storybook:
      type: object
      properties:
        id: { type: string, examples: [sb_7Hn3xQ] }
        title: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/ContentStatus' }
        page_count: { type: integer }
        age_group: { type: [string, 'null'] }
        reading_level: { type: [string, 'null'] }
        style: { type: [string, 'null'] }
        published: { $ref: '#/components/schemas/PublishTarget' }
        created_by: { $ref: '#/components/schemas/Reference' }
        subject:
          description: Null for library content that is not about anyone in particular.
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        session:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        characters:
          type: array
          items: { $ref: '#/components/schemas/StorybookCharacter' }
        pages:
          type: array
          items: { $ref: '#/components/schemas/StorybookPage' }
        created_at: { type: string, format: date-time }

    StorybookCharacter:
      type: object
      properties:
        name: { type: string }
        role: { type: string, enum: [protagonist, supporting, background] }
        description:
          type: string
          description: The visual description carried into every page's image generation.

    StorybookPage:
      type: object
      properties:
        id: { type: string }
        number: { type: integer }
        text: { type: string }
        image_url: { type: [string, 'null'], format: uri }
        characters_in_scene:
          type: array
          items: { type: string }
        regenerating: { type: boolean }

    StorybookGenerate:
      type: object
      required: [clinician, prompt]
      properties:
        clinician:
          allOf: [{ $ref: '#/components/schemas/Ref' }]
          description: Required. Generation costs money and somebody owns that.
        subject:
          allOf: [{ $ref: '#/components/schemas/Ref' }]
          description: Set it and the storybook is personal. Omit it and you have made library content.
        session: { $ref: '#/components/schemas/Ref' }
        prompt: { type: string, minLength: 10, maxLength: 2000 }
        pages: { type: integer, minimum: 4, maximum: 24, default: 8 }
        age_group: { type: string, examples: ['3-5', '5-8', '9-12', '13-17'] }
        reading_level: { type: string, examples: [pre-reader, grade-1, grade-2, grade-4] }
        style: { type: string, examples: [watercolour, cartoon, storybook-classic, collage] }
        characters:
          type: array
          maxItems: 6
          items: { $ref: '#/components/schemas/StorybookCharacter' }

    Worksheet:
      type: object
      properties:
        id: { type: string, examples: [ws_3Bn8kR] }
        title: { type: string }
        status: { $ref: '#/components/schemas/ContentStatus' }
        page_count: { type: integer }
        age_group: { type: [string, 'null'] }
        created_by: { $ref: '#/components/schemas/Reference' }
        subject:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        published: { $ref: '#/components/schemas/PublishTarget' }
        created_at: { type: string, format: date-time }

    WorksheetWrite:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 200 }
        age_group: { type: string }
        published: { $ref: '#/components/schemas/PublishTarget' }

    WorksheetGenerate:
      type: object
      required: [clinician, prompt]
      properties:
        clinician: { $ref: '#/components/schemas/Ref' }
        subject: { $ref: '#/components/schemas/Ref' }
        session: { $ref: '#/components/schemas/Ref' }
        prompt: { type: string, minLength: 10, maxLength: 2000 }
        pages: { type: integer, minimum: 1, maximum: 12, default: 3 }
        age_group: { type: string }
        include_imagery: { type: boolean, default: true }

    WorksheetCopy:
      type: object
      properties:
        id: { type: string }
        worksheet: { $ref: '#/components/schemas/Reference' }
        subject: { $ref: '#/components/schemas/Reference' }
        created_by: { $ref: '#/components/schemas/Reference' }
        session:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        status: { type: string, enum: [assigned, in_progress, complete] }
        due_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }

    Form:
      type: object
      properties:
        id: { type: string, examples: [frm_2Kd9Wp] }
        title: { type: string }
        status: { $ref: '#/components/schemas/ContentStatus' }
        scored: { type: boolean }
        fields:
          type: array
          items: { $ref: '#/components/schemas/FormField' }
        created_by: { $ref: '#/components/schemas/Reference' }
        published: { $ref: '#/components/schemas/PublishTarget' }
        created_at: { type: string, format: date-time }

    FormField:
      type: object
      properties:
        id: { type: string }
        label: { type: string }
        type:
          type: string
          enum: [short_text, long_text, number, single_select, multi_select, scale, date, signature, file, image_choice]
        required: { type: boolean }
        options:
          type: array
          items: { type: string }
        scale:
          type: object
          properties:
            min: { type: integer }
            max: { type: integer }
            labels:
              type: array
              items: { type: string }
        conditional:
          description: Show this field only when another field holds a given value.
          type: [object, 'null']
          properties:
            field_id: { type: string }
            equals: {}

    FormWrite:
      type: object
      required: [title, fields]
      properties:
        title: { type: string, maxLength: 200 }
        scored: { type: boolean, default: false }
        fields:
          type: array
          minItems: 1
          items: { $ref: '#/components/schemas/FormField' }
        published: { $ref: '#/components/schemas/PublishTarget' }

    FormResponse:
      type: object
      properties:
        id: { type: string }
        form: { $ref: '#/components/schemas/Reference' }
        subject: { $ref: '#/components/schemas/Reference' }
        submitted_by:
          description: The client, a caregiver, or the clinician.
          type: object
          properties:
            role: { type: string, enum: [patient, caregiver, clinician] }
            ref: { $ref: '#/components/schemas/Reference' }
        session:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        answers:
          type: array
          items:
            type: object
            properties:
              field_id: { type: string }
              label: { type: string }
              type: { type: string }
              value: {}
        score:
          type: [number, 'null']
          description: Present for scored instruments only.
        submitted_at: { type: string, format: date-time }

    GeneratedGame:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        status: { $ref: '#/components/schemas/ContentStatus' }
        players: { type: integer }
        play_url: { type: [string, 'null'], format: uri }
        created_by: { $ref: '#/components/schemas/Reference' }
        subject:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        published: { $ref: '#/components/schemas/PublishTarget' }
        created_at: { type: string, format: date-time }

    Model:
      type: object
      properties:
        id: { type: string }
        title: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/ContentStatus' }
        format: { type: string, const: glb }
        preview_url: { type: [string, 'null'], format: uri }
        source: { type: string, enum: [text, image] }
        created_by: { $ref: '#/components/schemas/Reference' }
        created_at: { type: string, format: date-time }

    Job:
      type: object
      properties:
        id: { type: string, examples: [job_5Rp1wKz] }
        type: { $ref: '#/components/schemas/JobType' }
        status: { $ref: '#/components/schemas/JobStatus' }
        progress: { type: integer, minimum: 0, maximum: 100 }
        estimated_seconds: { type: [integer, 'null'] }
        result:
          description: Present when `status` is `complete`.
          oneOf:
            - $ref: '#/components/schemas/Artifact'
            - type: 'null'
        reason:
          type: [string, 'null']
          description: |
            Present when `status` is `failed`. Written for a clinician rather than an
            engineer. A failed job does not consume quota.
        created_by: { $ref: '#/components/schemas/Reference' }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }

    Artifact:
      type: object
      description: |
        Everything PlaySpace produced and kept. The attribution triple —
        `created_by`, `subject`, `session` — is what makes the client library, the export
        bundle, the spend report and the session summary all one query.
      properties:
        id: { type: string }
        type: { $ref: '#/components/schemas/ArtifactType' }
        title: { type: [string, 'null'] }
        status: { $ref: '#/components/schemas/ContentStatus' }
        created_by:
          allOf: [{ $ref: '#/components/schemas/Reference' }]
          description: The clinician. Always present.
        subject:
          description: The client it was made for. Null for library content.
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        session:
          description: Where it happened. Null for content created outside a session.
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        formats:
          type: array
          description: Download formats available for this artifact.
          items: { $ref: '#/components/schemas/DownloadFormat' }
        created_at: { type: string, format: date-time }

    ExportCreate:
      type: object
      required: [include, requested_by, reason]
      properties:
        subject:
          allOf: [{ $ref: '#/components/schemas/Ref' }]
          description: Required unless `scope` is `organisation`.
        scope: { type: string, enum: [subject, organisation], default: subject }
        include:
          type: array
          minItems: 1
          description: |
            Artifact families, or `*` for everything the organisation is licensed for and
            entitled to read. Naming them explicitly is better practice — an export is a
            disclosure, and disclosures should be deliberate.
          items:
            type: string
            enum:
              - '*'
              - storybooks
              - worksheets
              - form_responses
              - sandtray_saves
              - dollhouse_saves
              - whiteboard_snapshots
              - generated_games
              - models
              - session_summaries
              - notes
        format: { type: string, enum: [bundle, json], default: bundle }
        requested_by: { $ref: '#/components/schemas/Ref' }
        reason:
          type: string
          enum: [client_request, caregiver_request, clinical_transfer, migration, legal, internal_review]

    Export:
      type: object
      properties:
        id: { type: string }
        scope: { type: string, enum: [subject, organisation] }
        subject:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        include:
          type: array
          items: { type: string }
        format: { type: string, enum: [bundle, json] }
        status: { type: string, enum: [queued, running, complete, failed, expired] }
        progress: { type: integer, minimum: 0, maximum: 100 }
        size_bytes: { type: [integer, 'null'] }
        part_count: { type: integer, default: 1 }
        requested_by: { $ref: '#/components/schemas/Reference' }
        reason: { type: string }
        created_at: { type: string, format: date-time }
        expires_at:
          type: [string, 'null']
          format: date-time
          description: Seven days after completion. Request it again if you need it again.

    Note:
      type: object
      properties:
        id: { type: string }
        subject: { $ref: '#/components/schemas/Reference' }
        author: { $ref: '#/components/schemas/Reference' }
        session:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        template: { $ref: '#/components/schemas/Reference' }
        status: { type: string, enum: [draft, final, amended] }
        version: { type: integer }
        sections:
          type: array
          items:
            type: object
            properties:
              heading: { type: string }
              body: { type: string }
        finalised_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }

    NoteVersion:
      type: object
      properties:
        version: { type: integer }
        author: { $ref: '#/components/schemas/Reference' }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    NoteTemplate:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        sections:
          type: array
          items: { type: string }

    Change:
      type: object
      properties:
        sequence:
          type: integer
          description: Monotonic within your organisation.
        type: { $ref: '#/components/schemas/ChangeType' }
        occurred_at: { type: string, format: date-time }
        artifact:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        session:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        subject:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        job:
          oneOf:
            - $ref: '#/components/schemas/Reference'
            - type: 'null'
        data:
          type: object
          description: |
            Type-specific detail. Identifiers, statuses, counts, durations and enumerated
            values only — never a name, never clinical content.
          additionalProperties: true

    ChangeType:
      type: string
      enum:
        - session.created
        - session.started
        - session.complete
        - session.cancelled
        - artifact.created
        - artifact.ready
        - artifact.deleted
        - job.progress
        - job.complete
        - job.failed
        - form.submitted
        - worksheet.completed
        - note.available
        - export.ready
        - quota.threshold

    Usage:
      type: object
      properties:
        period: { type: string, examples: ['2026-09'] }
        resources:
          type: object
          additionalProperties:
            type: object
            properties:
              used: { type: integer }
              limit: { type: integer }
          examples:
            - storybooks: { used: 214, limit: 1000 }
              worksheets: { used: 88, limit: 500 }
              forms: { used: 41, limit: 500 }
              games: { used: 12, limit: 100 }
              models: { used: 37, limit: 250 }
        resets_at: { type: string, format: date-time }
