typescript type declarations

The precise contract for @playspace/sdk.

/**
 * @playspace/sdk — PlaySpace Partner Platform server SDK
 *
 * Generated from the OpenAPI 3.1 contract at
 * https://api.playspace.health/v1/openapi.json, then hand-finished for
 * ergonomics. A field present here is present on the wire.
 *
 * Server-side only. The credentials this client takes authorise everything your
 * organisation can do; to render a surface in a browser, mint a session token
 * with `tokens.issue()` and hand that to `@playspace/react` instead.
 *
 * @packageDocumentation
 */

// ─── Client ──────────────────────────────────────────────────────────────────

export interface PlaySpaceOptions {
  /** Defaults to `process.env.PLAYSPACE_CLIENT_ID`. */
  clientId?: string
  /** Defaults to `process.env.PLAYSPACE_CLIENT_SECRET`. */
  clientSecret?: string
  /** Defaults to `process.env.PLAYSPACE_ENV`, then `'production'`. */
  environment?: 'production' | 'sandbox'
  /** Override the base URL. Rarely needed outside our own testing. */
  baseUrl?: string
  /** Per-request timeout in milliseconds. Default 30000. */
  timeout?: number
  /** Retries for transient failures. Default 3. Mutations retry only when an idempotency key makes it safe. */
  maxRetries?: number
  /** Extra headers on every request. */
  headers?: Record<string, string>
  /** Supply a custom fetch. Defaults to the global. */
  fetch?: typeof fetch
}

export declare class PlaySpace {
  constructor(options?: PlaySpaceOptions)

  readonly organisation: OrganisationResource
  readonly clinics: ClinicsResource
  readonly practitioners: PractitionersResource
  readonly patients: PatientsResource
  readonly caregivers: CaregiversResource
  readonly sessions: SessionsResource
  readonly tokens: TokensResource
  readonly playrooms: PlayroomsResource
  readonly toolkits: ToolkitsResource
  readonly games: GamesResource
  readonly storybooks: StorybooksResource
  readonly worksheets: WorksheetsResource
  readonly forms: FormsResource
  readonly studio: StudioResource
  readonly models: ModelsResource
  readonly jobs: JobsResource
  readonly artifacts: ArtifactsResource
  readonly exports: ExportsResource
  readonly notes: NotesResource
  readonly changes: ChangesResource
  readonly usage: UsageResource

  /** Escape hatch for an endpoint this SDK version does not model yet. */
  request<T = unknown>(
    method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
    path: string,
    init?: { body?: unknown; query?: Record<string, unknown>; idempotencyKey?: string }
  ): Promise<T>
}

export default PlaySpace

// ─── References ──────────────────────────────────────────────────────────────

/**
 * A reference to a resource by either identifier. Supply exactly one.
 *
 * Your `externalId` works everywhere a PlaySpace `id` does, so you never have to
 * store a PlaySpace identifier.
 */
export type Ref = { id: string } | { externalId: string }

/** A resolved pointer, returned on every object that references another. */
export interface Reference {
  id: string
  externalId: string | null
}

/** A resource identifier, or a `Ref`, wherever the SDK accepts either. */
export type IdOrRef = string | Ref

// ─── Pagination ──────────────────────────────────────────────────────────────

/**
 * A list result. Iterate it to page transparently, or call `.page()` to take one
 * page at a time.
 *
 * ```ts
 * for await (const patient of playspace.patients.list()) { ... }
 * const first = await playspace.patients.list().page()
 * ```
 */
export interface Paginated<T> extends AsyncIterable<T> {
  page(): Promise<Page<T>>
  /** Collect every page into one array. Guard the size before calling this on a large list. */
  all(): Promise<T[]>
}

export interface Page<T> {
  data: T[]
  cursor: string | null
  nextCursor: string | null
  limit: number
  hasMore: boolean
  requestId: string
}

export interface ListOptions {
  cursor?: string
  /** 1 to 100. Default 25. */
  limit?: number
}

export interface WriteOptions {
  /** Overrides the key the SDK derives from the call's arguments. */
  idempotencyKey?: string
}

// ─── Enumerations ────────────────────────────────────────────────────────────

export type Surface =
  | 'sandtray'
  | 'dollhouse'
  | 'whiteboard'
  | 'games'
  | 'playroom'
  | 'storybooks'
  | 'worksheets'
  | 'forms'
  | 'studio'
  | 'models'

export type Role = 'clinician' | 'patient'
export type PractitionerRole = 'member' | 'administrator' | 'owner'
export type CaregiverRelationship =
  | 'parent'
  | 'guardian'
  | 'fosterCarer'
  | 'grandparent'
  | 'sibling'
  | 'other'

export type PlayroomType = 'child' | 'teen' | 'adult'

export type SessionItem =
  | 'activityShelf'
  | 'multiplayerGames'
  | 'singlePlayerGames'
  | 'wallPosters'
  | 'whiteboard'
  | 'sandTray'
  | 'dollhouse'

export type GameProvider = 'playspace' | 'foony' | 'papergames' | 'studio'
export type ContentStatus = 'generating' | 'ready' | 'failed' | 'archived'
export type PublishTarget = 'false' | 'playroomToolkit' | 'clinicLibrary' | 'community'
export type SessionStatus = 'pending' | 'live' | 'complete' | 'cancelled'
export type JobStatus = 'queued' | 'running' | 'complete' | 'failed' | 'cancelled'

export type JobType =
  | 'storybook'
  | 'storybookPage'
  | 'worksheet'
  | 'form'
  | 'formExtraction'
  | 'game'
  | 'model'
  | 'export'

export type ArtifactType =
  | 'storybook'
  | 'storybookCopy'
  | 'worksheet'
  | 'worksheetCopy'
  | 'form'
  | 'formResponse'
  | 'sandtraySave'
  | 'dollhouseSave'
  | 'whiteboardSnapshot'
  | 'generatedGame'
  | 'model'
  | 'sessionSummary'
  | 'note'

export type DownloadFormat = 'pdf' | 'epub' | 'png' | 'svg' | 'glb' | 'zip' | 'csv' | 'json'

/**
 * Durations in request parameters. Minutes or hours.
 * @example '15m' | '60m' | '24h'
 */
export type Duration = `${number}m` | `${number}h`

// ─── Organisation ────────────────────────────────────────────────────────────

export interface Organisation {
  id: string
  name: string
  status: 'active' | 'suspended' | 'revoked'
  /** Product areas enabled for this organisation, such as `notes`. */
  capabilities: string[]
  createdAt: string
}

export interface SurfaceAvailability {
  surface: Surface
  licensed: boolean
  /** Whether this surface can render in a partner-hosted frame. */
  embeddable: boolean
}

export interface PracticeGroup {
  id: string
  externalId: string | null
  name: string
  parent: Reference | null
  clinicCount: number
}

export interface OrganisationResource {
  get(): Promise<Organisation>
  /** Render your own entry points from this. A surface absent here is refused at mint. */
  surfaces(): Promise<SurfaceAvailability[]>
  groups(options?: ListOptions): Paginated<PracticeGroup>
}

// ─── Identity ────────────────────────────────────────────────────────────────

export interface Clinic {
  id: string
  externalId: string | null
  name: string
  country: string
  timezone: string | null
  group: Reference | null
  createdAt: string
  archivedAt: string | null
}

export interface ClinicWrite {
  externalId: string
  name: string
  /** Two-letter country code. */
  country: string
  timezone?: string
  group?: Ref
}

export interface Practitioner {
  id: string
  externalId: string | null
  clinic: Reference
  firstName: string
  lastName: string
  email: string | null
  role: PractitionerRole
  country: string | null
  createdAt: string
  archivedAt: string | null
}

export interface PractitionerWrite {
  externalId: string
  clinic: Ref
  firstName: string
  lastName: string
  email?: string
  role?: PractitionerRole
  /**
   * Required for regional availability of some surfaces. Omit it and those
   * surfaces fail closed for this practitioner.
   */
  country?: string
}

export interface Patient {
  id: string
  externalId: string | null
  clinic: Reference
  firstName: string
  lastName: string
  dateOfBirth: string | null
  caregivers: CaregiverLink[]
  createdAt: string
  archivedAt: string | null
}

export interface PatientWrite {
  externalId: string
  clinic: Ref
  /** An initial is acceptable. Send the minimum a clinician needs to recognise the right person. */
  firstName: string
  lastName: string
  dateOfBirth?: string
  /** Clinicians whose caseload this client is on. */
  practitioners?: Ref[]
  caregivers?: Array<{ externalId: string; relationship: CaregiverRelationship }>
}

export interface CaregiverLink {
  id: string
  patient: Reference
  caregiver: Reference
  relationship: CaregiverRelationship
  receivesSessionLinks: boolean
  canRequestExport: boolean
}

export interface ClinicsResource {
  list(options?: ListOptions & { group?: string; includeArchived?: boolean }): Paginated<Clinic>
  /** Creates on first sight, updates thereafter. Keyed on `externalId`. */
  upsert(body: ClinicWrite, options?: WriteOptions): Promise<Clinic>
  get(id: IdOrRef): Promise<Clinic>
  update(id: IdOrRef, body: Partial<ClinicWrite>, options?: WriteOptions): Promise<Clinic>
  /** Soft. A clinic with active practitioners or patients is refused. */
  archive(id: IdOrRef, options?: WriteOptions): Promise<void>
}

export interface PractitionersResource {
  list(
    options?: ListOptions & { clinic?: string; includeArchived?: boolean }
  ): Paginated<Practitioner>
  upsert(body: PractitionerWrite, options?: WriteOptions): Promise<Practitioner>
  get(id: IdOrRef): Promise<Practitioner>
  update(
    id: IdOrRef,
    body: Partial<PractitionerWrite>,
    options?: WriteOptions
  ): Promise<Practitioner>
  archive(id: IdOrRef, options?: WriteOptions): Promise<void>
}

export interface PatientsResource {
  list(
    options?: ListOptions & { clinic?: string; practitioner?: string; includeArchived?: boolean }
  ): Paginated<Patient>
  upsert(body: PatientWrite, options?: WriteOptions): Promise<Patient>
  get(id: IdOrRef): Promise<Patient>
  update(id: IdOrRef, body: Partial<PatientWrite>, options?: WriteOptions): Promise<Patient>
  archive(id: IdOrRef, options?: WriteOptions): Promise<void>
  /**
   * A POST, and there is no GET equivalent. A name 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.
   */
  search(body: {
    query: string
    clinic?: string
    practitioner?: string
    limit?: number
  }): Promise<Patient[]>
  caregivers(id: IdOrRef): Promise<CaregiverLink[]>
}

export interface CaregiversResource {
  link(
    body: {
      patient: Ref
      caregiver: Ref
      relationship: CaregiverRelationship
      receivesSessionLinks?: boolean
      canRequestExport?: boolean
    },
    options?: WriteOptions
  ): Promise<CaregiverLink>
  unlink(linkId: string, options?: WriteOptions): Promise<void>
}

// ─── Sessions and tokens ─────────────────────────────────────────────────────

export interface SessionParticipant {
  patient: Reference
  joinedAt: string | null
}

export interface SessionLinks {
  clinician: string
  patient: string
  expiresAt: string
}

export interface Session {
  id: string
  externalId: string | null
  status: SessionStatus
  clinician: Reference
  participants: SessionParticipant[]
  playroom: string | null
  surfaces: Surface[]
  scheduledAt: string | null
  startedAt: string | null
  endedAt: string | null
  /** Present on create and on an explicit re-mint. Never stored. */
  links: SessionLinks | null
  createdAt: string
}

export interface NotifyFlags {
  patient?: boolean
  clinician?: boolean
}

export interface SessionCreate {
  appointment: { externalId: string; scheduledAt?: string }
  clinician: Ref
  participants: Array<{ patient: Ref }>
  /** A playroom identifier or slug. Omit for the clinic's default. */
  playroom?: string
  /** The ceiling for this session. Omit for every licensed surface. */
  surfaces?: Surface[]
  notify?: NotifyFlags
}

export interface SessionSummary {
  session: Reference
  durationMs: number
  participants: Array<{ role: Role; joinedAt: string; leftAt: string | null }>
  surfacesUsed: Array<{ surface: Surface; durationMs: number }>
  /**
   * Three caveats before building a report. Only the clinician's play bracket is
   * persisted. Every duration is an upper bound — a backgrounded tab keeps
   * accruing. A remount can split one continuous play into two entries, so sum
   * by game rather than assuming one entry per play.
   */
  gamesPlayed: Array<{ title: string; durationMs: number; plays: number }>
  artifacts: Artifact[]
}

export interface SessionsResource {
  list(
    options?: ListOptions & {
      status?: SessionStatus
      clinician?: string
      patient?: string
      scheduledAfter?: string
      scheduledBefore?: string
    }
  ): Paginated<Session>
  create(body: SessionCreate, options?: WriteOptions): Promise<Session>
  get(id: IdOrRef): Promise<Session>
  update(
    id: IdOrRef,
    body: { scheduledAt?: string; playroom?: string; surfaces?: Surface[]; notify?: NotifyFlags },
    options?: WriteOptions
  ): Promise<Session>
  end(id: IdOrRef, options?: WriteOptions): Promise<Session>
  cancel(
    id: IdOrRef,
    body?: { reason?: string; notify?: NotifyFlags },
    options?: WriteOptions
  ): Promise<Session>
  summary(id: IdOrRef): Promise<SessionSummary>
  /** Hosted links are ephemeral. Mint them when you are about to show them. */
  links(id: IdOrRef, options?: WriteOptions): Promise<SessionLinks>
}

export interface SessionToken {
  id: string
  value: string
  role: Role
  surfaces: Surface[]
  expiresAt: string
}

export interface TokenCreate {
  session: string
  subject: Ref
  role: Role
  /** Never wider than the session's ceiling. Omit to inherit it. */
  surfaces?: Surface[]
  /**
   * Host origins allowed to frame this surface. Validated at mint, so a typo is a
   * readable error rather than a blank frame in production.
   */
  origins: string[]
  /** Maximum `'60m'`. Default `'15m'`. Short by design; the React SDK renews silently. */
  ttl?: Duration
}

export interface TokensResource {
  issue(body: TokenCreate, options?: WriteOptions): Promise<SessionToken>
  revoke(tokenId: string, options?: WriteOptions): Promise<void>
}

// ─── Content ─────────────────────────────────────────────────────────────────

export interface ContentRef {
  type: 'storybook' | 'worksheet' | 'form' | 'game' | 'generatedGame'
  id?: string
  /** For catalog games. */
  slug?: string
  order?: number
}

export interface PlayroomContent {
  id: string
  type: ContentRef['type']
  ref: Reference
  title: string
  order: number
}

export interface Playroom {
  id: string
  title: string
  type: PlayroomType
  theme: string | null
  items: SessionItem[]
  colourPalette: string | null
  contents: PlayroomContent[]
  createdAt: string
}

export interface PlayroomWrite {
  title: string
  type: PlayroomType
  theme?: string
  items?: SessionItem[]
  colourPalette?: string
  contents?: ContentRef[]
}

export interface Toolkit {
  id: string
  title: string
  description: string | null
  contents: PlayroomContent[]
  createdAt: string
}

export interface ToolkitWrite {
  title: string
  description?: string
  contents?: ContentRef[]
}

export interface Game {
  slug: string
  title: string
  description: string | null
  category: string | null
  provider: GameProvider
  minPlayers: number
  maxPlayers: number
  /** Therapeutic skills the game develops. */
  skills: string[]
  ageRange: [number, number]
  thumbnailUrl: string | null
  embeddable: boolean
}

export interface PlayroomsResource {
  list(options?: ListOptions & { type?: PlayroomType }): Paginated<Playroom>
  create(body: PlayroomWrite, options?: WriteOptions): Promise<Playroom>
  get(id: string): Promise<Playroom>
  update(id: string, body: Partial<PlayroomWrite>, options?: WriteOptions): Promise<Playroom>
  archive(id: string, options?: WriteOptions): Promise<void>
  attach(id: string, content: ContentRef, options?: WriteOptions): Promise<PlayroomContent>
  detach(id: string, contentId: string, options?: WriteOptions): Promise<void>
}

export interface ToolkitsResource {
  list(options?: ListOptions): Paginated<Toolkit>
  create(body: ToolkitWrite, options?: WriteOptions): Promise<Toolkit>
  get(id: string): Promise<Toolkit>
  update(id: string, body: Partial<ToolkitWrite>, options?: WriteOptions): Promise<Toolkit>
  archive(id: string, options?: WriteOptions): Promise<void>
}

export interface GamesResource {
  list(
    options?: ListOptions & {
      skills?: string[]
      players?: number
      ageMin?: number
      ageMax?: number
      category?: string
      provider?: GameProvider
    }
  ): Paginated<Game>
  get(slug: string): Promise<Game>
}

// ─── Attribution ─────────────────────────────────────────────────────────────

/**
 * The attribution triple, required on every generation call and carried on every
 * artifact for the rest of its life.
 *
 * `clinician` is required because generation costs money and somebody owns that.
 * `subject` is what makes an artifact personal — omit it and you have made
 * library content. `session` ties it to a moment.
 */
export interface Attribution {
  clinician: Ref
  subject?: Ref
  session?: Ref
}

// ─── Storybooks ──────────────────────────────────────────────────────────────

export interface StorybookCharacter {
  name: string
  role: 'protagonist' | 'supporting' | 'background'
  /** The visual description carried into every page's image generation. */
  description: string
}

export interface StorybookPage {
  id: string
  number: number
  text: string
  imageUrl: string | null
  charactersInScene: string[]
  regenerating: boolean
}

export interface Storybook {
  id: string
  title: string | null
  status: ContentStatus
  pageCount: number
  ageGroup: string | null
  readingLevel: string | null
  style: string | null
  published: PublishTarget
  createdBy: Reference
  subject: Reference | null
  session: Reference | null
  characters: StorybookCharacter[]
  pages: StorybookPage[]
  createdAt: string
}

export interface StorybookGenerate extends Attribution {
  prompt: string
  /** 4 to 24. Default 8. */
  pages?: number
  ageGroup?: '3-5' | '5-8' | '9-12' | '13-17' | (string & {})
  readingLevel?: 'pre-reader' | 'grade-1' | 'grade-2' | 'grade-4' | (string & {})
  style?: 'watercolour' | 'cartoon' | 'storybook-classic' | 'collage' | (string & {})
  /**
   * Naming characters up front locks their appearance across every page. Omit and
   * they are inferred from the prompt, then locked after the first page.
   */
  characters?: StorybookCharacter[]
}

export interface StorybooksResource {
  list(
    options?: ListOptions & { subject?: string; createdBy?: string; status?: ContentStatus }
  ): Paginated<Storybook>
  get(id: string): Promise<Storybook>
  update(
    id: string,
    body: { title?: string; published?: PublishTarget },
    options?: WriteOptions
  ): Promise<Storybook>
  archive(id: string, options?: WriteOptions): Promise<void>
  /** Asynchronous. Two to four minutes for eight pages. */
  generate(body: StorybookGenerate, options?: WriteOptions): Promise<Job<Storybook>>
  pages: {
    list(storybookId: string): Promise<StorybookPage[]>
    updateText(pageId: string, text: string, options?: WriteOptions): Promise<StorybookPage>
    /** Leaves every other page untouched and preserves character consistency. */
    regenerate(
      pageId: string,
      body?: { prompt?: string },
      options?: WriteOptions
    ): Promise<Job<StorybookPage>>
  }
  /** A personal copy that tracks reading position. */
  assign(
    id: string,
    body: { subject: Ref; notifyCaregiver?: boolean },
    options?: WriteOptions
  ): Promise<Artifact>
  download(id: string, options: { format: DownloadFormat }): Promise<DownloadLink>
}

// ─── Worksheets ──────────────────────────────────────────────────────────────

export interface Worksheet {
  id: string
  title: string
  status: ContentStatus
  pageCount: number
  ageGroup: string | null
  createdBy: Reference
  subject: Reference | null
  published: PublishTarget
  createdAt: string
}

export interface WorksheetWrite {
  title: string
  ageGroup?: string
  published?: PublishTarget
}

export interface WorksheetGenerate extends Attribution {
  prompt: string
  /** 1 to 12. Default 3. */
  pages?: number
  ageGroup?: string
  includeImagery?: boolean
}

export interface WorksheetCopy {
  id: string
  worksheet: Reference
  subject: Reference
  createdBy: Reference
  session: Reference | null
  status: 'assigned' | 'inProgress' | 'complete'
  dueAt: string | null
  completedAt: string | null
}

export interface WorksheetsResource {
  list(options?: ListOptions & { subject?: string; createdBy?: string }): Paginated<Worksheet>
  create(body: WorksheetWrite, options?: WriteOptions): Promise<Worksheet>
  get(id: string): Promise<Worksheet>
  update(id: string, body: Partial<WorksheetWrite>, options?: WriteOptions): Promise<Worksheet>
  archive(id: string, options?: WriteOptions): Promise<void>
  generate(body: WorksheetGenerate, options?: WriteOptions): Promise<Job<Worksheet>>
  assign(
    id: string,
    body: { subject: Ref; dueAt?: string; notifyCaregiver?: boolean },
    options?: WriteOptions
  ): Promise<Artifact>
  /** A completed copy is its own artifact, distinct from the blank it came from. */
  copies: {
    list(
      options?: ListOptions & {
        subject?: string
        worksheet?: string
        status?: WorksheetCopy['status']
      }
    ): Paginated<WorksheetCopy>
  }
  download(id: string, options: { format: DownloadFormat }): Promise<DownloadLink>
}

// ─── Forms ───────────────────────────────────────────────────────────────────

export type FormFieldType =
  | 'shortText'
  | 'longText'
  | 'number'
  | 'singleSelect'
  | 'multiSelect'
  | 'scale'
  | 'date'
  | 'signature'
  | 'file'
  | 'imageChoice'

export interface FormField {
  id: string
  label: string
  type: FormFieldType
  required: boolean
  options?: string[]
  scale?: { min: number; max: number; labels?: string[] }
  /** Show this field only when another field holds a given value. */
  conditional?: { fieldId: string; equals: unknown } | null
}

export interface Form {
  id: string
  title: string
  status: ContentStatus
  scored: boolean
  fields: FormField[]
  createdBy: Reference
  published: PublishTarget
  createdAt: string
}

export interface FormWrite {
  title: string
  scored?: boolean
  fields: FormField[]
  published?: PublishTarget
}

export interface FormResponse {
  id: string
  form: Reference
  subject: Reference
  submittedBy: { role: 'patient' | 'caregiver' | 'clinician'; ref: Reference }
  session: Reference | null
  answers: Array<{ fieldId: string; label: string; type: FormFieldType; value: unknown }>
  /** Present for scored instruments only. */
  score: number | null
  submittedAt: string
}

export interface FormsResource {
  list(options?: ListOptions & { createdBy?: string }): Paginated<Form>
  create(body: FormWrite, options?: WriteOptions): Promise<Form>
  get(id: string): Promise<Form>
  update(id: string, body: Partial<FormWrite>, options?: WriteOptions): Promise<Form>
  archive(id: string, options?: WriteOptions): Promise<void>
  generate(
    body: Attribution & { prompt: string; maxFields?: number; scored?: boolean },
    options?: WriteOptions
  ): Promise<Job<Form>>
  /**
   * Build a structured, fillable form from an existing paper intake sheet. The
   * most common request from clinics with twenty years of paper.
   */
  extract(
    body: { clinician: Ref; file: Uint8Array | Blob; filename: string },
    options?: WriteOptions
  ): Promise<Job<Form>>
  /** Set `routeToCaregiver` for a paediatric client so the link reaches the adult. */
  assign(
    id: string,
    body: { subject: Ref; routeToCaregiver?: boolean; dueAt?: string },
    options?: WriteOptions
  ): Promise<Artifact>
  responses: {
    list(
      options?: ListOptions & { subject?: string; form?: string; submittedAfter?: string }
    ): Paginated<FormResponse>
    get(responseId: string): Promise<FormResponse>
  }
}

// ─── Studio and models ───────────────────────────────────────────────────────

export interface GeneratedGame {
  id: string
  title: string
  status: ContentStatus
  players: number
  playUrl: string | null
  createdBy: Reference
  subject: Reference | null
  published: PublishTarget
  createdAt: string
}

export interface StudioResource {
  /** Four to eight minutes. Produces a real playable application. */
  generate(
    body: Attribution & {
      prompt: string
      players?: number
      duration?: 'short' | 'medium' | 'long'
    },
    options?: WriteOptions
  ): Promise<Job<GeneratedGame>>
  games: {
    list(
      options?: ListOptions & { subject?: string; createdBy?: string }
    ): Paginated<GeneratedGame>
  }
}

export interface Model {
  id: string
  title: string | null
  status: ContentStatus
  format: 'glb'
  previewUrl: string | null
  source: 'text' | 'image'
  createdBy: Reference
  createdAt: string
}

export type ModelGenerate =
  | { clinician: Ref; session?: Ref; source: 'text'; prompt: string }
  | { clinician: Ref; session?: Ref; source: 'image'; file: Uint8Array | Blob; filename: string }

export interface ModelsResource {
  /**
   * Three to six minutes. An uploaded photograph is used to produce the model and
   * then discarded — not retained, not used for training, never in an export. The
   * capability is for objects, pets and places; do not upload an image containing
   * a person.
   */
  generate(body: ModelGenerate, options?: WriteOptions): Promise<Job<Model>>
  list(options?: ListOptions & { createdBy?: string }): Paginated<Model>
}

// ─── Jobs ────────────────────────────────────────────────────────────────────

/**
 * Asynchronous work. Every generation call returns one; nothing blocks.
 *
 * ```ts
 * const job = await playspace.storybooks.generate({ ... })
 * const book = await job.wait({ timeout: '5m' })
 * ```
 */
export interface Job<T = unknown> {
  id: string
  type: JobType
  status: JobStatus
  /** 0 to 100. */
  progress: number
  estimatedSeconds: number | null
  /** Present when `status` is `'complete'`. */
  result: T | null
  /**
   * Present when `status` is `'failed'`. Written for a clinician rather than an
   * engineer. A failed job does not consume quota.
   */
  reason: string | null
  createdBy: Reference
  createdAt: string
  completedAt: string | null

  /** Poll until terminal. Rejects with `JobFailedError` on failure. */
  wait(options?: { timeout?: Duration; pollInterval?: Duration }): Promise<T>
  /** Re-read the job's current state. */
  refresh(): Promise<Job<T>>
  cancel(): Promise<Job<T>>
}

export interface JobsResource {
  list(options?: ListOptions & { status?: JobStatus; type?: JobType }): Paginated<Job>
  get(jobId: string): Promise<Job>
  cancel(jobId: string, options?: WriteOptions): Promise<Job>
}

// ─── Artifacts ───────────────────────────────────────────────────────────────

/**
 * Everything PlaySpace produced and kept.
 *
 * The attribution triple is why the client library, the export bundle, the spend
 * report and the session summary are each one query.
 */
export interface Artifact {
  id: string
  type: ArtifactType
  title: string | null
  status: ContentStatus
  /** The clinician. Always present. */
  createdBy: Reference
  /** The client it was made for. Null for library content. */
  subject: Reference | null
  /** Where it happened. Null for content created outside a session. */
  session: Reference | null
  formats: DownloadFormat[]
  createdAt: string
}

export interface DownloadLink {
  url: string
  format: DownloadFormat
  sizeBytes: number
  expiresAt: string
}

export interface ArtifactsResource {
  list(
    options?: ListOptions & {
      subject?: string
      createdBy?: string
      session?: string
      type?: ArtifactType
      createdAfter?: string
    }
  ): Paginated<Artifact>
  get(artifactId: string): Promise<Artifact>
  /**
   * A short-lived, single-use link. Request it when you are ready to stream it.
   *
   * `'json'` is available for every type and is always complete: no rendered
   * format carries information its structured form omits.
   */
  download(artifactId: string, options: { format: DownloadFormat }): Promise<DownloadLink>
}

// ─── Exports ─────────────────────────────────────────────────────────────────

export type ExportInclude =
  | '*'
  | 'storybooks'
  | 'worksheets'
  | 'formResponses'
  | 'sandtraySaves'
  | 'dollhouseSaves'
  | 'whiteboardSnapshots'
  | 'generatedGames'
  | 'models'
  | 'sessionSummaries'
  | 'notes'

export type ExportReason =
  | 'clientRequest'
  | 'caregiverRequest'
  | 'clinicalTransfer'
  | 'migration'
  | 'legal'
  | 'internalReview'

export interface ExportCreate {
  /** Required unless `scope` is `'organisation'`. */
  subject?: Ref
  scope?: 'subject' | 'organisation'
  /** Naming families explicitly is better practice — an export is a disclosure. */
  include: ExportInclude[]
  format?: 'bundle' | 'json'
  /** Must resolve to somebody with standing to make this disclosure. */
  requestedBy: Ref
  reason: ExportReason
}

export interface Export {
  id: string
  scope: 'subject' | 'organisation'
  subject: Reference | null
  include: ExportInclude[]
  format: 'bundle' | 'json'
  status: 'queued' | 'running' | 'complete' | 'failed' | 'expired'
  progress: number
  sizeBytes: number | null
  partCount: number
  requestedBy: Reference
  reason: ExportReason
  createdAt: string
  /** Seven days after completion. Request it again if you need it again. */
  expiresAt: string | null

  wait(options?: { timeout?: Duration }): Promise<ExportDownload>
  refresh(): Promise<Export>
}

export interface ExportDownload {
  parts: Array<{ url: string; part: number; sizeBytes: number }>
  expiresAt: string
}

export interface ExportsResource {
  list(options?: ListOptions & { subject?: string }): Paginated<Export>
  create(body: ExportCreate, options?: WriteOptions): Promise<Export>
  get(exportId: string): Promise<Export>
  download(exportId: string): Promise<ExportDownload>
}

// ─── Notes ───────────────────────────────────────────────────────────────────

export interface Note {
  id: string
  subject: Reference
  author: Reference
  session: Reference | null
  template: Reference
  status: 'draft' | 'final' | 'amended'
  version: number
  sections: Array<{ heading: string; body: string }>
  finalisedAt: string | null
  createdAt: string
}

export interface NoteVersion {
  version: number
  author: Reference
  isActive: boolean
  createdAt: string
}

export interface NoteTemplate {
  id: string
  name: string
  sections: string[]
}

/**
 * Read-only. There is no write method and there will not be one: your platform
 * owns clinical documentation. Requires the `notes` capability on the
 * organisation, which is off by default.
 */
export interface NotesResource {
  list(
    options?: ListOptions & { subject?: string; session?: string; finalisedAfter?: string }
  ): Paginated<Note>
  get(noteId: string): Promise<Note>
  versions(noteId: string): Promise<NoteVersion[]>
  templates(): Promise<NoteTemplate[]>
}

// ─── Change feed ─────────────────────────────────────────────────────────────

export type ChangeType =
  | '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'

/**
 * One entry in the change feed.
 *
 * Payloads carry identifiers, statuses, counts, durations and enumerated values.
 * Never a name, never clinical content. Fetch the object if you need it.
 */
export interface Change {
  /** Monotonic within your organisation. */
  sequence: number
  type: ChangeType
  occurredAt: string
  artifact: Reference | null
  session: Reference | null
  subject: Reference | null
  job: Reference | null
  data: Record<string, unknown>
}

export interface ChangesResource {
  /** Omit `since` to start from now. Entries are retained thirty days. */
  list(options?: {
    since?: string
    limit?: number
    types?: ChangeType[]
  }): Promise<{ data: Change[]; nextCursor: string; hasMore: boolean }>

  /**
   * A managed poll loop for a worker process. Invokes `onChange` in order and
   * advances the cursor only after it resolves, so a throw stops at the last
   * successfully processed entry — nothing lost, nothing silently skipped.
   */
  subscribe(options: {
    cursor?: string
    onChange: (change: Change) => Promise<void> | void
    onCursor?: (cursor: string) => Promise<void> | void
    onError?: (error: PlaySpaceError) => Promise<void> | void
    types?: ChangeType[]
    interval?: Duration
    signal?: AbortSignal
  }): Promise<void>
}

// ─── Usage ───────────────────────────────────────────────────────────────────

export interface Usage {
  /** `'2026-09'`. */
  period: string
  resources: Record<string, { used: number; limit: number }>
  resetsAt: string
}

export interface UsageResource {
  get(): Promise<Usage>
  byClinician(
    options?: ListOptions & { createdBy?: string }
  ): Paginated<{ clinician: Reference; counts: Record<string, number> }>
  history(options?: { periods?: number }): Promise<Usage[]>
}

// ─── Errors ──────────────────────────────────────────────────────────────────

export declare class PlaySpaceError extends Error {
  /** The stable problem type. Branch on this, never on `message`. */
  readonly type: string
  readonly status: number
  readonly requestId: string | null
  readonly detail: string | null
}

export declare class AuthenticationError extends PlaySpaceError {}

export declare class PermissionError extends PlaySpaceError {
  readonly requiredScopes: string[] | null
  readonly grantedScopes: string[] | null
  readonly surface: Surface | null
}

export declare class NotFoundError extends PlaySpaceError {}

export declare class ConflictError extends PlaySpaceError {}

export declare class ValidationError extends PlaySpaceError {
  readonly fields: Array<{ path: string; message: string }>
  /** Present on `origin-not-allowed`. */
  readonly origin: string | null
}

export declare class RateLimitError extends PlaySpaceError {
  /** Seconds. */
  readonly retryAfter: number
}

export declare class QuotaExceededError extends PlaySpaceError {
  readonly resource: string
  readonly resetsAt: string
}

export declare class ServiceError extends PlaySpaceError {
  readonly retryAfter: number | null
}

/** Thrown by `job.wait()` when the job reaches `'failed'`. */
export declare class JobFailedError extends PlaySpaceError {
  readonly jobId: string
  /** Written for a clinician rather than an engineer. */
  readonly reason: string
}

/** Thrown by `job.wait()` and `export.wait()` when the timeout elapses. The work continues. */
export declare class TimeoutError extends PlaySpaceError {
  readonly jobId: string
}