@playspace-health/embed
Embed PlaySpace surfaces — storybooks, forms, worksheets and games — in your application, and build the content a clinician brings to a session.
The package has two halves and they must stay on their own sides of the wire:
@playspace-health/embed/server— exchanges your Partner API credential for a short-lived embed token, mints game sessions, reads a clinician's storybooks, forms, worksheets and game saves, and manages their playrooms and toolkits. Server only.@playspace-health/embed/@playspace-health/embed/react— mounts the surface in an iframe and reports lifecycle events back to your page. Browser.
How much of PlaySpace you frame is a ladder of four integration levels, described on the PlaySpace partner documentation site: the whole workspace in one frame, that workspace plus your own host controls, one surface at a time, and the Partner API with no frame at all. This document is the reference for all of them.
Why a token exchange
Your Partner API token carries your organization's full scope set and is
long-lived. An iframe URL is exposed to browser history, Referer headers, and
anything that logs URLs. So the powerful credential stays on your server, and
what reaches the browser is a token that can only do what you asked for, for as
long as you asked, framed only by the origins you named.
Install
npm install @playspace-health/embed
The package is pre-release: every version is 0.x, and a minor version may change the API until 1.0. Your lockfile pins the version you installed, as with any dependency. The import paths in this document will not change.
How it is published
.github/workflows/prod-embed-sdk.yml publishes to the public npm registry
under the latest dist-tag when an embed-v*.*.* tag is pushed. There is no
separate pre-release dist-tag to remember — the 0.x version number IS the
pre-release signal, so npm install @playspace-health/embed is the correct
install command for a partner today.
Working in this repository: dist/ is gitignored, so the package must be BUILT
(npm run build in packages/playspace-embed/) before any consumer in the
monorepo builds. A consumer failing to resolve @playspace-health/embed is
almost always an unbuilt dist/, not a broken import path.
The package does nothing without a Partner API credential. PlaySpace issues that at onboarding, after review — there is no self-serve sign-up for any part of the Partner Platform — and the same credential gates every call the SDK makes.
Without npm
The browser half is dependency-free ES modules, so a server-rendered application in any language can load it from a CDN that mirrors npm:
<script type="module">
import { createStorybookEmbed } from 'https://cdn.jsdelivr.net/npm/@playspace-health/embed/dist/index.js'
</script>
A URL without a version follows the newest release. In production, put the
version you tested into the URL (embed@0.1.2/dist/index.js) so an upgrade is
your decision.
The server half is one HTTP call, POST /v1/partner/embed-tokens, that any HTTP
client can make. Using the API from other languages
covers generating a client for it and hosting a surface with no JavaScript at all.
Server: mint a token
The SDK never fetches or stores your credential — you hand it one. Whatever you already do (client-credentials, a cached token, a vault lookup) keeps working.
import { createEmbedClient } from '@playspace-health/embed/server'
const playspace = createEmbedClient({
baseUrl: 'https://agentic-ps.playspace.health',
getAccessToken: () => getMyPartnerToken(), // must be DELEGATED
})
const embed = await playspace.mintEmbedToken({
capabilities: ['storybook:read', 'storybook:create'],
origins: ['https://app.yourclinic.com'],
ttlSeconds: 900,
})
The token must be delegated — issued acting as one practitioner. PlaySpace reads the acting practitioner from the token's claim and refuses an org-wide one, because an embed is minted for someone: the storybooks created in it are owned by that clinician.
mintEmbedToken returns practitioner_id, so you can record attribution
against your own records rather than trusting what you sent.
Minting for a form
A form the patient fills is a write to the clinical record, so the token has to name the patient it is written for:
const embed = await playspace.mintEmbedToken({
capabilities: ['form:read', 'form:submit'],
origins: ['https://app.yourclinic.com'],
patientId: 'a1b2c3d4-...', // required for form:submit
ttlSeconds: 900,
})
patientId is a PlaySpace Partner API patient id. It is checked at mint time
against both your organization and the acting clinician's own roster — a patient
who is neither is a 403 here rather than a token that fails later.
The SDK takes camelCase options and posts the API's snake_case body, so
patientId goes on the wire as patient_id and ttlSeconds as ttl_seconds;
if you call the endpoint over raw HTTP instead, send the snake_case names,
because an unknown field is rejected rather than ignored. The one option whose
wire name is not a straight transliteration is ttlSeconds on
createGameSession, which posts token_ttl_seconds.
This is the only place a submission's subject is ever set. The framed surface
has no patient picker and the browser cannot override it, which is why omitting
patientId while asking for form:submit is a 422 instead of a token that
would record answers attached to nobody. Ask for form:read alone and the frame
renders read-only.
Authoring is a separate grant and needs no patient. form:create opens the
form builder (mode="form-create"). A form is a template reused across a
caseload, so it has no subject and no patientId is required — ask for it
alongside form:read / form:submit in one mint if the clinician should be able
to both author and fill, or on its own for an authoring-only surface.
form:create is one of the four TEMPLATE capabilities, and asking for any of
them changes what /embed/forms renders: that url is the fill picker for a token
without one and the whole forms workspace for a token with one. If you mean to
keep the picker, do not mint them. See form-workspace under "Surfaces".
Browser: render the surface
import { StorybookEmbed } from '@playspace-health/embed/react'
<div style={{ height: 900 }}>
<StorybookEmbed
baseUrl="https://agentic-ps.playspace.health"
fetchToken={() => mintTokenOnMyServer()} // see "Sessions longer than a token"
mode="create"
onEvent={(event) => {
if (event.type === 'storybook.ready') {
recordStorybook(event.payload.storybookId)
}
}}
/>
</div>
The form surface is the same shape:
import { FormEmbed } from '@playspace-health/embed/react'
<div style={{ height: 900 }}>
<FormEmbed
baseUrl="https://agentic-ps.playspace.health"
token={embed.token}
mode="fill"
formId={formId}
onEvent={(event) => {
if (event.type === 'form.submitted' && event.payload.status === 'completed') {
recordCompletion(event.payload.formId, event.payload.submissionId)
}
}}
/>
</div>
Leave formId off and use mode="list" to let the clinician pick from their own
published forms inside the frame; you still get form.opened telling you which
one they chose. mode="form-workspace" frames the whole forms library at that
same url instead, for a clinician who authors as well as fills — the capabilities
on the token are what separate the two, and "Surfaces" below has the rule.
mode="form-create" frames the form builder — the same one the PlaySpace
dashboard uses. It takes no formId (there is nothing to open yet) and no
patientId, and reports the saved form through form.created:
<FormEmbed
baseUrl="https://agentic-ps.playspace.health"
token={embed.token}
mode="form-create"
onEvent={(event) => {
if (event.type === 'form.created') {
// Only a published form appears in listForms().
if (event.payload.status === 'published') refreshMyFormList()
}
}}
/>
The frame does not navigate after a save — it shows its own confirmation and
leaves the next move to you. That is deliberate: the form's own page is the fill
surface and needs form:submit plus a patient-bound token, so auto-navigating
there would strand an authoring-only token on a 403.
Not using React:
import { createFormEmbed, createStorybookEmbed } from '@playspace-health/embed'
const handle = createStorybookEmbed(container, {
baseUrl,
fetchToken: () => mintTokenOnMyServer(),
mode: 'create',
})
const forms = createFormEmbed(container, {
baseUrl,
fetchToken: () => mintTokenOnMyServer(),
mode: 'fill',
formId,
})
// later
handle.destroy()
forms.destroy()
Sessions longer than a token
Embed tokens are deliberately short-lived (15 minutes by default, one hour
maximum) because they ride in an iframe URL. A therapy session is longer than
that, so don't stretch the TTL — pass fetchToken instead of token:
fetchTokenis called once to mount the frame, then again shortly before each expiry. The SDK pushes the fresh token into the running frame; the iframe never reloads and in-progress work is never lost.- The callback should hit your own server, which calls
POST /v1/partner/embed-tokens(e.g. viamintEmbedToken) and returns the token string. Your Partner credential still never reaches the browser. - A one-shot
tokenremains supported and is fine for a surface guaranteed to outlive its content (a quick reader).fetchToken: () => Promise.resolve(token)is the mechanical migration. - A changed
tokenprop remounts the frame.tokenis part of what the SDK rebuilds the iframesrcfrom, so handing down a freshly minted string throws away whatever the frame was showing — a half-answered form, or a confirmation screen it rendered a moment earlier. This bites hosts that re-mint as a side effect of their own data refresh (arouter.refresh()fired from an embed event, for instance). Either usefetchToken, which pushes the re-mint into the running frame without touching thesrc, or pin the token in your own state and replace it only when its authority genuinely changes — a different patient, different capabilities. A re-mint that grants exactly what the old one did is never worth a remount. - If a mint fails, the SDK emits an
errorevent and retries while the current token is still valid.
Ending the session (logout)
Unmounting the iframe does not invalidate the token — it stays a live bearer credential until it expires. When your user logs out, call:
await handle.logout() // revokes every token this embed held, then destroys the frame
In React, grab the handle with the onHandle prop:
const embedHandle = useRef<StorybookEmbedHandle | null>(null)
<StorybookEmbed onHandle={(handle) => { embedHandle.current = handle }} ... />
// on host logout:
await embedHandle.current?.logout()
Revocation is server-side (POST /api/embed/session/logout, authenticated by
the token itself): a revoked token is refused by every embed endpoint from the
next request on.
Camera, microphone and screen share
The SDK sets
allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
on the iframe (plus the legacy allowfullscreen attribute for older Safari),
which is required by the Permissions-Policy spec for any cross-origin frame to
use these features at all — without it the browser throws NotAllowedError
before any permission prompt appears, which looks exactly like the user
clicking "Block".
The list is the union a live session needs, not just a media capture:
camera and microphone for the video pane, display-capture for screen share,
fullscreen and picture-in-picture for the video controls, and autoplay so
the remote stream starts without a user gesture. The workspace embed opens a
session inside its own content pane, so the session document is one frame
below the one you place — and a feature dropped at your level is denied for
every level below it.
Two things remain on your side:
-
If your page (or a proxy/CDN in front of it) sends a
Permissions-Policyheader, it must allow the features for the PlaySpace origin, or the header wins over the iframe attribute:Permissions-Policy: camera=(self "https://agentic-ps.playspace.health"), microphone=(self "https://agentic-ps.playspace.health"), display-capture=(self "https://agentic-ps.playspace.health"), fullscreen=(self "https://agentic-ps.playspace.health"), autoplay=(self "https://agentic-ps.playspace.health"), picture-in-picture=(self "https://agentic-ps.playspace.health")A page with no
Permissions-Policyheader needs no change — the iframeallowattribute alone delegates. -
The browser still prompts your user on the first capture, attributed to your page. Nothing to build, but worth a line in your own support docs.
To opt a surface out, pass iframeAttributes: { allow: '' } (or your own
allowlist) — a host-supplied allow is never overridden.
Events
| Event | When | Payload |
|---|---|---|
ready |
the surface is interactive | { mode } |
storybook.created |
a generation was accepted | { storybookId } |
storybook.ready |
the book is readable | { storybookId, title, pageCount } |
storybook.saved |
one confirmed edit in storybook-workspace: a page's text, an accepted illustration, an added page or a new page order |
{ storybookId, pageCount } |
storybook.deleted |
the clinician deleted the book from inside storybook-workspace |
{ storybookId } |
form.opened |
a form was opened for filling | { formId } |
form.created |
a form was authored in the frame | { formId, fieldCount, status } |
form.saved |
an existing form was edited in the frame | { formId, fieldCount, status } |
form.deleted |
the clinician deleted the form | { formId } |
form.shelf_changed |
a form was placed on, or taken off, a playroom or toolkit shelf | { formId, containerType, containerId, attached } |
form.submitted |
a submission row exists | { formId, submissionId, status } |
worksheet.created |
a new worksheet exists (from a PDF, a blank create, or a Duplicate) | { worksheetId, pageCount } |
worksheet.ready |
the worksheet is readable | { worksheetId, pageCount } |
worksheet.saved |
an edit reached the server | { worksheetId, revision } |
worksheet.deleted |
the clinician deleted the worksheet | { worksheetId } |
game.session_started |
the multiplayer scene is live | { gameSessionId, gameType } |
game.save_created |
a new save row exists | { saveId } |
game.saved |
the active save was persisted | { saveId } |
game.save_loaded |
a saved scene was loaded | { saveId } |
appointment.created |
the clinician booked an appointment in the framed calendar | { partnerAppointmentId, partnerPatientId, startAt, endAt, timezone, sessionType, status } |
appointment.updated |
the clinician moved one, or corrected the zone it is booked in | { partnerAppointmentId, partnerPatientId, startAt, endAt, timezone, sessionType, status } |
appointment.cancelled |
the clinician cancelled one | { partnerAppointmentId, partnerPatientId, startAt, endAt, timezone, sessionType, status } |
session.launch_requested |
the clinician asked you to open a session | { partnerAppointmentId, launchAttemptId } |
session.in_person_requested |
deprecated — never sent. The workspace starts the in-person session itself | { partnerPatientId, requestAttemptId } |
form.fill_requested |
the clinician asked you to open a form for one patient | { formId, partnerPatientId, requestAttemptId } |
form.send_requested |
the clinician asked you to have PlaySpace email a form to one patient | { formId, partnerPatientId, requestAttemptId } |
storybook.shared |
the clinician emailed the patient a link to the book | { storybookId, shareId, expiresAt } |
worksheet.shared |
the clinician emailed the patient a link to the worksheet | { worksheetId, shareId, expiresAt } |
error |
something worth surfacing | { message, code?, severity?, retryable?, requestId? } |
storybook.created fires as soon as the row exists — before any page is
written — so you can record the id even if the user closes the frame while it is
still illustrating.
form.created fires as soon as the row exists, so you can record the id even if
the clinician closes the frame straight afterwards. Its status is the
authoring state ('draft' or 'published'): only a published form can be
opened for filling and only a published form appears in listForms(), so check
it rather than assuming a new form is usable. The form's name is deliberately not
on the channel — it is clinician free text.
form.saved is the EDIT's event and carries the same payload shape as
form.created. They are two events on purpose: if you keep your own index of a
clinician's instruments, a rename must not be counted as a new one. It fires once
per successful save, so a clinician who edits the same form three times produces
three form.saved events for one formId. It needs form:write on the token,
which is separate from form:create — a seat can be minted to author new forms
without being able to rewrite the ones a clinician already relies on, or the
reverse.
form.deleted is terminal for that form: the row is soft-deleted, so the form
stops appearing in listForms(), stops being fillable and comes off every room
it sat on, while responses already recorded against it stay readable. It carries
the id alone and needs form:delete.
form.submitted carries status, which is 'in_progress' or 'completed'.
Check it: a saved draft fires this event too, and treating any submission as a
finished instrument will mark work complete that nobody finished.
Answers never travel on this channel, and no PlaySpace surface you can reach returns them either. A submission is patient data; it stays inside PlaySpace where the clinician reads it.
worksheet.created fires the moment the worksheet exists and before the frame
navigates to the viewer, so a host that records the id there does not also have
to wait for worksheet.ready. Worksheet payloads carry ids and counts only:
titles come from the clinician's own filename or the PDF's metadata and are
treated as patient-adjacent, the same as model-generated storybook titles.
worksheet.saved is the one event that repeats: the editor saves continuously, so
one is delivered per confirmed save with revision counting up from 1 within a
frame load. Use it as the signal that the clinician's work is on the server —
revision > 0 means at least one save landed. It carries no content, only the id
and the counter.
worksheet.deleted is terminal for that frame: the editor becomes a "worksheet
deleted" panel and nothing further arrives for that worksheet. Treat it as your
cue to close the frame and drop the id from your own list — the id will not
resolve again, and re-opening it renders an error rather than an editor.
The three appointment.* events are notifications, and they are the only
place PlaySpace tells you about an appointment your own system did not create.
Every other appointment PlaySpace holds arrived through
POST /v1/partner/appointments from your server, so the two books agree by
construction; these did not — the clinician booked, moved or cancelled inside
the framed calendar. Answer by reading the appointment back with
GET /v1/partner/appointments/{id} and writing it into your own records. Treat
the event as a signal, never as the record: it carries the window and the state,
not the appointment.
They need the appointment:write capability on the token that opened the
workspace. A calendar minted appointment:read alone is read-only, offers no
booking control, and emits none of the three.
appointment.cancelled is separate from appointment.updated on purpose: a host
that folded them together would leave a cancelled session on its own schedule.
partnerPatientId is null for a client PlaySpace holds no record of yours for
— a client the clinician created in PlaySpace has no id of yours to name — so a
booking against one of those still reports the appointment and leaves the patient
unresolved on your side. There is no video field on any of the three and there
will not be: an appointment booked in the frame always uses the organization's
platform video provider, and a join link is a live credential you mint on your
own server with POST /v1/partner/appointments/{id}/session-links. An
appointment carrying video links you supplied can be cancelled in the frame
but never moved there — the supplied-video contract requires both meeting URLs
to be resubmitted whenever the window moves, so PlaySpace refuses that reschedule
and tells the clinician to move it in your system.
session.launch_requested is a request rather than a
notification. The clinician pressed "Open session" on an appointment in the
framed calendar; PlaySpace deliberately did not open it. Answer by calling
POST /v1/partner/appointments/{id}/session-links from your own server with your
own credential and opening the returned link in your own surface. Ignoring it is
a valid choice — nothing in the frame changes either way, and no error follows.
It is shaped that way because a session link is a live join credential, and it is
minted where your credential already is: your server. Nothing about the session
travels back through the frame. partnerAppointmentId is the id you gave the
appointment (partner_appointments.id) — the same id every
/v1/partner/appointments/... route takes; PlaySpace's own internal appointment
id is never exposed and would not be accepted. The control appears only on
appointments you can actually act on, so an appointment you promoted offers it
and an unpromoted one does not. An in-person appointment offers it too, labelled
Start in-person tools: the session it opens is the playroom, the whiteboard
and the worksheets, with no video pane and no waiting room. The payload is the
same on both.
session.in_person_requested is declared but never sent, and you need no
handler for it. The workspace starts an in-person session itself, in its own
content pane, and creates no appointment: booking for a session that is already
happening would put a block nobody scheduled into your book, to be cancelled
afterwards. The
member stays declared so existing handlers keep compiling; delete yours at your
convenience.
launchAttemptId names the press, not the appointment, and it is how you
tell a repeated delivery from a repeated request. A single press always carries
one launchAttemptId, however many times its message reaches you; pressing the
control again — on the same appointment, after opening another one — is a new
press with a new launchAttemptId, and the clinician means it. Answer each
distinct launchAttemptId once. It is unique within one frame load, so use it
to collapse duplicates in the moment rather than as an idempotency key you
store.
form.fill_requested is request-shaped for the same reason. A fill token must be bound to a named patient at mint time — the mint refuses to issue one without a patient and the verifier refuses to accept one without it, because a submission is clinical content recorded against a person, and a token that could choose the person afterwards could write anyone's answers anywhere. The workspace's own token is bound to a CLINICIAN, so the frame has nothing to fill a form with and no way to obtain one. It therefore emits the event and stops: you mint the fill token from your own server and render the fill surface yourself. partnerPatientId is the id you gave the patient (partner_patients.id), and requestAttemptId names the press exactly as launchAttemptId does — unique within one frame load, for collapsing duplicate deliveries, never an idempotency key you store.
form.send_requested is request-shaped for the same reason again, and it is the one where PlaySpace does the work. The clinician pressed "Send to client" on a form in the framed workspace, asking PlaySpace to email that patient a link to fill it in. form:send is patient-bound exactly as form:submit is — the send reads that person's stored address and records a pending response against them — so the workspace's clinician token cannot buy it. Answer by minting an embed token carrying form:send for that patient from your own server, then calling POST /api/embed/forms/{id}/send with it once. The request body carries no fields: the recipient is the token's, and the sender is the token's.
PlaySpace mints the fill credential, builds the link and hands it to the email provider. You never receive the link, the address, or the credential — what comes back is the pending response's id, a status, and, on a refusal, a problem type whose last segment is a stable slug you can branch on. A form you cannot reach and a patient you cannot reach answer the same 404, deliberately, so the status cannot be used to discover which patients exist; a patient on the roster with no address on file is the distinct recipient-address-missing. Pressing again reuses the outstanding pending response rather than recording a second, so a resend is another email against one row.
The payload is { formId, partnerPatientId, requestAttemptId } — the same three ids the fill request carries, and no address. requestAttemptId is counted separately from the fill control's, so a fill press and a send press on the same form never share one.
Delivery is at-most-once per event per frame load, and every message is
verified to come from the PlaySpace origin and from that specific iframe
before it reaches your handler. Unknown event types are dropped, so a newer
PlaySpace release cannot break a handler written today. "Per event" means per
subject: worksheet.saved at revision 3 and revision 4 are different events, so
neither is suppressed as a duplicate of the other, and
session.launch_requested for two presses of one appointment are likewise two
events, told apart by launchAttemptId. game.saved is not deduped at all —
every persist of the same save is a distinct event — and neither is
form.saved: it is posted once per confirmed save, so two edits that happen to
leave the same field count and status behind are still two events.
Listing what a clinician has
const { data, nextCursor, hasMore } = await playspace.listStorybooks({ limit: 25 })
const forms = await playspace.listForms({ limit: 25 })
const worksheets = await playspace.listWorksheets({ limit: 25 })
Storybook titles are model-generated from the clinician's prompt and can echo details of the child a story was written for. Treat them as patient-adjacent: fine to render in a clinical UI, not fine in logs or analytics. Worksheet listing covers the clinician's library only — copies a client has annotated are clinical content and are never returned.
listForms returns the clinician's published forms only — a draft is still
being authored, and answers recorded against one would not mean anything. Each
row carries embeddable and unsupported_field_types: a form using a field type
the embedded surface does not render yet comes back embeddable: false, and
framing it gets you a 422 rather than a half-rendered instrument. They are
listed rather than hidden so you can say why one is unavailable instead of
silently dropping it from a clinician's own library.
Each row also carries shelf_ready, and it answers a different question than
"is it in this list". Being listed means the form is finished and fillable
through the embed. shelf_ready means the clinician additionally shared it to
their playrooms and toolkits, which is what makes it eligible to open from
inside a live PlaySpace session — a separate step that is off by default, so
expect false for most of a library. Eligible is not placed: placement is a
separate membership row, and unlike worksheets and storybooks a form cannot be
attached through the API yet, so it is done in PlaySpace. Read it if your
product coordinates with what happens in session; ignore it if you only ever
frame forms yourself.
Sending a storybook or worksheet to a patient
Two ways, same effect: PlaySpace emails the patient a secure, time-limited link to read the book or view the worksheet, exactly as a clinician's own "Send to client" does inside PlaySpace.
From your server, when your own workflow decides it is time:
const share = await playspace.shareStorybook(storybookId, {
patientId, // your PlaySpace patient id
expirySeconds: 7 * 86400, // optional, 1 hour .. 30 days, default 7 days
message: 'We read this one together today.', // optional, up to 500 chars
})
// share.share_id, share.storybook_id, share.patient_id, share.expires_at
await playspace.shareWorksheet(worksheetId, { patientId })
From inside the frame, when the clinician should decide: mint the
reader or worksheet token with storybook:share / worksheet:share and
a patientId, and the surface shows a "Send to client" button. The frame
sends to that one patient and nobody else — there is no recipient picker,
because the recipient was decided on your server when you minted the token.
The host hears storybook.shared / worksheet.shared once per send.
Three things hold on both paths. The address comes off the patient's record —
neither call accepts an email. The patient must be on the acting clinician's
current roster, or the send is refused (422, and a patient on a colleague's
roster reads exactly like one that does not exist). And nothing you get back
contains the link or the address: the link is a live credential and only the
patient ever holds it. Re-sending extends the existing link rather than
issuing a second one. A storybook must be ready and a worksheet must have
pages, or the send is a 422 rather than a dead link in a child's inbox.
Session spaces: playrooms and toolkits
A playroom is the themed space a session runs in — a room theme, a colour palette, and which session items are available inside it. A toolkit is the same set of items, grouped and named, with no theming of its own.
const playroom = await playspace.createPlayroom({
title: 'Calm corner',
room_type: 'child',
color_palette: 'color2',
session_items: ['sand_tray', 'whiteboard'],
published: true,
})
const { data, nextCursor, hasMore } = await playspace.listPlayrooms({ limit: 25 })
await playspace.updatePlayroom(playroom.id, { published: false })
listToolkits / getToolkit / createToolkit / updateToolkit mirror these,
minus room_type and color_palette — a toolkit has no theme, and sending one
is a 422 rather than a silently dropped field.
Four things to know:
- The token must be delegated here too. A session space belongs to one
clinician, so an org-wide token is refused with a
403. Someone else's space answers404, never403. thumbnail_urlis derived, not uploaded. It followsroom_type+color_palette, so changing the theme changes the artwork.- There is no delete. Retire a space with
published: false; anything that already references it keeps working. - A
PATCHarray field replaces, it does not merge. Send the fullsession_itemsyou want, not the delta.
Titles and descriptions are the clinician's own words. They are not patient records, but they are not log material either.
Things that will bite you
- Keep the iframe
srcstable for the life of your page. Anything that changes it remounts the frame and throws away in-progress work — half-answered questions included. Resize with CSS; never re-render the embed to resize it. The React components only rebuild the frame whenbaseUrl,token,modeor the resource id (storybookId/formId/worksheetId/gameType) actually change — a token refreshed throughfetchTokenreaches the frame without a rebuild, and an inlinefetchTokenarrow never causes one. - Mint per page load, not per session. Tokens are short-lived (15 minutes by
default, one hour maximum);
fetchTokenkeeps a long session alive without stretching the TTL. Where you are handing over a one-shottokeninstead, ask for the full hour onworksheet-edit, where a clinician is working in the frame rather than looking at it. - Your origins must match exactly. They become the frame's
frame-ancestors; a mismatch means the browser refuses to render it at all. - Give it height. Around 900px for a comfortable create flow; it scrolls below that rather than breaking.
- No cookies are involved. Nothing here depends on third-party cookies, so Safari's ITP and Chrome's partitioning do not affect it.
Errors
Errors reach you on two channels, and they are not interchangeable.
Server calls throw
Anything from createEmbedClient throws one of two typed errors. Nothing else
escapes — a non-JSON response from a proxy, a dropped connection, and a
getAccessToken that throws are all wrapped, so instanceof is a complete
check rather than a best guess.
| Thrown | When | Key fields |
|---|---|---|
PlaySpaceApiError |
the API answered with a 4xx/5xx | status, type (the problem slug), requestId, retryable, retryAfterSeconds, rateLimit, problem (the RFC 9457 body, verbatim) |
PlaySpaceTransportError |
no interpretable response came back | kind (access_token | network | malformed_response), requestId, retryable, cause |
try {
await playspace.mintEmbedToken({ capabilities: ['worksheet:read'], origins })
} catch (error) {
if (error instanceof PlaySpaceApiError) {
if (error.type === 'rate-limited') await wait(error.retryAfterSeconds ?? 30)
// Log this. It is the fastest route to an answer from PlaySpace support.
logger.warn({ requestId: error.requestId, type: error.type })
}
}
Always log requestId. PlaySpace tags its own error tracking with the same
value, so quoting one id resolves to the exact request rather than a time range.
The framed surface emits error events
message is human-readable and may be reworded between releases — branch on
code. Treat an unrecognised code as a generic failure of the given
severity; new ones appear as surfaces gain failure paths, and a host must not
break on one it has not seen.
onEvent: (event) => {
if (!isEventOfType(event, 'error')) return
if (event.payload.severity === 'warning') return // recovers on its own
showBanner(event.payload.message, event.payload.requestId)
}
embed.* codes come from this SDK, running on your page. PlaySpace has no
telemetry there, so this event is the only way those failures are ever seen —
forwarding them to your own logging is what makes them diagnosable at all.
Everything else comes from inside the PlaySpace frame and is also reported to
PlaySpace's own error tracking.
| Code | Severity | Meaning |
|---|---|---|
embed.token_refresh_failed |
warning | A re-mint failed; the current token is still valid and the SDK is retrying. |
embed.token_refresh_exhausted |
error | Re-minting failed repeatedly and the SDK has stopped. Your fetchToken is failing; the surface dies when the current token expires. |
embed.token_fetch_failed |
error | The first mint failed, so the surface never loaded. |
embed.token_unreadable |
warning | The token carries no readable expiry, so no refresh was scheduled. It will stop at its TTL. |
embed.session_expired |
error | The token has expired. The surface is dead until re-mounted. |
embed.logout_incomplete |
error | logout() could not revoke every token it held — one is still live until it expires. |
storybook.load_failed |
error | The storybook could not be read. |
worksheet.load_failed |
error | The worksheet could not be read. |
worksheet.upload_failed |
error | The PDF did not become a worksheet. retryable: false means the file was too large. |
worksheet.save_failed |
error | An edit did not persist. retryable: false means the server refused it outright; true means the network attempts ran out and the next edit will try again. |
worksheet.asset_upload_failed |
warning | An image could not be added. The clinician keeps editing; only that image was lost. |
worksheet.page_change_failed |
warning | Adding, deleting or reordering a page failed. The editor is left as it was, including the page order. |
worksheet.delete_failed |
error | The worksheet was not deleted and is still there. |
game.session_failed |
error | The session could not be started. |
game.save_failed |
error | A scene did not persist. |
game.load_failed |
error | A saved scene could not be read. |
shell.seat_failed |
error | The framed workspace could not load the clinician's seat — usually an expired credential. retryable: true: pass fetchToken and the SDK pushes a fresh token into the running frame, which re-reads the seat without a remount. |
storybook.share_failed |
error | The book was not emailed; nothing was sent. retryable: false when PlaySpace refused the send (422) — the patient has no email address on file, is no longer on the roster, or the book is not ready — and message then carries the instruction the frame shows the clinician (for a missing address: add one to the patient's record). Otherwise retryable: true. |
worksheet.share_failed |
error | The worksheet was not emailed; nothing was sent. Same retryable and message contract as storybook.share_failed. |
message, code, severity and retryable are always present; requestId
only when an API call was behind the failure. Every field was added alongside
the original message, so a handler written before this table keeps working.
Surfaces
| Mode | What it renders | Capabilities it needs |
|---|---|---|
create |
the storybook authoring flow | storybook:create (+ storybook:read) |
reader |
one storybook, read-only | storybook:read (+ storybook:share with patientId to offer Send to client) |
storybook-workspace |
the clinician's whole storybook library, with its own navigation (no storybookId) |
storybook:read + whichever of storybook:create (New storybook, Regenerate image and Add page in the editor: each a real generation), storybook:write (Add to my rooms, Rooms, and the editor's text, illustration and page-order edits), storybook:delete (Delete in the editor) the seat should hold; storybook:share with patientId to offer Send to client |
list |
the clinician's published forms, to pick from | form:read, and no template capability — see form-workspace |
fill |
one form, answerable | form:read + form:submit (+ patientId) |
form-create |
the form builder (no formId, no patientId) |
form:create |
form-shelf |
which of the clinician's rooms this form appears in (pass formId) |
form:compose |
form-workspace |
the clinician's whole forms library, with its own navigation (no formId) |
form:read + at least one of form:create, form:write, form:delete, form:compose; client:read optional, and what offers "Fill out" and "Send to client" |
worksheet |
one worksheet, read-only (pass worksheetId) |
worksheet:read (+ worksheet:share with patientId to offer Send to client) |
worksheet-upload |
a PDF becomes a worksheet in the library | worksheet:create (+ worksheet:read) |
worksheet-edit |
one worksheet in the drawing editor (pass worksheetId) |
worksheet:write (+ worksheet:read; worksheet:delete to offer Delete, worksheet:generate to offer AI images) |
worksheet-workspace |
the clinician's whole worksheet library, with its own navigation (no worksheetId) |
worksheet:read + whichever of worksheet:create (upload, blank create, Duplicate), worksheet:write (editor, Add to my rooms, Rooms), worksheet:delete, worksheet:generate the seat should hold; worksheet:share with patientId to offer Send to client |
game |
a live sandtray or dollhouse session | games:play |
shell |
the whole PlaySpace workspace, with its own navigation | shell:read (+ one per area — see below; community:read + community:write for the Internal Library) |
shell is different in kind from every mode above it. The others frame ONE
artifact and hand the result back through an event; this one frames the product,
owns its own navigation, and stays mounted while the clinician moves around
inside it. Mount it once — re-rendering with a different surface remounts the
frame and throws away wherever they had navigated to, so surface is a starting
point for a deep link, not a controlled prop.
What the clinician sees inside it is decided by PlaySpace, not by this SDK. No prop widens it and no capability on the token does either. Three separate things narrow it, each by removing the row — see "What is missing, and why" under the area table below.
shell:read is what licenses framing the whole product, and it is required by
both halves: the framed document refuses to render without it, and so does the
seat the workspace boots from. It grants no data of its own — each area inside
the workspace is gated by its own capability as it lands — so mint shell:read
alongside the capabilities for the areas you want reachable. Minted on its own,
the navigation shows Home alone; each other row appears once the token carries
that area's read capability.
It is also refused outright on the PATIENT seat of a game session. The token
returned as patient.embed_url by POST /v1/partner/game-sessions is for a
child's device and opens the game, never the clinician's workspace.
| Area | surface |
Capabilities to request |
|---|---|---|
| Today's summary — the landing area | home |
appointment:read + note:read + client:read (it summarises all three) |
| Video Session — today's launchable appointments | session |
appointment:read |
| In-Person Tools — choose a client to start the tools with | in-person-session |
client:read |
| Calendar | appointments |
appointment:read; add appointment:write so the clinician can book, reschedule and cancel in it |
| Client roster | clients |
client:read; add data:export for "Export all client data" at the bottom of the roster |
| Clinical notes | clinical-notes |
note:read; add note:write so the clinician can edit, sign and unlock their own note in the frame |
| Creative Suite — storybooks, worksheets and session rooms | creative-suite |
storybook:read + worksheet:read; add storybook:create, storybook:write, storybook:delete, worksheet:create, worksheet:write, worksheet:delete and worksheet:generate for the authoring controls, and playroom:read + playroom:write for the Rooms area |
| Forms | forms |
form:read; add form:create for the builder and the row menu's Duplicate, form:write to reopen an existing form in it and save the edit, form:delete for the row menu's Delete, and form:compose to add or remove a form from a room |
| Internal Library — what the clinic has shared | clinic-community |
community:read; add community:write for Share with clinic on the worksheet, storybook and form screens, and for Copy to my library and Remove |
The calendar shows the clinician's own appointments and deliberately carries no
meeting join link. A join URL is a live credential, and a framed document is
hosted by you; PlaySpace will not hand one across that boundary. To open a
session, mint one through POST /v1/partner/appointments/{id}/session-links
from your own server.
Each appointment you can act on carries an Open session control — Start
in-person tools on an in-person one — and pressing it emits
session.launch_requested with that appointment's partnerAppointmentId, which
is exactly the id that endpoint takes, and a launchAttemptId naming the press.
Handle the event, mint the link server-side, and open the session in your own
surface; the frame does not navigate and expects nothing back. Pressing it a
second time is a second request, so a clinician who moves between appointments
and comes back can reopen the first one.
In-Person Tools is a separate area with its own navigation row, for the child already in the room with nothing booked. It offers the clinician's clients in a dropdown and a Start session control; the workspace starts the session itself and opens it in its own content pane, exactly as an appointment row does. No appointment is created and nothing reaches your book — an ad-hoc session with a child already in the room is not a scheduled event. A client your application has no id for is shown and disabled rather than dropped, so a clinician can see why they cannot pick them.
Export all client data sits at the bottom of the Clients area when the token
carries data:export, and is absent without it; no other capability implies it.
It lets the clinician start a copy of their whole PlaySpace record, watch its
phase and counts while it builds, and download it as one ZIP file — the same
archive POST /v1/partner/exports produces. The frame calls PlaySpace itself
and handles every refusal in its own words, emitting no error event for them.
The problem slugs it can meet are forbidden (the token acts for no single
clinician — mint with a delegated token), export-in-progress (the clinician's
one live export slot is taken, possibly by an export they started in PlaySpace;
the frame shows that export instead), export-not-ready and export-expired
(the retention window has closed; start a new one). The download navigates the
frame to a short-lived signed link on PlaySpace's file storage origin, which the
browser turns into a download, so a page whose Content-Security-Policy restricts
frame-src must allow that origin too. A ready export also offers "Regenerate
export", which starts a fresh export the same way (an export-in-progress
refusal is handled the same way too). The frame offers only the newest export from the
moment it starts; any earlier copy stays downloadable by its own link until it
expires on its normal schedule.
What is missing, and why. Three different mechanisms narrow the workspace. Each removes the row outright, so what tells them apart is who can change it:
- Not offered by the framed workspace at all — the row is REMOVED from the
navigation. Community and Settings are absent by design: the PlaySpace
practitioner community is out of scope for a partner-managed seat, and you own
account management for the seats you provision. Nothing here renders a
PlaySpace upgrade advert inside your application unless you turn on
upgrade_enabledin/v1/partner/session-config: it is off by default, and when on, a clinic's owner (or its only practitioner) sees an Upgrade to PlaySpace row until the clinic has upgraded. The In-Person Tools row IS offered, and what it reaches is the CLIENT CHOOSER described above, not the 3D tools themselves: the playroom, sandtray and games behind it are exactly whatshell:readexists to keep out of the frame, so the area ends by asking you to open a session. - Listed, but the clinician is not entitled to it, or your organization switched it off in
enabled_features— the row is removed, along with any Home tile that summarises it, and opening the area by its address renders a neutral refusal panel. Entitlements are the clinician's PlaySpace plan, and no token you mint widens them. - Listed and entitled, but your token did not carry the area's read capability — the row is removed too, and opening the area by its address refuses. That one is yours: mint the capability from the table above.
An area that has not been built into the workspace yet says so in plain words
rather than 404ing, so a surface typo is visible in the frame instead of
blanking it.
Pass fetchToken, not a static token. A clinician works in a workspace for as
long as a session lasts, which is far longer than a token's TTL.
Every surface in the table above has a reference page on the PlaySpace partner documentation site. Each one carries the identifier that surface needs, what each capability adds or unlocks, the server and browser snippets, every event it emits with its payload, and the traps particular to it:
- Storybooks — https://docs.playspace.health/docs/surfaces/storybooks
- Forms — https://docs.playspace.health/docs/surfaces/forms
- Worksheets — https://docs.playspace.health/docs/surfaces/worksheets
- Games — https://docs.playspace.health/docs/surfaces/games
- The whole workspace — https://docs.playspace.health/docs/surfaces/shell
Each new surface adds a mode here and a row to the events table above — a host written against today's modes keeps working, because unknown event types are dropped rather than thrown on.
Games: two participants, one scene
A game session is multiplayer: the therapist and the patient each get their own
frame, and the two frames share one live scene. You do not mint these tokens
via mintEmbedToken — a dedicated endpoint mints the pair so both carry the
same session key:
const session = await playspace.createGameSession({
gameType: 'sandtray', // or 'dollhouse'
patientId, // your patient id (the one you created via the Partner API)
origins: ['https://your-app.example.com'],
})
// session.practitioner and session.patient each carry { embed_url, token, expires_at }
Mount each seat where that person is signed in:
<GameEmbed
baseUrl={PLAYSPACE_ORIGIN}
gameType="sandtray"
token={session.practitioner.token}
fetchToken={refreshPractitionerSeat} // sessions run 60+ minutes; see "Sessions longer than a token"
onEvent={handleEvent}
/>
The therapist's frame drives the session (choosing or creating a save, saving,
loading); the patient's frame follows automatically. Saves are read back
server-side via listGameSaves / getGameSave, and deleteGameSave soft-
deletes one. Game events: game.session_started, game.save_created,
game.saved, game.save_loaded — ids only, never scene content or names.
When refreshing a game seat with fetchToken, re-mint FOR THE SAME SESSION —
call createGameSession again with the original response's game_session_id
and hand the frame its own seat's new token (never the other seat's). A pushed
token whose practitioner, patient, role, or session key differ is refused by
the frame rather than re-scoping a session in progress.
The full integration procedure — session mint, the opaque patient-link
pattern, both seats, refresh, saves, and the security checklist — ships with
this package as an agent skill: skills/playspace-games-integration/SKILL.md.
Drop that directory into your repo's skill roster (e.g.
.claude/skills/playspace-games-integration/) or hand it to your coding agent
directly.
The same skills/ directory carries one procedure per integration job, each
written so a coding agent can build that job against your own data model in
one pass and each pointing at the Northwind Charts reference implementation
for a worked example:
| Skill | Job |
|---|---|
playspace-partner-roster-sync |
clinics, practitioners, patients into PlaySpace; link table; PATCH and drift; the org-unique 409s |
playspace-partner-scheduling |
appointments, reschedule/cancel, join links, the 409 conflict |
playspace-embed-surface |
the plumbing every embedded surface shares: token route, fetchToken, origins, full-page host, byte proxy |
playspace-storybooks-integration |
builder and reader, shelf_ready, covers and PDFs, delete |
playspace-worksheets-integration |
PDF upload, viewer, editor, delete |
playspace-forms-integration |
published forms, patient-bound form:submit, picker, fill, builder |
playspace-games-integration |
two-seat sandtray / dollhouse sessions |
playspace-spaces-integration |
playrooms, toolkits, shelf attach/detach |
playspace-partner-observability |
request ids, rate-limit headers, SDK hooks, error events, problem rendering |
License
Apache-2.0. The LICENSE and NOTICE files ship in the package, and the license
text is at https://www.apache.org/licenses/LICENSE-2.0.
The license covers this SDK's code only. Access to the Partner API it talks to comes from your PlaySpace partner agreement and the credentials issued under it, not from this license.