Embed a sandtray

A sandtray session is two people, in two different places, moving figures around one tray and seeing each other do it. This guide puts that inside your own application: your navigation, your domain, your patient record open beside it.

It is a Level 3 integration: one PlaySpace surface, framed in your own layout, with your interface around it. The games surface reference is the shorter companion to this guide — the capabilities, the events and the traps, without the walkthrough. Everything below is code you can run.

What you are building

Three steps, and the second one is the interesting one.

  1. Your server mints a game session. One call returns two seats on one shared scene: one for the clinician, one for the client.
  2. You get each seat's token to the right browser. The clinician's is easy. The client's is not, and this is where an integration usually goes wrong.
  3. Each browser mounts a frame with its own seat's token. The two frames are then in the same tray.

Before you start

You need a delegated Partner API access token — one issued acting as a single practitioner, not an organisation-wide one. PlaySpace reads the acting clinician out of the token's own claim and refuses an organisation-wide token outright, because a session belongs to one practitioner and one of their patients. There is no practitioner parameter to get wrong, by design.

You also need the practitioners:write scope. A read-only credential can list a caseload but cannot start a session, and the API answers that with a real 403 rather than an empty result.

Finally, you need a PlaySpace patient identifier, created earlier through POST /v1/partner/patients. The patient must be on the acting clinician's own roster; one who is not answers 404.

Step 1 — mint the session on your server

Install the SDK and create a client. The SDK never fetches or stores your credential — you hand it one, so whatever you already do for token acquisition keeps working.

import { createEmbedClient } from '@playspace-health/embed/server'

const playspace = createEmbedClient({
  baseUrl: 'https://agentic-ps.playspace.health',
  getAccessToken: () => getMyDelegatedPartnerToken(),
})

Then open the session:

const session = await playspace.createGameSession({
  gameType: 'sandtray',                        // or 'dollhouse'
  patientId,                                   // a PlaySpace Partner API patient id
  origins: ['https://app.yourclinic.com'],     // where these frames are allowed to be embedded
})

That is one POST /v1/partner/game-sessions. The response carries game_session_id, game_type, and a practitioner and a patient object, each with its own embed_url, token and expires_at.

origins is not decoration. It is the list of pages allowed to frame this session, and it is checked when the token is minted rather than when the frame loads — so a mistake here is a clear failure at mint time instead of a blank iframe nobody can explain later.

Step 2 — get each seat to the right browser

The clinician's seat is straightforward: they are signed in to your application, so hand it to the page they are already on.

The client's seat is the part worth slowing down for. A client is not a user of your application. They have no account, no session, and often nothing but a link sent to them a few minutes before the appointment. So the obvious approach — putting the client's token in that link — is the wrong one. A token in a link is written into browser history, sent in Referer headers, and captured by anything that logs URLs, and this one grants live access to a clinical session.

Keep the client's token on your server and hand out an opaque handle instead. Store the handle against the seat on your side; the link the clinician sends carries only the handle, and the client's page exchanges it for a fresh seat when they actually arrive.

Step 3 — mount the frame

Each browser mounts its own seat:

import { GameEmbed } from '@playspace-health/embed/react'

<GameEmbed
  baseUrl={PLAYSPACE_ORIGIN}
  gameType="sandtray"
  token={session.practitioner.token}
  fetchToken={refreshThisSeat}
  onEvent={(event) => console.log(event.type)}
/>

The clinician's frame drives the session — choosing or creating a save, saving, loading. The client's frame follows automatically. Neither needs to be told which role it is: the token already says.

The thing that will bite you: sessions outlive tokens

A seat token lives fifteen minutes. A therapy session runs an hour. If you mount a frame and walk away, it expires under a clinician mid-session.

That is what fetchToken is for, and there is one rule about how you implement it: re-mint for the same session. Call createGameSession again with the original response's game_session_id, and hand each frame its own seat's new token.

async function refreshThisSeat(): Promise<string> {
  const renewed = await playspace.createGameSession({
    gameType: session.gameType,
    patientId,
    origins: ['https://app.yourclinic.com'],
    gameSessionId: session.game_session_id,   // the same session, not a new one
  })
  return renewed.practitioner.token
}

Omit gameSessionId and you have started a second, empty tray while the first one is still open. Hand a frame the other seat's token and it is refused rather than silently re-scoped — a token whose practitioner, patient, role or session key differs from the frame's is rejected outright.

Reading back what they made

Scene contents never enter your page. They are rendered inside PlaySpace's own document, in the frame, which is why nothing in your DOM ever holds a client's play.

What you get instead are events — identifiers only, never scene content and never names:

  • game.session_started
  • game.save_created
  • game.saved
  • game.save_loaded

Use them to know when to refresh your own view. Then read the saves server-side:

const saves = await playspace.listGameSaves({ limit: 20 })
const save = await playspace.getGameSave(saveId)
await playspace.deleteGameSave(saveId)   // soft delete

GET /v1/partner/game-saves and its siblings are delegated reads, scoped to the acting clinician, and GET /v1/partner/game-saves/{id}/thumbnail returns image bytes you can point an <img> at directly.

Where to go next

  • @playspace-health/embed — the full package documentation, including every other surface it can frame.
  • Using the API from other languages — the same sandtray from a server that is not running Node, as a generated client and a plain iframe.
  • Endpoint summary — every operation the Partner API serves.
  • Authentication — how a delegated token differs from an organisation-wide one, and how to get both.
  • Errors — what a 403 or a 404 from these endpoints actually means.