Core concepts

Seven ideas. If you understand these, the rest of the documentation is reference material you can look up when you need it.


1. Identity is a pointer, not a copy

You are the record of truth for people. We hold a pointer.

Every person-shaped object on this platform — clinic, practitioner, patient, caregiver — carries an externalId, which is your identifier for them. It is unique within your organisation, it never changes, and it is addressable everywhere a PlaySpace identifier is.

// These two calls are equivalent.
await playspace.patients.get('pt_9Kd2mXwF')
await playspace.patients.get({ externalId: 'client_55130' })

The consequence worth internalising: you never have to store a PlaySpace identifier. If you would rather not add a column, do not add one. Every call accepts your own identifier and every object we return echoes it back.

upsert is the write verb for identity. It creates on first sight and updates thereafter, keyed on externalId. It is safe to call on every synchronisation pass, and it is the reason there is no separate reconciliation story in this documentation.

Caregivers are first-class. A dependent client links to the adult who holds consent. That relationship determines who receives a session link, who can join alongside a child, and who is entitled to request an export. In a paediatric caseload it is load-bearing, not decorative.


2. Sessions are the container for everything live

A session is one appointment's worth of PlaySpace. It has a clinician, one or more participants, a playroom, a permitted surface list, and a lifetime.

pending  →  live  →  complete
                 ↘   cancelled

Everything produced inside a session — a saved tray, a whiteboard snapshot, a completed worksheet, a game played, a storybook generated mid-session — is attributed to it. That attribution is what makes a session summary possible, and what makes the export in Portability able to answer "what did this child make with us".

A session does not need to correspond to a real appointment. Pass an externalId of your own devising and you have a durable container for asynchronous work — homework a client does between appointments, an intake form completed at home.

Sessions are cheap. Create one per appointment. Do not try to hold a long-lived session open across a caseload.


3. The token carries the role, so your components do not

A token is minted for one person, in one session, for one role.

const token = await playspace.tokens.issue({
  session: 'sess_2Nk8pQvR7xLm',
  subject: { externalId: 'staff_8842' },
  role: 'clinician',
  origins: ['https://app.example-practice.com'],
  ttl: '15m',
})

Two roles exist: clinician and patient.

A clinician token renders the facilitation side of every surface — the figure library, the tool palette, the navigation rail, the ability to open and close surfaces for everyone, saves, and the session controls.

A patient token renders the participation side — the play surface, their own cursor, and whatever the clinician has opened. No library management, no session controls, no access to another client's anything.

This is why <Sandtray /> takes no role prop. There is no flag to pass and therefore no flag to get wrong. Hand a component a clinician token and it is the clinician's sandtray; hand it a patient token and it is the patient's.

Tokens are short and self-renewing. Fifteen minutes by default, sixty maximum. You never write expiry handling: the provider calls your fetchToken again before the current one lapses. Short lifetimes are what make it safe for a token to sit in a browser tab that a clinician leaves open between appointments.

Origins are validated when the token is minted, not when the frame fails. Register the domains your application is served from and a typo becomes a readable error at mint time rather than a blank rectangle in production that you have to diagnose from the browser console.


4. Surfaces are declared twice, and the narrower one wins

A surface is a thing a person can interact with — sandtray, dollhouse, whiteboard, games, storybooks, worksheets, forms, studio, models.

They are declared in two places:

  • On the session. The ceiling. Nothing outside this list can be opened by anyone in this session.
  • On the token. The individual's share of that ceiling, never wider than it.
// The session permits four surfaces.
sessions.create({ ..., surfaces: ['sandtray', 'dollhouse', 'whiteboard', 'games'] })

// This clinician's token permits two of them.
tokens.issue({ ..., surfaces: ['sandtray', 'whiteboard'] })

Omit surfaces on the token and it inherits the session's list. Request a surface the session does not permit and the mint fails with a readable error rather than issuing a token that will not work.

Availability is a third gate you do not control. A surface the organisation is not licensed for is refused at mint time with entitlement_denied, naming the surface. Read the licensed set with playspace.organisation.surfaces() and render your own entry points from it, so a clinician never clicks something that will fail.


5. Artifacts belong to people

Everything PlaySpace produces and keeps is an artifact: storybooks, worksheets and their completed copies, form responses, saved sandtrays and dollhouses, whiteboard snapshots, generated games, generated three-dimensional models, session summaries.

Every artifact carries the same attribution triple:

{
  "id": "sb_7Hn3xQ",
  "type": "storybook",
  "createdBy": { "id": "prac_4Kx", "externalId": "staff_8842" },
  "subject":   { "id": "pt_9Kd",  "externalId": "client_55130" },
  "session":   { "id": "sess_2Nk", "externalId": "appt_11923" },
  "createdAt": "2026-09-02T15:22:41Z"
}

createdBy is the clinician. subject is the client it was made for, and is null for library content that is not about anyone in particular. session is where it happened, and is null for content created outside one.

This triple is why three things are possible at all: listing everything a given client has made, attributing generation spend to a clinician, and exporting one person's complete record without a hand-written query. It is the single most important structure on the platform and every generation call requires you to populate it.

Artifacts are queryable from either end.

await playspace.artifacts.list({ subject: { externalId: 'client_55130' } })
await playspace.artifacts.list({ createdBy: { externalId: 'staff_8842' }, type: 'storybook' })
await playspace.artifacts.list({ session: { externalId: 'appt_11923' } })

6. Anything expensive is a job

Generation costs real money per invocation and takes anywhere from ten seconds to four minutes. It is never a blocking call.

const job = await playspace.storybooks.generate({ ... })
job.id      // 'job_5Rp1wKz'
job.status  // 'queued'

const storybook = await job.wait({ timeout: '5m' })   // polls for you

job.wait() is a convenience over polling playspace.jobs.get(id). Use it in a script; use the raw poll or the change feed in a server that should not hold a request open.

Jobs are metered against a quota. Every organisation has a generation allowance per period, readable at any time:

const usage = await playspace.usage.get()
// { period: '2026-09', storybooks: { used: 214, limit: 1000 }, models: { used: 37, limit: 250 }, ... }

Exhausting it returns quota_exceeded with the reset time. This is deliberately visible rather than a surprise on an invoice — build a warning into your own interface at eighty percent and your clinicians will never hit a wall they did not see coming.


7. The change feed tells you what happened

There are no webhooks. Instead there is one ordered, resumable feed of everything that changed in your organisation, and you poll it on whatever interval suits you.

let cursor = await yourStore.getCursor()

const changes = await playspace.changes.list({ since: cursor, limit: 100 })

for (const change of changes.data) {
  switch (change.type) {
    case 'artifact.ready':   await attachToChart(change.artifact); break
    case 'session.complete': await recordSessionOutcome(change.session); break
    case 'job.failed':       await alertClinician(change.job); break
    case 'note.available':   await queueNoteFetch(change.note); break
  }
}

await yourStore.setCursor(changes.nextCursor)

Properties worth knowing. The cursor is opaque and monotonic — store it, resume from it, and you cannot miss an event or process one twice. Entries are retained for thirty days. Payloads carry identifiers, statuses, counts and timestamps, never clinical content or a person's name. And a poll that finds nothing is cheap and does not count against your rate limit.

Every event type is documented in the API reference.


Errors

Every failure is an RFC 9457 problem document with a stable machine-readable type, whether it comes back from the REST API or is thrown by an SDK.

{
  "type": "https://api.playspace.health/problems/quota-exceeded",
  "title": "Generation quota exceeded",
  "status": 429,
  "detail": "The storybook generation allowance for this period is exhausted.",
  "resets_at": "2026-10-01T00:00:00Z",
  "request_id": "req_8Kp2mQ"
}

The SDKs throw typed errors carrying the same information:

try {
  await playspace.storybooks.generate({ ... })
} catch (error) {
  if (error instanceof QuotaExceededError) {
    scheduleRetryAfter(error.resetsAt)
  }
}

Always log request_id. Quoting one to partner engineering resolves an issue in minutes rather than hours, because it addresses a single request in our audit log directly.


Two rules the platform enforces on itself

No name and no free-text search term ever travels in a URL. Not in a query string, not in a path segment, in either direction. Person search takes a request body. This is not a preference — a platform's own request logs capture paths and query strings before any application-level protection can act on them, so the only durable answer is not to put it there.

Deletion is soft and unenumerable. Removing something makes it unreadable rather than destroying it. "Never existed", "belongs to another organisation" and "already deleted" all return the same not-found response, deliberately, so no caller can map what they cannot reach.