Surface: the whole workspace
The shell mode frames the real PlaySpace workspace — its own navigation, its own header, its own feature gating — inside your application, acting as one of your clinicians. It is different in kind from every other surface on this site: the others frame ONE artifact and hand the result back through an event, while this one frames the product and stays mounted while the clinician moves around inside it.
Level 1: the whole workspace is the build walkthrough — the token route on your server and the component in your page, in full. This page is the reference beside it: what the mode takes, what each area costs in capabilities, what the frame tells you, and what is deliberately not in it.
What it renders
mode |
What the clinician sees | The URL the SDK builds |
|---|---|---|
shell |
the whole PlaySpace workspace, with its own navigation | /embed/shell |
shell with surface |
the same workspace, opened on one area | /embed/shell/{area} |
Mount it once. Re-rendering with a different surface remounts the frame and throws away wherever the clinician had navigated to, so surface is a starting point for a deep link, not a controlled property. The framed workspace owns its own navigation; wherever the clinician goes next is decided inside the frame.
What the clinician sees inside it is decided by PlaySpace, not by the SDK. No property widens it and no capability on the token does either — three separate things narrow it, and they look different in the frame. That is the last section on this page.
An area segment the workspace does not recognise renders a page inside the frame saying so, rather than a 404: a blank frame and a broken embed are indistinguishable to the person looking at one, so a typo in surface is visible instead of blanking the page. Leading and trailing slashes are ignored, each segment is encoded, and dot segments are dropped rather than encoded.
The identifier it needs
None. There is no artifact to address: the workspace is the clinician's own, resolved from the seat the token names.
patientId is not required, and not appropriate. The workspace spans a whole caseload — a roster, a calendar, a notes list — so there is no single patient to name. Anything inside it that IS about one named patient is handed back to you as a request event rather than acted on in the frame, which is exactly why Level 2 exists.
Capabilities
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. On its own the navigation shows Home alone — every other row needs its area's read capability on the token — and any other area opened by its address refuses.
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; add session:launch so the session opens in the frame's own content pane |
| 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, 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 |
Neither note:write nor appointment:write is ever implied by its read half: a seat bought to look at a calendar or a note has not thereby bought one that books into a clinician's book or signs a clinical record.
Forms is the one area with a standalone twin. If Forms is all you want, you do not have to frame the whole workspace to get it — the same library, tabs, tiles and row controls mount on their own as the forms surface. No other area has one.
Mint the token
The mint is the ordinary one, with shell:read and one capability per area:
const embed = await playspace.mintEmbedToken({
capabilities: [
'shell:read',
'appointment:read',
'session:launch',
'client:read',
'note:read',
],
origins: ['https://app.yourclinic.com'],
})
Pass fetchToken, never a static token. A clinician works in a workspace for as long as a session lasts, which is far longer than any token's lifetime, and the workspace is the surface where a remount costs the most: it throws away wherever they had navigated to. Level 1 carries the token route and the failure paths worth wiring before you need them.
Mount it
'use client'
import { ShellEmbed } from '@playspace-health/embed/react'
<div style={{ height: 820 }}>
<ShellEmbed
baseUrl="https://agentic-ps.playspace.health"
fetchToken={mintWorkspaceTokenOnMyServer}
surface="clinical-notes" // optional deep link; a starting point, not a controlled value
onEvent={handleEvent}
/>
</div>
Without React, the workspace mounts through createShellEmbed(container, { baseUrl, fetchToken, surface }), and handle.destroy() takes it down.
Without the package at all, the workspace is an ordinary iframe pointed at /embed/shell with the token in the query string. The allow attribute is not optional here, because the workspace opens a live session inside its own content pane — the session document is one frame below the one you place, and a feature dropped at your level is denied at every level below it:
<iframe
src="https://agentic-ps.playspace.health/embed/shell?token=EMBED_TOKEN"
title="PlaySpace"
allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width: 100%; height: 820px; border: 0"
></iframe>
If your page, or a CDN in front of it, sends its own Permissions-Policy header, that header wins over the attribute and must name the PlaySpace origin for each feature. Using the API from other languages carries the exact header and the plain JavaScript that replaces the SDK's refresh, event verification and logout.
Events it emits
The workspace emits everything its areas emit, so a host framing it hears the storybook, form and worksheet events documented on those surfaces' own pages. These are the ones that belong to the workspace itself:
| Event | When it fires | Payload |
|---|---|---|
ready |
the framed document loaded and is interactive. Fires on every load, including a hard reload | { mode } |
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 | the same shape |
appointment.cancelled |
the clinician cancelled one | the same shape |
session.launch_requested |
the clinician pressed Open session — or Start in-person tools — on an appointment row | { partnerAppointmentId, launchAttemptId } |
session.in_person_requested |
the clinician chose a client in In-Person Tools to start the tools with | { 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 } |
error |
something worth surfacing; branch on code, never on message |
{ message, code?, severity?, retryable?, requestId? } |
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 from your server, so the two books agree by construction; these did not. 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. They need appointment:write on the token; a calendar minted appointment:read alone is read-only, offers no booking control, and emits none of the three. partnerPatientId is null for a client PlaySpace holds no identifier of yours for.
session.launch_requested is a NOTIFICATION, not an instruction — do not open a tab. The frame opens the session itself, in its own content pane, minting the acting clinician's own join credential on the embed tier, licensed by session:launch. A host that also opens a tab puts the same clinician into the same session twice. What is still yours is the PATIENT's link, which no framed document ever receives.
session.in_person_requested, form.fill_requested and form.send_requested are requests. Nothing happens inside the frame after the press, so a host that ignores one leaves the clinician looking at a control that appears to do nothing. Answering all of them is Level 2.
launchAttemptId and requestAttemptId name the press, not the subject. A single press always carries one identifier however many times its message reaches you, and pressing again is a new press the clinician means — so answer each distinct one once. Each is unique within one frame load, which makes it right for collapsing a duplicate delivery in the moment and wrong as an idempotency key you store.
shell.seat_failed is the error code specific to this surface: the framed workspace could not load the clinician's seat, usually an expired credential. It is retryable: true — pass fetchToken and the SDK pushes a fresh token into the running frame, which re-reads the seat without a remount.
The workspace signs itself out after a period of inactivity and says nothing on this channel. The frame replaces itself with a signed-out panel and posts no event, so do not infer from silence that the embed is still live.
What is missing, and why
Three different mechanisms narrow the workspace. All three end in a row that is simply absent, so the difference is in who can change it, not in what the clinician sees.
Not offered by the framed workspace at all — the row is REMOVED from the navigation. Settings and the PlaySpace-wide practitioner community are absent by design: the global community is out of scope for a partner-managed seat, and you own account management for the seats you provision. Removing a row rather than locking it is deliberate, because a locked row is pressable and pressing one sends the clinician to PlaySpace rather than to you. 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, not the three-dimensional 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. The clinic's own library IS offered: the Internal Library row appears when your token carries community:read, and shows only what clinicians of that clinic shared with each other.
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. Turning Appointments off removes both Calendar and Video Session; turning Clients off removes both Clients and In-Person Tools. Opening the area by its address renders a neutral panel that points at the organisation's administrator. Entitlements belong to the seat and are resolved server-side, so no property you pass and no capability 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.
The calendar carries no meeting join link, and will not. A join URL is a live credential and a framed document is hosted by you; PlaySpace will not hand one across that boundary. That is not the same thing as the clinician being unable to start a session — pressing Open session opens it in the frame's own content pane. The patient's link is minted on your own server with POST /v1/partner/appointments/{id}/session-links.
Things that will bite you
- Driving
surfacefrom your own state. It remounts the frame and discards where the clinician was. Set it once, from a deep link. - A static
token. The workspace is the surface where a remount costs the most. PassfetchToken. - Ignoring the request events. The controls are visible inside the frame whether you answer them or not, so a clinician presses one and nothing happens. Ignoring them is a valid Level 1 integration only if you accept that.
- Opening a tab on
session.launch_requested. That is the same clinician in the same session twice, and the second arrival is the one that looks like a bug. - A
Permissions-Policyheader on your own page. It wins over the iframe attribute, and the failure is aNotAllowedErrorraised before any prompt appears. - A
Content-Security-Policythat restrictsframe-src. "Export all client data" downloads by navigating the frame to a short-lived signed link on PlaySpace's file storage origin, so that origin must be allowed as well as the PlaySpace host. - Give the frame height. Around 820 pixels is comfortable; the workspace scrolls internally below that rather than breaking.
Related
- Level 1: the whole workspace is the build walkthrough — the server half and the browser half, in full, with the failure paths.
- Level 2: host controls is how to answer every request the frame hands back.
- Level 3: single surfaces is the table of every framed surface, and which screens exist only inside this one.
- The forms surface is the one area you can frame without the workspace around it.
@playspace-health/embedcarries the full component, event and error reference.