React SDK
@playspace/react — React 18 and 19, TypeScript-first, server-component safe.
npm install @playspace/react
Every component in this package is role-aware. It reads the role from the token in context and renders the clinician's side or the client's side accordingly. You never pass a role, and there is no prop that would let you pass the wrong one.
Three composition levels
Pick the one that matches how much of the experience you want to own.
Level 1 — the whole session. One component. The complete PlaySpace experience inside your layout, with our navigation.
<PlaySpaceProvider fetchToken={fetchToken}>
<PlayroomSession />
</PlaySpaceProvider>
Level 2 — individual surfaces. You own the navigation and the layout; we own the surface.
<PlaySpaceProvider fetchToken={fetchToken}>
{tab === 'sand' && <Sandtray />}
{tab === 'house' && <Dollhouse />}
{tab === 'draw' && <Whiteboard />}
</PlaySpaceProvider>
Level 3 — headless. You own everything visual. We give you state, data and actions.
const { role, session, surfaces, open } = usePlaySpace()
const { artifacts, isLoading } = useArtifacts({ subject: clientId })
Most partners ship level 2. Level 1 is the fastest route to a demo. Level 3 is for teams with a design system they will not compromise.
<PlaySpaceProvider>
The root. Everything else must be inside one.
<PlaySpaceProvider
fetchToken={fetchToken}
onEvent={handleEvent}
onError={handleError}
theme={{ accent: '#2563eb', radius: 'lg', font: 'inherit' }}
locale="en-CA"
>
{children}
</PlaySpaceProvider>
| Prop | Type | Notes |
|---|---|---|
fetchToken |
() => Promise<string> |
Called on mount and again before expiry. The only required prop. |
token |
string |
A static token. Use instead of fetchToken for short-lived contexts only — it will not renew. |
onEvent |
(e: PlaySpaceEvent) => void |
Every lifecycle event. See Events. |
onError |
(e: PlaySpaceError) => void |
Load and runtime failures. See Error taxonomy. |
theme |
PlaySpaceTheme |
Accent colour, corner radius, font inheritance. Surfaces adapt their chrome; the play content itself is not themeable. |
locale |
string |
Interface language. Falls back to the clinician's account setting, then en. |
environment |
'sandbox' | 'production' |
Inferred from the token. Set explicitly only to assert. |
The renewal contract. fetchToken is called once on mount, then again at eighty percent of the token's lifetime. If it throws, the provider retries with backoff and emits error with code token_refresh_failed after three attempts. A surface mid-interaction is never torn down by a renewal — the new token is swapped in underneath it.
One provider per session. Do not nest providers or mount two for the same session. If you need surfaces in two places on one page, put the provider above both.
Live play surfaces
These render inside a session and synchronise between participants in real time.
<Sandtray />
The flagship surface. A three-dimensional tray, a categorised figure library, and every participant's cursor.
<Sandtray
saveId={resumeFrom}
autosave
height={640}
onSnapshot={(snap) => console.log(snap.artifactId)}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
saveId |
string |
— | Resume a saved tray. Omit for a fresh one. |
autosave |
boolean |
true |
Persists on change, debounced. Clinician token only. |
figureSets |
string[] |
all licensed | Restrict the library, e.g. ['core', 'animals', 'family']. |
height |
number | string |
'100%' |
See Sizing. |
onSnapshot |
(s: Snapshot) => void |
— | Fires when a save completes. |
By role. A clinician gets the figure library, the sand tools, camera controls, save and reset, and the ability to hand control to the client. A client gets the tray, their cursor, and the figures the clinician has made available. Neither sees the other's interface.
<Dollhouse />
Rooms, furniture, and family figures. Same props as <Sandtray />, plus:
| Prop | Type | Default | Notes |
|---|---|---|---|
layout |
'house' | 'apartment' | 'classroom' |
'house' |
The starting structure. |
rooms |
string[] |
all | Restrict which rooms are available. |
<Whiteboard />
Collaborative drawing.
| Prop | Type | Default | Notes |
|---|---|---|---|
background |
'blank' | 'grid' | 'lined' | ImageRef |
'blank' |
An ImageRef puts a worksheet or photograph underneath the drawing layer. |
tools |
Tool[] |
all | 'pen', 'highlighter', 'shapes', 'text', 'stamps', 'eraser'. |
onSnapshot |
(s: Snapshot) => void |
— | Fires on explicit save. |
By role. A clinician can clear the board, change the background, and restrict the client's tools live. A client draws.
<GameLibrary /> and <Game />
<GameLibrary
filter={{ players: 2, skills: ['emotional-regulation'], ageRange: [6, 10] }}
onLaunch={(game) => setActiveGame(game.slug)}
/>
<Game slug="worry-pet" />
<GameLibrary /> renders the catalog the organisation is licensed for, filterable by player count, therapeutic skill, age range, category and provider. <Game /> renders one.
By role. A clinician browses and launches; a client sees only what was launched. A client token rendering <GameLibrary /> renders nothing and emits error with code role_not_permitted.
<Playroom />
Every live surface in one themed environment, with the clinician steering and the client following.
<Playroom type="child" theme="forest" />
| Prop | Type | Default | Notes |
|---|---|---|---|
type |
'child' | 'teen' | 'adult' |
from session | Age-appropriate content and chrome. |
theme |
string |
from playroom | Visual environment. |
items |
SessionItem[] |
from playroom | 'activity_shelf', 'multiplayer_games', 'single_player_games', 'wall_posters', 'whiteboard', 'sand_tray', 'dollhouse'. |
The clinician's navigation moves both people. When the clinician opens the dollhouse, the client's view follows. This is the single component behind Level 1 composition.
<PlayroomSession />
<Playroom /> plus the session frame — video panel, participant presence, the waiting-to-join notice, and session controls. The complete hosted experience, inside your application.
Content surfaces
These do not require a live session. They work standalone, which means you can put them anywhere in your product — a client's chart, a content library page, a homework assignment view.
<StorybookCreator /> and <StorybookReader />
The reference implementation, shipping today.
<StorybookCreator
subject={{ externalId: 'client_55130' }}
onCreated={({ storybookId }) => attachToChart(storybookId)}
onReady={({ storybookId, pageCount }) => notifyClinician(storybookId, pageCount)}
/>
<StorybookReader storybookId="sb_7Hn3xQ" />
onCreated fires the moment the storybook exists, before a single page is written, so you can record the identifier even if the clinician closes the panel mid-generation. onReady fires when it is readable.
By role. A clinician creates, regenerates individual pages, edits text, and publishes. A client reads, and their reading position persists across sessions.
<StorybookLibrary />
<StorybookLibrary
filter={{ subject: { externalId: 'client_55130' } }}
onSelect={(sb) => setReading(sb.id)}
allowCreate
/>
<WorksheetCanvas /> and <WorksheetLibrary />
<WorksheetCanvas
worksheetId="ws_3Bn8kR"
mode="collaborative"
subject={{ externalId: 'client_55130' }}
onComplete={({ copyId }) => attachToChart(copyId)}
/>
| Prop | Type | Default | Notes |
|---|---|---|---|
mode |
'collaborative' | 'solo' | 'review' |
'solo' |
Collaborative requires a live session; review is read-only. |
page |
number |
1 |
Controlled paging, if you want your own navigation. |
By role. A clinician can annotate, add pages, and mark complete. A client fills in. In review neither can write.
<FormFill /> and <FormBuilder />
<FormFill
formId="frm_2Kd9Wp"
subject={{ externalId: 'client_55130' }}
onSubmit={({ responseId, score }) => writeToAssessmentHistory(responseId, score)}
/>
onSubmit carries the response identifier and, for scored instruments, the computed score. The response itself is fetched from your backend with the server SDK — it is never handed to a browser you do not control.
<FormBuilder /> is clinician-only and emits error with role_not_permitted on a client token.
Generation surfaces
Interfaces where a clinician creates something new. Each one is metered against the organisation's quota and each renders its own progress state, so you do not build a spinner.
<PlayStudio />
A clinician describes a game and gets a playable one.
<PlayStudio
subject={{ externalId: 'client_55130' }}
onGenerated={({ gameId, playUrl }) => addToPlayroom(gameId)}
/>
<ModelStudio />
A figure that is not in the sandtray library yet, generated from a description or a photograph and dropped straight into the tray.
<ModelStudio
source="text" // or 'image'
onGenerated={({ modelId }) => addToSandtray(modelId)}
/>
<ContentStudio />
All generation surfaces behind one tabbed interface — storybooks, worksheets, forms, games, models. The fastest way to expose the whole generative capability without building five entry points.
<ContentStudio subject={{ externalId: 'client_55130' }} />
Hooks
const {
ready, // boolean
role, // 'clinician' | 'patient'
subject, // { id, externalId }
session, // { id, externalId, status, participants }
surfaces, // Surface[] — what this token permits
open, // (surface: Surface) => void — clinician only
close, // (surface: Surface) => void — clinician only
end, // () => Promise<void> — clinician only
} = usePlaySpace()
const { artifacts, isLoading, error, refresh } =
useArtifacts({ subject: { externalId: 'client_55130' }, type: 'storybook' })
const { job, status, progress } = useGenerationJob(jobId)
const { usage } = useUsage() // quota consumption, for your own warning banner
Every hook is read-only with respect to clinical data and safe to call in any component under the provider.
Events
<PlaySpaceProvider onEvent={(e) => { ... }}>
| Event | Payload | Fires when |
|---|---|---|
ready |
{ role, surfaces } |
The surface mounted and is interactive. |
surface.opened |
{ surface } |
A surface became visible to this participant. |
surface.closed |
{ surface, durationMs } |
It went away. |
participant.joined |
{ role } |
Somebody else entered the session. |
participant.left |
{ role } |
Somebody left. |
artifact.created |
{ artifactId, type } |
Something now exists. May not be finished. |
artifact.ready |
{ artifactId, type } |
It is finished and usable. |
job.progress |
{ jobId, percent } |
Generation advanced. |
session.ended |
{ durationMs, surfacesUsed } |
The session completed. |
error |
PlaySpaceError |
See below. |
Events are at-most-once and carry no clinical content. Identifiers, counts, durations and enumerated values only. Never a name, never a page of story text, never a form answer.
Error taxonomy
A fixed set, so you can tell our failure from your own and from the browser's.
| Code | Meaning | What to do |
|---|---|---|
token_expired |
The token lapsed and renewal did not run. | Call fetchToken. Usually indicates a static token prop. |
token_invalid |
Malformed, revoked, or for another organisation. | Log the request_id and check the mint call. |
token_refresh_failed |
fetchToken threw three times. |
Your token endpoint is down. |
origin_not_allowed |
This page's origin is not on the token. | Add it to origins at mint time. |
capability_missing |
The token does not permit this surface. | Widen surfaces at mint, within the session's ceiling. |
entitlement_denied |
The organisation is not licensed for this surface. | A commercial question, not a code one. |
role_not_permitted |
A client token rendered a clinician-only component. | Branch on role from usePlaySpace(). |
surface_unavailable |
Temporarily down. | Retry; the surface renders its own notice. |
network_error |
Transport failure. | Retry; the surface recovers its own state on reconnect. |
render_blocked |
A browser extension or a storage restriction blocked the frame. | Not your bug. Surface the message rather than debugging it. |
Handlers must be idempotent. An error may fire more than once for the same underlying cause.
Sizing
The host sets width and, if it wants, a maximum height. The surface owns its own height.
<div style={{ width: '100%', maxHeight: '80vh', overflow: 'auto' }}>
<Sandtray />
</div>
Setting a fixed height on the container clips the surface. Setting height on the component is honoured but takes precedence over the surface's own layout, which is rarely what you want.
Minimums. The sandtray and dollhouse need 640 by 480 to be usable. Below it they render a message telling the person to enlarge the window rather than presenting an unplayable tray.
Server components
The provider is a client component. Everything under it is too. Render the token fetch on the server and pass the callback down:
// page.tsx — server component
export default async function Page({ params }) {
const session = await getSession(params.id)
return <PlayPanel sessionId={session.playspaceId} />
}
Do not fetch a token in a server component and pass its value as a prop. It will be serialised into the page payload, cached where you did not intend, and will have expired by the time anyone reads it.