Server SDK
@playspace/sdk — TypeScript and JavaScript, Node 20 and above. Also runs on Deno, Bun, and Vercel and Cloudflare edge runtimes.
npm install @playspace/sdk
import { PlaySpace } from '@playspace/sdk'
const playspace = new PlaySpace({
clientId: process.env.PLAYSPACE_CLIENT_ID,
clientSecret: process.env.PLAYSPACE_CLIENT_SECRET,
environment: 'production',
})
Credentials are read from PLAYSPACE_CLIENT_ID, PLAYSPACE_CLIENT_SECRET and PLAYSPACE_ENV when the constructor is called with no arguments. These are server credentials. They must never reach a browser — the React SDK exists so that they do not have to.
What the client handles for you
Access tokens. Fetched, cached, and refreshed before expiry. You never see one.
Idempotency. Every mutating call sends an idempotency key derived from its arguments. A retried network failure cannot create two patients or start two generation jobs. Override with { idempotencyKey } when you want to control it yourself.
Retries. Transient failures and rate limits are retried with exponential backoff and jitter, honouring Retry-After. Configure with { maxRetries, timeout }. Mutations are only retried when idempotency makes it safe.
Pagination. Every list method is an async iterator that pages transparently.
for await (const patient of playspace.patients.list({ clinic: clinic.id })) {
// pages fetched as needed; stop whenever you like
}
// Or take a page at a time.
const page = await playspace.patients.list({ limit: 50 }).page()
page.data; page.nextCursor; page.hasMore
Types. Generated from the same contract that serves the API, so a field that exists in the types exists on the wire.
Identity
playspace.clinics
playspace.practitioners
playspace.patients
playspace.caregivers
Each exposes upsert, get, list, update and archive.
const clinician = await playspace.practitioners.upsert({
externalId: 'staff_8842',
clinic: { externalId: 'location_northgate' },
firstName: 'Dana',
lastName: 'Okafor',
email: 'dana.okafor@example-practice.com',
role: 'member', // 'member' | 'administrator' | 'owner'
country: 'CA',
})
upsert is keyed on externalId. Safe to run on every synchronisation pass; it creates on first sight and updates thereafter. There is no separate reconciliation story because there is nothing to reconcile.
Every method accepts your identifier in place of ours.
await playspace.patients.get({ externalId: 'client_55130' })
await playspace.patients.update({ externalId: 'client_55130' }, { dateOfBirth: '2018-04-11' })
Person search takes a body, never a query string.
await playspace.patients.search({ query: 'A. R.', clinic: clinic.id })
This is a POST. It is a POST because a platform's own request logs capture query strings before anything at the application layer can act on them, and a client's name has no business being in one.
Caregivers link a dependent to the adult who holds consent.
await playspace.caregivers.link({
patient: { externalId: 'client_55130' },
caregiver: { externalId: 'client_55129' },
relationship: 'parent',
receivesSessionLinks: true,
canRequestExport: true,
})
Archiving is soft and unenumerable. archive makes a record unreadable rather than destroying it. Reading an archived, non-existent, or another organisation's record all return the same not-found response.
Sessions and tokens
const session = await playspace.sessions.create({
appointment: { externalId: 'appt_11923', scheduledAt: '2026-09-02T15:00:00Z' },
clinician: { externalId: 'staff_8842' },
participants: [{ patient: { externalId: 'client_55130' } }],
playroom: 'child-default',
surfaces: ['sandtray', 'dollhouse', 'whiteboard', 'games'],
notify: { patient: true, clinician: false },
})
session.id // 'sess_2Nk8pQvR7xLm'
session.status // 'pending'
session.links.clinician // hosted link, role-scoped
session.links.patient // hosted link, role-scoped
Other methods: get, list, update, cancel, end, and summary.
const summary = await playspace.sessions.summary(session.id)
// { durationMs, participants, surfacesUsed, artifacts: [...], gamesPlayed: [...] }
Tokens are minted per person per session and are what the React SDK consumes.
const token = await playspace.tokens.issue({
session: session.id,
subject: { externalId: 'staff_8842' },
role: 'clinician',
surfaces: ['sandtray', 'whiteboard'], // optional; defaults to the session's list
origins: ['https://app.example-practice.com'],
ttl: '15m', // maximum '60m'
})
token.value; token.expiresAt
playspace.tokens.revoke(token.id) invalidates one immediately — useful when a clinician logs out of your application mid-session.
Content library
playspace.playrooms
playspace.toolkits
playspace.games
playspace.storybooks
playspace.worksheets
playspace.forms
playspace.studio // generated games
playspace.models // generated three-dimensional models
Read the catalog:
for await (const game of playspace.games.list({
skills: ['emotional-regulation'],
players: 2,
ageRange: [6, 10],
})) { ... }
Assemble a playroom:
const playroom = await playspace.playrooms.create({
title: 'Anxiety — ages 6 to 9',
type: 'child',
items: ['sand_tray', 'dollhouse', 'whiteboard', 'multiplayer_games', 'activity_shelf'],
contents: [
{ type: 'storybook', id: 'sb_7Hn3xQ' },
{ type: 'worksheet', id: 'ws_3Bn8kR' },
{ type: 'game', slug: 'worry-pet' },
],
})
Read what a client produced:
const responses = await playspace.forms.responses.list({
subject: { externalId: 'client_55130' },
})
const copies = await playspace.worksheets.copies.list({
subject: { externalId: 'client_55130' },
})
Generation
Every generation method returns a job. See Content and generation for the full treatment.
const job = await playspace.storybooks.generate({
clinician: { externalId: 'staff_8842' },
subject: { externalId: 'client_55130' },
prompt: 'A story about starting at a new school.',
pages: 8,
ageGroup: '5-8',
style: 'watercolour',
})
const storybook = await job.wait({ timeout: '5m' })
playspace.storybooks.generate(...)
playspace.worksheets.generate(...)
playspace.forms.generate(...) // from a prompt
playspace.forms.extract(...) // from an uploaded document
playspace.studio.generate(...) // a playable game
playspace.models.generate(...) // a three-dimensional figure
Job control:
await playspace.jobs.get(job.id)
await playspace.jobs.cancel(job.id)
for await (const j of playspace.jobs.list({ status: 'running' })) { ... }
Artifacts
The single index over everything PlaySpace produced and kept.
for await (const artifact of playspace.artifacts.list({
subject: { externalId: 'client_55130' },
})) {
artifact.type // 'storybook' | 'worksheet_copy' | 'form_response' | 'sandtray_save' |
// 'whiteboard_snapshot' | 'generated_game' | 'model' | 'session_summary'
artifact.createdBy // the clinician
artifact.subject // the client, or null
artifact.session // where it happened, or null
}
await playspace.artifacts.download('sb_7Hn3xQ', { format: 'pdf' })
Exports
const exp = await playspace.exports.create({
subject: { externalId: 'client_55130' },
include: ['storybooks', 'worksheets', 'form_responses', 'sandtray_saves', 'session_summaries'],
format: 'bundle',
requestedBy: { externalId: 'staff_8842' },
})
const { url, expiresAt } = await exp.wait()
Full treatment in Portability and exports.
The change feed
const changes = await playspace.changes.list({ since: cursor, limit: 100 })
for (const change of changes.data) { ... }
await store.setCursor(changes.nextCursor)
Or as a managed loop, for a worker process:
await playspace.changes.subscribe({
cursor: await store.getCursor(),
onChange: async (change) => { await handle(change) },
onCursor: async (cursor) => { await store.setCursor(cursor) },
interval: '30s',
})
subscribe polls, invokes your handler in order, and advances the cursor only after your handler resolves. A throw stops the loop at the last successfully processed entry, so nothing is lost and nothing is silently skipped.
Usage and quota
const usage = await playspace.usage.get()
// {
// period: '2026-09',
// storybooks: { used: 214, limit: 1000 },
// worksheets: { used: 88, limit: 500 },
// models: { used: 37, limit: 250 },
// games: { used: 12, limit: 100 },
// resetsAt: '2026-10-01T00:00:00Z'
// }
Build a warning into your own interface at eighty percent. A clinician who hits an exhausted quota mid-session with no warning has a bad afternoon and files a support ticket with you, not with us.
Errors
import {
PlaySpaceError, // base
AuthenticationError, // 401 — credentials
PermissionError, // 403 — scope, role, or entitlement
NotFoundError, // 404 — absent, archived, or another organisation's
ValidationError, // 422 — carries .fields
ConflictError, // 409 — idempotency or a real collision
RateLimitError, // 429 — carries .retryAfter
QuotaExceededError, // 429 — carries .resetsAt
ServiceError, // 5xx
} from '@playspace/sdk'
Every error carries requestId, type and status. Log requestId — quoting one to partner engineering addresses a single request in our audit log directly.
try {
await playspace.storybooks.generate({ ... })
} catch (error) {
if (error instanceof QuotaExceededError) return scheduleRetryAfter(error.resetsAt)
if (error instanceof ValidationError) return reportToClinician(error.fields)
throw error
}
Testing
import { PlaySpaceMock } from '@playspace/sdk/testing'
const playspace = new PlaySpaceMock({ seed: 'deterministic' })
// Generation resolves immediately with a fixture.
const job = await playspace.storybooks.generate({ ... })
const book = await job.wait() // returns instantly
PlaySpaceMock implements the full surface in memory with no network. Generation resolves against fixtures rather than a model, so a test suite is fast, deterministic, and costs nothing.
For integration tests against the real sandbox, playspace.messages.list() returns every email and text message the platform would have sent, so you can assert on delivery without a mailbox. The sandbox never sends to a real address.