@playspace/react
PlaySpace therapy surfaces as React components.
npm install @playspace/react
React 18 and 19. Every component is a client component. Built on @playspace/embed, which you can use directly on any other framework.
The whole integration
'use client'
import { PlaySpaceProvider, Sandtray } from '@playspace/react'
export function PlayPanel({ sessionId }: { sessionId: string }) {
const fetchToken = async () => {
const res = await fetch('/api/playspace/token', {
method: 'POST',
body: JSON.stringify({ sessionId }),
})
return (await res.json()).token
}
return (
<PlaySpaceProvider fetchToken={fetchToken}>
<Sandtray />
</PlaySpaceProvider>
)
}
Open that as a clinician in one browser and as a client in another, and both people are in the same tray. Figures move under both cursors. It autosaves.
You wrote no real-time synchronisation, no presence layer, and no role branching.
The one idea worth understanding
The token carries the role, so components do not.
<Sandtray /> takes no role prop. The session token minted for the person at the screen says clinician or patient, and every component renders the corresponding side.
A clinician gets the figure library, the tool palette, the navigation rail, saves and session controls. A client gets the play surface and their cursor. Neither sees the other's interface.
There is no flag to pass, and therefore no flag to get wrong. You cannot accidentally render a clinician's controls to a seven-year-old.
Three composition levels
Pick by how much of the experience you want to own.
Level 1 — the whole session.
<PlaySpaceProvider fetchToken={fetchToken}>
<PlayroomSession />
</PlaySpaceProvider>
Level 2 — individual surfaces. You own navigation and layout.
<PlaySpaceProvider fetchToken={fetchToken}>
{tab === 'sand' && <Sandtray />}
{tab === 'house' && <Dollhouse />}
{tab === 'draw' && <Whiteboard />}
</PlaySpaceProvider>
Level 3 — headless. You own everything visual.
const { role, session, surfaces, open } = usePlaySpace()
const { artifacts } = useArtifacts({ subject: { externalId: 'client_55130' } })
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.
Components
Live play — synchronised between participants in real time.
| Component | Notes |
|---|---|
<Sandtray /> |
The flagship. Figure library, sand tools, saves. Needs 640×480 |
<Dollhouse /> |
Rooms, furniture, family figures. layout, rooms |
<Whiteboard /> |
background, tools. A clinician can restrict a client's tools live |
<GameLibrary /> |
The licensed catalog, filterable. Clinician-only |
<Game slug="…" /> |
One game |
<Playroom /> |
Every live surface in one environment, clinician steering |
<PlayroomSession /> |
Playroom plus video, presence and session controls |
Content — no live session required, so these work anywhere in your product.
| Component | Notes |
|---|---|
<StorybookCreator /> |
onCreated fires before any page is written |
<StorybookReader /> |
Reading position persists across sessions |
<StorybookLibrary /> |
Browse and create |
<WorksheetCanvas /> |
mode: collaborative, solo, review |
<WorksheetLibrary /> |
|
<FormFill /> |
onSubmit carries the response identifier and score |
<FormBuilder /> |
Clinician-only |
Generation — each renders its own progress state, so you build no spinner.
| Component | Notes |
|---|---|
<PlayStudio /> |
A clinician describes a game and gets a playable one |
<ModelStudio /> |
A three-dimensional figure from text or a photograph |
<ContentStudio /> |
Every generation surface behind one tabbed interface |
Full props in index.d.ts and the React SDK guide.
Token renewal, which you do not write
fetchToken is called on mount and again at eighty percent of the current token's lifetime. The new token is swapped in underneath a live surface — a mid-interaction sandtray is never remounted, and no work is lost.
This is why the default token lifetime is fifteen minutes rather than eight hours. A clinician can leave the panel open across a lunch break and come back to a working session; a stolen token is useless within the quarter hour.
If fetchToken throws, the provider retries with backoff and emits token_refresh_failed after three attempts.
Use token instead of fetchToken only for something short-lived. A static token does not renew, and the failure mode is a clinician stranded mid-session.
Hooks
const { ready, role, subject, session, surfaces, open, close, end } = usePlaySpace()
const { artifacts, isLoading, error, refresh } = useArtifacts({ subject })
const { job, isLoading } = useGenerationJob(jobId)
const { usage } = useUsage()
usePlaySpace().role is how you branch your own interface — for example, rendering your navigation for a clinician and nothing for a client:
const { role, surfaces } = usePlaySpace()
return (
<>
{role === 'clinician' && <YourSurfaceNav surfaces={surfaces} />}
<Sandtray />
</>
)
useUsage() exists so you can warn at eighty percent of quota in your own chrome. A clinician who discovers an exhausted allowance mid-session with a seven-year-old will contact you, not us.
Events
<PlaySpaceProvider
fetchToken={fetchToken}
onEvent={(event) => {
if (event.type === 'artifact.ready') attachToChart(event.payload.artifactId)
if (event.type === 'session.ended') recordDuration(event.payload.durationMs)
}}
/>
ready, surface.opened, surface.closed, participant.joined, participant.left, artifact.created, artifact.ready, job.progress, session.ended.
At-most-once, and never carrying clinical content — identifiers, counts, durations and enumerated values only. Never a name, a page of story text or a form answer.
Errors
A fixed taxonomy, so you can tell our failure from your own and from the browser's.
<PlaySpaceProvider
fetchToken={fetchToken}
onError={(error) => {
switch (error.code) {
case 'token_refresh_failed': alertOncall('playspace token endpoint down'); break
case 'render_blocked': showExtensionNotice(); break
default: log(error.code, error.requestId)
}
}}
/>
token_expired, token_invalid, token_refresh_failed, origin_not_allowed, capability_missing, entitlement_denied, role_not_permitted, surface_unavailable, network_error, render_blocked.
Handlers must be idempotent — an error may fire more than once for one underlying cause.
render_blocked means a browser extension or a third-party storage restriction blocked the frame. It is not your bug and not ours; surface the message rather than debugging it.
Sizing
The host sets width and, optionally, a maximum height. The surface owns its own height.
<div style={{ width: '100%', maxHeight: '80vh', overflow: 'auto' }}>
<Sandtray />
</div>
A fixed container height clips. Setting height on the component is honoured but overrides the surface's own layout, which is rarely what you want.
The sandtray and dollhouse need 640 by 480. Below that they render a message asking the person to enlarge the window rather than an unplayable tray.
Server components
The provider is a client component, and so is everything under it. Render the identifier on the server and pass the token 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 gets serialised into the page payload, cached where you did not intend, and will have expired by the time anyone reads it.
Theming
<PlaySpaceProvider theme={{ accent: '#2563eb', radius: 'lg', font: 'inherit' }} />
Chrome, controls and focus rings adopt your accent, radius and font. The play content itself — the tray, the figures, the illustrations — is not themeable, because it is designed for children and its visual language is part of the clinical product rather than decoration.
colorScheme accepts light, dark or system and defaults to system.
Not on React?
@playspace/embed is the transport this package is built on. Same surfaces, same events, same error taxonomy, no component layer.
import { mount } from '@playspace/embed'
const surface = mount(document.querySelector('#play'), {
surface: 'sandtray',
fetchToken: () => fetch('/api/playspace/token').then((r) => r.text()),
})
Vue and Svelte wrappers are planned and will be thin — this package is roughly four hundred lines over the transport, most of it the renewal contract.
Documentation
- React SDK guide — every component, every prop, per-role behaviour
- Type declarations
- Server SDK — where session tokens come from
- Authentication — the token endpoint you write
- Changelog