Guide: Put a sandtray in your appointment view
A clinician opens an appointment in your application, clicks "Open sandtray", and a shared tray appears beside your clinical notes. The client, on their own device, is in the same tray.
End to end, this is one backend route and one component.
1. Map the people, once
Run this wherever you already create or update a clinician or a client. upsert is keyed on your identifier, so it is safe to call every time and there is nothing to reconcile.
// wherever your staff records change
await playspace.practitioners.upsert({
externalId: staff.id,
clinic: { externalId: staff.locationId },
firstName: staff.firstName,
lastName: staff.lastName,
email: staff.email,
country: staff.country, // some surfaces fail closed without it
role: staff.isAdmin ? 'administrator' : 'member',
})
// wherever your client records change
await playspace.patients.upsert({
externalId: client.id,
clinic: { externalId: client.locationId },
firstName: client.firstName,
lastName: client.lastName,
dateOfBirth: client.dateOfBirth,
practitioners: [{ externalId: client.primaryClinicianId }],
})
Send the minimum. A first initial and a last initial are a complete and acceptable name — enough for a clinician to pick the right person out of a list, which is all PlaySpace needs it for.
Link the caregiver for a paediatric client. This decides who receives a session link and who may later request an export.
await playspace.caregivers.link({
patient: { externalId: client.id },
caregiver: { externalId: client.guardianId },
relationship: 'parent',
receivesSessionLinks: true,
canRequestExport: true,
})
2. Open a session when the appointment starts
const session = await playspace.sessions.create({
appointment: { externalId: appointment.id, scheduledAt: appointment.startsAt },
clinician: { externalId: appointment.clinicianId },
participants: [{ patient: { externalId: appointment.clientId } }],
playroom: 'child-default',
surfaces: ['sandtray', 'dollhouse', 'whiteboard'],
})
await db.appointments.update(appointment.id, { playspaceSessionId: session.id })
surfaces is the ceiling. Nothing outside this list can be opened by anyone in the session, whatever the front end asks for.
Create it lazily. A session per appointment is cheap, but creating one for every appointment on a calendar that mostly will not use PlaySpace is wasted work. Create on first click and cache the identifier.
3. The token route
The only part that must live on your backend.
// app/api/playspace/token/route.ts
import { PlaySpace } from '@playspace/sdk'
const playspace = new PlaySpace()
export async function POST(request: Request) {
const user = await yourAuth(request)
const { appointmentId } = await request.json()
const appointment = await db.appointments.get(appointmentId)
// This line is the entire security boundary. PlaySpace can verify the subject
// belongs to your organisation; only you can verify this browser is that subject.
if (!canAccess(user, appointment)) {
return new Response('Forbidden', { status: 403 })
}
const isClinician = user.id === appointment.clinicianId
const token = await playspace.tokens.issue({
session: appointment.playspaceSessionId,
subject: { externalId: user.id },
role: isClinician ? 'clinician' : 'patient',
origins: [process.env.APP_ORIGIN!],
ttl: '15m',
})
return Response.json({ token: token.value })
}
Register APP_ORIGIN exactly. https://app.example.com — no trailing slash, no path, with the scheme. Origins are validated at mint, so a typo is a 422 naming the value rather than a blank frame in production.
4. Render
'use client'
import { PlaySpaceProvider, Sandtray, usePlaySpace } from '@playspace/react'
export function SandtrayPanel({ appointmentId }: { appointmentId: string }) {
const fetchToken = async () => {
const res = await fetch('/api/playspace/token', {
method: 'POST',
body: JSON.stringify({ appointmentId }),
})
if (!res.ok) throw new Error('token endpoint refused')
return (await res.json()).token
}
return (
<div style={{ width: '100%', maxHeight: '80vh', overflow: 'auto' }}>
<PlaySpaceProvider
fetchToken={fetchToken}
onEvent={(e) => {
if (e.type === 'artifact.ready') attachToChart(e.payload.artifactId)
}}
onError={(e) => reportToSentry(e.code, e.requestId)}
>
<Sandtray autosave />
</PlaySpaceProvider>
</div>
)
}
You are done. The clinician gets the figure library, sand tools, camera controls and saves. The client gets the tray and their cursor. You wrote no role branching, because the token carries the role.
5. Two things worth adding
Your own surface navigation. Read what the token permits and render your own tabs:
function SurfaceTabs() {
const { role, surfaces } = usePlaySpace()
if (role !== 'clinician') return null
return (
<nav>
{surfaces.map((s) => <button key={s} onClick={() => setActive(s)}>{s}</button>)}
</nav>
)
}
Hide entry points a clinic is not licensed for, so nobody clicks something that cannot work:
const licensed = await playspace.organisation.surfaces()
const embeddable = licensed.filter((s) => s.licensed && s.embeddable).map((s) => s.surface)
Troubleshooting
A blank frame. Almost always origin_not_allowed — check onError. The origin on the token must match the page's origin exactly.
The tray remounts and loses work. Something in your render is changing the frame source. Only token, surface and the surface's identity prop should change; a new object literal in theme on every render is the usual culprit, so hoist it out of the component or memoise it.
Works for the clinician, blank for the client. The client's token is being minted with role: 'clinician', or with a subject that is not a participant on the session. Check the branch in your token route.
role_not_permitted. A clinician-only component — <GameLibrary />, <FormBuilder /> — rendered under a patient token. Branch on role from usePlaySpace().
The tray is clipped. The container has a fixed height. Set width and maxHeight, and let the surface own its height.