@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:

  • fetchToken is 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. via mintEmbedToken) and returns the token string. Your Partner credential still never reaches the browser.
  • A one-shot token remains 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 token prop remounts the frame. token is part of what the SDK rebuilds the iframe src from, 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 (a router.refresh() fired from an embed event, for instance). Either use fetchToken, which pushes the re-mint into the running frame without touching the src, 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 error event 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:

  1. If your page (or a proxy/CDN in front of it) sends a Permissions-Policy header, 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-Policy header needs no change — the iframe allow attribute alone delegates.

  2. 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 answers 404, never 403.
  • thumbnail_url is derived, not uploaded. It follows room_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 PATCH array field replaces, it does not merge. Send the full session_items you 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 src stable 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 when baseUrl, token, mode or the resource id (storybookId / formId / worksheetId / gameType) actually change — a token refreshed through fetchToken reaches the frame without a rebuild, and an inline fetchToken arrow never causes one.
  • Mint per page load, not per session. Tokens are short-lived (15 minutes by default, one hour maximum); fetchToken keeps a long session alive without stretching the TTL. Where you are handing over a one-shot token instead, ask for the full hour on worksheet-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_enabled in /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 what shell:read exists 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:

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.