@playspace/sdk — method reference
Every resource and every method. Precise signatures are in index.d.ts; this page is the annotated version.
Conventions used throughout:
IdOrRefis a PlaySpace identifier string, or{ id }, or{ externalId }. All three are accepted anywhere an identifier is.Paginated<T>is an async iterator.for awaitto page transparently,.page()for one page,.all()to collect.- Every mutation accepts a trailing
{ idempotencyKey }. Omit it and the SDK derives one from the arguments. - Every method may throw one of the typed errors.
Client
new PlaySpace(options?: PlaySpaceOptions)
| Option | Default | Notes |
|---|---|---|
clientId |
PLAYSPACE_CLIENT_ID |
|
clientSecret |
PLAYSPACE_CLIENT_SECRET |
Server-side only |
environment |
PLAYSPACE_ENV, then production |
production or sandbox |
baseUrl |
derived | Rarely needed |
timeout |
30000 |
Per request, milliseconds |
maxRetries |
3 |
Mutations retry only where idempotency makes it safe |
headers |
— | Added to every request |
fetch |
global | Supply your own |
playspace.request<T>(method, path, init?)
Escape hatch for an endpoint this SDK version does not model yet. Handles authentication, retries and idempotency; returns the unwrapped data.
organisation
playspace.organisation.get(): Promise<Organisation>
Your organisation, its status, and its enabled capabilities.
playspace.organisation.surfaces(): Promise<SurfaceAvailability[]>
Which surfaces this organisation is licensed for, and which of those can render in a partner-hosted frame. Render your own entry points from this list — a surface absent here is refused at token mint with entitlement-denied, and a clinician should never click something that cannot work.
playspace.organisation.groups(options?): Paginated<PracticeGroup>
The optional hierarchy level above the clinic, for multi-location practices.
clinics · practitioners · patients
The three identity resources share a shape.
playspace.clinics.list(options?): Paginated<Clinic>
playspace.clinics.upsert(body, options?): Promise<Clinic>
playspace.clinics.get(id): Promise<Clinic>
playspace.clinics.update(id, body, options?): Promise<Clinic>
playspace.clinics.archive(id, options?): Promise<void>
upsert is the write verb you want. Keyed on externalId, it creates on first sight and updates thereafter, so it is safe on every synchronisation pass. update is a partial write for when you do not have the full record to hand and throws NotFoundError for an unknown identifier.
archive is soft. The record becomes unreadable rather than being destroyed. Archiving a clinic with active practitioners or patients throws ValidationError.
List filters
| Resource | Filters |
|---|---|
clinics |
group, includeArchived |
practitioners |
clinic, includeArchived |
patients |
clinic, practitioner, includeArchived |
Archived records are excluded unless includeArchived is set.
patients.search
playspace.patients.search({ query, clinic?, practitioner?, limit? }): Promise<Patient[]>
A POST under the hood, and there is no GET equivalent. A person's 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. This is a routing decision, not a redaction one.
patients.caregivers
playspace.patients.caregivers(id): Promise<CaregiverLink[]>
caregivers
playspace.caregivers.link(body, options?): Promise<CaregiverLink>
playspace.caregivers.unlink(linkId, options?): Promise<void>
await playspace.caregivers.link({
patient: { externalId: 'client_55130' },
caregiver: { externalId: 'client_55129' },
relationship: 'parent',
receivesSessionLinks: true,
canRequestExport: true,
})
Load-bearing, not decorative. The link decides who receives a session link, who may join alongside a child, and who may request an export. canRequestExport is checked by exports.create and is why it exists here rather than being assumed.
sessions
playspace.sessions.create(body, options?): Promise<Session>
Created against an appointment that lives in your scheduler. PlaySpace needs your identifier for it, not the appointment itself.
surfaces is the ceiling for the session: a surface absent from it cannot be opened by anyone, whatever a token or front end asks for. Omit it for every licensed surface.
The response carries links.clinician and links.patient — hosted, role-scoped, and never stored.
playspace.sessions.list(options?): Paginated<Session>
playspace.sessions.get(id): Promise<Session>
playspace.sessions.update(id, body, options?): Promise<Session>
playspace.sessions.end(id, options?): Promise<Session>
playspace.sessions.cancel(id, body?, options?): Promise<Session>
playspace.sessions.summary(id): Promise<SessionSummary>
playspace.sessions.links(id, options?): Promise<SessionLinks>
list filters on status, clinician, patient, scheduledAfter, scheduledBefore.
get omits links — they are ephemeral. Call links() when you are about to put them in front of somebody.
cancel revokes every outstanding token and link for the session.
summary
const summary = await playspace.sessions.summary(sessionId)
Duration, participants, surfaces used, artifacts produced, games played.
Three caveats before you build a report from gamesPlayed. 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 — 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.
tokens
playspace.tokens.issue(body, options?): Promise<SessionToken>
playspace.tokens.revoke(tokenId, options?): Promise<void>
const token = await playspace.tokens.issue({
session: 'sess_2Nk8pQvR7xLm',
subject: { externalId: 'staff_8842' },
role: 'clinician',
surfaces: ['sandtray', 'whiteboard'], // optional; inherits the session's list
origins: ['https://app.example-practice.com'],
ttl: '15m', // maximum '60m'
})
Three gates apply at mint, and all three fail loudly rather than issuing a token that will not work: requested surfaces within the session's ceiling, origins a browser will honour as frame sources, and the organisation licensed for each surface.
Authorise before you mint. PlaySpace verifies the subject belongs to your organisation and the session exists. It cannot verify that this browser should be that subject — only you know that.
revoke is immediate. Call it when a clinician signs out of your application mid-session.
playrooms · toolkits
playspace.playrooms.list(options?): Paginated<Playroom>
playspace.playrooms.create(body, options?): Promise<Playroom>
playspace.playrooms.get(id): Promise<Playroom>
playspace.playrooms.update(id, body, options?): Promise<Playroom>
playspace.playrooms.archive(id, options?): Promise<void>
playspace.playrooms.attach(id, content, options?): Promise<PlayroomContent>
playspace.playrooms.detach(id, contentId, options?): Promise<void>
const playroom = await playspace.playrooms.create({
title: 'Anxiety — ages 6 to 9',
type: 'child',
items: ['sandTray', 'dollhouse', 'whiteboard', 'multiplayerGames', 'activityShelf'],
contents: [
{ type: 'storybook', id: 'sb_7Hn3xQ' },
{ type: 'worksheet', id: 'ws_3Bn8kR' },
{ type: 'game', slug: 'worry-pet' },
],
})
items selects which live surfaces the environment offers. contents is the ordered library attached to it.
toolkits exposes list, create, get, update and archive with the same content model and no environment settings.
games
playspace.games.list(options?): Paginated<Game>
playspace.games.get(slug): Promise<Game>
The catalog this organisation is licensed for, across every provider.
for await (const game of playspace.games.list({
skills: ['emotional-regulation'],
players: 2,
ageMin: 6,
ageMax: 10,
})) { ... }
Filters: skills, players, ageMin, ageMax, category, provider.
storybooks
playspace.storybooks.generate(body, options?): Promise<Job<Storybook>>
Asynchronous. Two to four minutes for eight pages.
const job = await playspace.storybooks.generate({
clinician: { externalId: 'staff_8842' },
subject: { externalId: 'client_55130' },
session: { externalId: 'appt_11923' },
prompt: 'A story about starting at a new school.',
pages: 8,
ageGroup: '5-8',
readingLevel: 'grade-2',
style: 'watercolour',
characters: [
{ name: 'Nia', role: 'protagonist', description: 'seven, box braids, yellow raincoat' },
{ name: 'Pip', role: 'supporting', description: 'a small grey cat with one white ear' },
],
})
Naming characters locks their appearance across pages. The description is 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, then locked after the first page.
playspace.storybooks.list(options?): Paginated<Storybook>
playspace.storybooks.get(id): Promise<Storybook>
playspace.storybooks.update(id, { title?, published? }, options?): Promise<Storybook>
playspace.storybooks.archive(id, options?): Promise<void>
playspace.storybooks.download(id, { format }): Promise<DownloadLink>
playspace.storybooks.pages.list(storybookId): Promise<StorybookPage[]>
playspace.storybooks.pages.updateText(pageId, text, options?): Promise<StorybookPage>
playspace.storybooks.pages.regenerate(pageId, { prompt? }, options?): Promise<Job<StorybookPage>>
playspace.storybooks.assign(id, { subject, notifyCaregiver? }, options?): Promise<Artifact>
assign creates a personal copy that tracks reading position, so a child can read between appointments and the clinician can see how far they got.
Download formats: pdf, epub, png per page, json.
worksheets
playspace.worksheets.generate(body, options?): Promise<Job<Worksheet>>
playspace.worksheets.list(options?): Paginated<Worksheet>
playspace.worksheets.create(body, options?): Promise<Worksheet>
playspace.worksheets.get(id): Promise<Worksheet>
playspace.worksheets.update(id, body, options?): Promise<Worksheet>
playspace.worksheets.archive(id, options?): Promise<void>
playspace.worksheets.assign(id, { subject, dueAt?, notifyCaregiver? }, options?): Promise<Artifact>
playspace.worksheets.copies.list(options?): Paginated<WorksheetCopy>
playspace.worksheets.download(id, { format }): Promise<DownloadLink>
A completed copy is its own artifact, distinct from the blank it came from. The blank is library content with no subject; the copy has one. copies.list filters on subject, worksheet and status.
forms
playspace.forms.generate({ clinician, prompt, maxFields?, scored? }, options?): Promise<Job<Form>>
playspace.forms.extract({ clinician, file, filename }, options?): Promise<Job<Form>>
generate builds a form from a description. extract reads an existing paper intake sheet — portable document, PNG or JPEG, up to 20 megabytes — and produces a structured, fillable form from it. The latter is the most common request from clinics with twenty years of paper.
playspace.forms.list(options?): Paginated<Form>
playspace.forms.create(body, options?): Promise<Form>
playspace.forms.get(id): Promise<Form>
playspace.forms.update(id, body, options?): Promise<Form>
playspace.forms.archive(id, options?): Promise<void>
playspace.forms.assign(id, { subject, routeToCaregiver?, dueAt? }, options?): Promise<Artifact>
playspace.forms.responses.list(options?): Paginated<FormResponse>
playspace.forms.responses.get(responseId): Promise<FormResponse>
Set routeToCaregiver for a paediatric client so the completion link reaches the adult who holds consent rather than the child.
Responses come back as structured answers with a computed score for scored instruments — never a rendered blob.
studio · models
playspace.studio.generate({ clinician, subject?, prompt, players?, duration? }, options?)
: Promise<Job<GeneratedGame>>
playspace.studio.games.list(options?): Paginated<GeneratedGame>
Four to eight minutes. Produces a real playable application, launchable in a session like any catalog game and attachable to a playroom.
playspace.models.generate(body, options?): Promise<Job<Model>>
playspace.models.list(options?): Paginated<Model>
// From a description
await playspace.models.generate({
clinician: { externalId: 'staff_8842' },
source: 'text',
prompt: 'a small brown terrier with a red collar, sitting',
})
// From a photograph
await playspace.models.generate({
clinician: { externalId: 'staff_8842' },
source: 'image',
file: await fs.readFile('./dog.jpg'),
filename: 'dog.jpg',
})
Three to six minutes, producing a GLB usable in the sandtray immediately.
An uploaded photograph is discarded after generation. Not retained, not used for training, never in an export. The capability exists for objects, pets and places — do not upload an image containing a person.
jobs
playspace.jobs.list(options?): Paginated<Job>
playspace.jobs.get(jobId): Promise<Job>
playspace.jobs.cancel(jobId, options?): Promise<Job>
Every generation call returns a Job with three methods of its own:
job.wait(options?): Promise<T> // polls until terminal
job.refresh(): Promise<Job<T>> // re-read current state
job.cancel(): Promise<Job<T>>
Pick by where your code runs.
// A script: block.
const result = await job.wait({ timeout: '5m' })
// A request handler: poll and return.
const current = await playspace.jobs.get(job.id)
// A worker: the change feed, and never poll a job identifier again.
await playspace.changes.subscribe({ onChange: handle })
wait throws JobFailedError on failure and TimeoutError if the timeout elapses — the work continues either way.
A failed job does not consume quota, so a retry after a failure is free. job.reason is written for a clinician rather than an engineer.
Typical durations: storybook two to four minutes, worksheet one to two, form under thirty seconds, generated game four to eight, model three to six.
artifacts
playspace.artifacts.list(options?): Paginated<Artifact>
playspace.artifacts.get(artifactId): Promise<Artifact>
playspace.artifacts.download(artifactId, { format }): Promise<DownloadLink>
The single index over everything PlaySpace produced and kept, queryable from any end of the attribution triple.
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' } })
Filters: subject, createdBy, session, type, createdAfter.
json is available for every artifact type and is always complete — no rendered format carries information its structured form omits. Download links are short-lived and single-use; request one when you are ready to stream it.
exports
playspace.exports.create(body, options?): Promise<Export>
playspace.exports.list(options?): Paginated<Export>
playspace.exports.get(exportId): Promise<Export>
playspace.exports.download(exportId): Promise<ExportDownload>
const exp = await playspace.exports.create({
subject: { externalId: 'client_55130' },
include: ['storybooks', 'worksheets', 'formResponses', 'sandtraySaves', 'sessionSummaries'],
format: 'bundle',
requestedBy: { externalId: 'staff_8842' },
reason: 'clientRequest',
})
const { parts, expiresAt } = await exp.wait({ timeout: '30m' })
requestedBy must resolve to somebody with standing. A clinician for their own clients, an administrator for any client in their clinic, a caregiver for a dependent whose link carries canRequestExport. Otherwise PermissionError.
Notes need three things to be included: the organisation has the notes capability, notes appears explicitly in include, and the requester is a clinician or administrator. A caregiver-initiated export never contains notes.
Naming families explicitly is better practice than '*' — an export is a disclosure, and disclosures should be deliberate. Bundles are available seven days after completion, then deleted.
notes
playspace.notes.list(options?): Paginated<Note>
playspace.notes.get(noteId): Promise<Note>
playspace.notes.versions(noteId): Promise<NoteVersion[]>
playspace.notes.templates(): Promise<NoteTemplate[]>
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. Without it every call throws PermissionError.
changes
playspace.changes.list(options?): Promise<{ data, nextCursor, hasMore }>
playspace.changes.subscribe(options): Promise<void>
One ordered, resumable feed of everything that changed. There are no webhooks; you poll this.
const changes = await playspace.changes.list({ since: cursor, limit: 100 })
for (const change of changes.data) { ... }
await store.setCursor(changes.nextCursor)
For a worker, subscribe manages the loop:
await playspace.changes.subscribe({
cursor: await store.getCursor(),
onChange: async (change) => { await handle(change) },
onCursor: async (cursor) => { await store.setCursor(cursor) },
interval: '30s',
signal: abortController.signal,
})
It invokes onChange in order and advances the cursor only after your handler resolves, so a throw stops at the last successfully processed entry — nothing lost, nothing silently skipped.
The cursor is opaque and monotonic. Entries are retained thirty days; a cursor older than that throws ValidationError. 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. Fetch the object if you need it.
usage
playspace.usage.get(): Promise<Usage>
playspace.usage.byClinician(options?): Paginated<{ clinician, counts }>
playspace.usage.history({ periods? }): Promise<Usage[]>
const usage = await playspace.usage.get()
// { period: '2026-09', resources: { storybooks: { used: 214, limit: 1000 } }, resetsAt: '...' }
Warn at eighty percent in your own interface. A clinician who discovers an exhausted quota halfway through a session with a seven-year-old will contact you, not us.
Errors
Every error extends PlaySpaceError and carries type, status, requestId and detail.
| Class | Thrown for | Extra properties |
|---|---|---|
AuthenticationError |
401 | |
PermissionError |
403 | requiredScopes, grantedScopes, surface |
NotFoundError |
404 | |
ConflictError |
409 | |
ValidationError |
422 | fields, origin |
RateLimitError |
429 rate limit | retryAfter |
QuotaExceededError |
429 quota | resource, resetsAt |
ServiceError |
5xx | retryAfter |
JobFailedError |
job.wait() on failure |
jobId, reason |
TimeoutError |
job.wait() on timeout |
jobId |
Branch on the class or on type, never on message — messages are prose and may be reworded.
404 covers three cases on purpose: absent, archived, and belonging to another organisation are deliberately indistinguishable, because distinguishing them is exactly the signal an enumeration attack needs.
Full catalogue with causes and fixes: error reference.
Testing
import { PlaySpaceMock } from '@playspace/sdk/testing'
const playspace = new PlaySpaceMock({ seed: 'deterministic' })
The whole surface in memory, no network. Generation resolves against fixtures, so job.wait() returns instantly and a suite is fast, deterministic and free.
Seed it with your own data:
const playspace = new PlaySpaceMock({
seed: 'deterministic',
fixtures: { patients: [{ externalId: 'client_55130', firstName: 'A.', lastName: 'R.' }] },
})
Assert on failure paths without provoking them:
playspace.mock.failNext('storybooks.generate', new QuotaExceededError({ resource: 'storybooks' }))