Level 1: the whole workspace

Frame the entire PlaySpace workspace in your own page, acting as one of your clinicians. They get the real product — its navigation, its header, its feature gating — inside your application, on your domain, without a PlaySpace login.

This is the shortest integration that puts something in front of a clinician. Your server mints a token and your page mounts a frame. There is nothing else.


What you build

One route on your own backend that authenticates your user, works out which practitioner they are, and mints an embed token for them. Your Partner API credential never leaves your server.

One frame in your page, given a callback to that route.

That is the whole of it. Everything below is the detail of those two files.


The server half

// playspace/workspace-token.ts — server only. Never runs in a browser.
import { createEmbedClient } from '@playspace-health/embed/server'
import type { EmbedCapability } from '@playspace-health/embed'

/** The PlaySpace host your credential was issued for. */
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'

/** Every origin that will host the frame. Checked when the token is minted. */
const HOST_ORIGINS = ['https://app.yourclinic.com']

/**
 * Two functions you supply. `getDelegatedPartnerToken` runs the
 * client-credentials exchange with `practitioner_id` set; `signedInPractitionerId`
 * reads your own session and is the entire security boundary of this route.
 */
declare function getDelegatedPartnerToken(practitionerId: string): Promise<string>
declare function signedInPractitionerId(request: Request): Promise<string | null>

/** The whole workspace: the frame itself, plus one capability per area inside it. */
export const WORKSPACE_CAPABILITIES: EmbedCapability[] = [
  'shell:read', // the framed workspace and the seat it boots from
  'note:read', // Clinical Notes: the list and the reader
  'note:write', // and letting the clinician edit, sign and unlock their own note
  'client:read', // Clients: the roster, and the In-Person Tools picker
  'appointment:read', // the calendar and the Video Session list
  'appointment:write', // and letting the clinician book, move and cancel in it
  'storybook:read', // storybooks in the Creative Suite, and the reader
  'storybook:create', // the create control and the generation it starts
  'storybook:write', // "Add to my rooms" on a finished storybook, and its editor
  'storybook:delete', // Delete inside that editor
  'worksheet:read', // worksheets in the Creative Suite, and the reader
  'worksheet:create', // the PDF upload control
  'worksheet:write', // the drawing editor, its page rail, and "Add to my rooms"
  'worksheet:delete', // the editor's Delete control
  'worksheet:generate', // the editor's Sparkles control — the one verb that spends money
  'form:read', // Forms: the shelf and a blank template preview
  'form:create', // the Forms create control and the in-frame builder
  'form:write', // the row menu's Edit form, and saving in that builder
  'form:delete', // the row menu's Delete form
  'form:compose', // placing a form on a room's shelf
  'playroom:read', // Rooms: the room list and one room's shelf
  'playroom:write', // creating and editing a room
]

export async function mintWorkspaceToken(practitionerId: string): Promise<string> {
  const playspace = createEmbedClient({
    baseUrl: PLAYSPACE_BASE_URL,
    getAccessToken: () => getDelegatedPartnerToken(practitionerId),
  })

  const embed = await playspace.mintEmbedToken({
    capabilities: WORKSPACE_CAPABILITIES,
    origins: HOST_ORIGINS,
  })

  return embed.token
}

/**
 * POST /api/playspace/workspace-token — the only PlaySpace route your browser calls.
 *
 * A Web `Request` in, a Web `Response` out, so this is the handler shape for
 * every framework that speaks the platform types.
 */
export async function POST(request: Request): Promise<Response> {
  const practitionerId = await signedInPractitionerId(request)
  if (!practitionerId) return new Response('Not signed in', { status: 401 })

  const token = await mintWorkspaceToken(practitionerId)
  return new Response(JSON.stringify({ token }), {
    status: 200,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
  })
}

Home needs three of the capabilities at once. It has no capability of its own: it summarises Calendar, Clinical Notes and Clients by making the same reads those areas make, so it needs appointment:read, note:read and client:read together. Mint only some of them and the landing page still opens, with the part of the summary you did not grant unreadable while the rest of the workspace looks fine.

Mint with a delegated token. The acting practitioner is read from the token's own claim and never from the request body, so an organisation-wide token has nobody to act as and is refused.

origins is checked at mint time, not when the frame fails to load. Pass the scheme and host of the page that will hold the frame, with no trailing slash and no path. A wildcard is accepted only as a whole leading label, as in https://*.yourclinic.com. A malformed value is an error you can read rather than a blank rectangle you have to diagnose from a browser console. A development origin such as http://localhost:3000 is an ordinary value here and is accepted: the check is only whether a browser would honour the string as a frame source, so plain http and a port both pass and you can build against the frame before you have a deployed host.

Leave ttlSeconds alone. The default is fifteen minutes, the maximum is one hour, and the refresh below is what carries a long session — not a longer token.


The browser half

The imports below come from @playspace-health/embed, published on the public npm registry: npm install @playspace-health/embed. A page that cannot run npm can load the same browser half from a CDN, and a host with no JavaScript build at all can mount this workspace as a plain iframe — both are in Using the API from other languages.

'use client'
// playspace/workspace-frame.tsx — browser.
import { useCallback } from 'react'
import type { ReactElement } from 'react'
import { ShellEmbed } from '@playspace-health/embed/react'
import type { EmbedEvent } from '@playspace-health/embed'

const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'

/** Your own error tracker. */
declare function reportEmbedFailure(code: string | undefined, requestId: string | undefined): void

export function PlaySpaceWorkspace(): ReactElement {
  const fetchToken = useCallback(async (): Promise<string> => {
    const response = await fetch('/api/playspace/workspace-token', { method: 'POST' })
    if (!response.ok) throw new Error(`Token route answered ${response.status}`)
    const body = (await response.json()) as { token: string }
    return body.token
  }, [])

  const onEvent = useCallback((event: EmbedEvent): void => {
    if (event.type === 'error' && event.payload.severity !== 'warning') {
      reportEmbedFailure(event.payload.code, event.payload.requestId)
    }
  }, [])

  // The frame fills its container, so the container is where the height lives.
  return (
    <div style={{ height: 820 }}>
      <ShellEmbed baseUrl={PLAYSPACE_BASE_URL} fetchToken={fetchToken} onEvent={onEvent} />
    </div>
  )
}

Without React, the same mount is a function call:

// playspace/workspace-frame.ts — browser, no framework.
import { createShellEmbed } from '@playspace-health/embed'

const container = document.getElementById('playspace-workspace')
if (!container) throw new Error('No #playspace-workspace element on the page')

container.style.height = '820px'

const handle = createShellEmbed(container, {
  baseUrl: 'https://agentic-ps-dev.playspace.health',
  fetchToken: async () => {
    const response = await fetch('/api/playspace/workspace-token', { method: 'POST' })
    if (!response.ok) throw new Error(`Token route answered ${response.status}`)
    const body = (await response.json()) as { token: string }
    return body.token
  },
  onEvent: (event) => {
    if (event.type === 'error') console.warn('playspace embed', event.payload.code)
  },
})

/** Call this when your user signs out. Removing the frame does not revoke the token. */
export async function signOutOfPlaySpace(): Promise<void> {
  await handle.logout()
}

Give the container a height. The frame is 100% of whatever holds it, so a container with no height renders nothing at all and looks like a failure that it is not.

Mount once and leave it. The workspace owns its own navigation and stays mounted while the clinician moves around inside it. surface sets the area the frame opens on, so pass it once from a deep link; re-rendering with a different value remounts the frame and throws away wherever they had navigated to.


Pass fetchToken, never a static token

An embed token is short-lived on purpose: it rides in an iframe URL, which is exposed to browser history, Referer headers and anything that logs a URL. A therapy session lasts far longer than that.

fetchToken resolves that without stretching the lifetime. The SDK calls it once to mount, and again shortly before each expiry, and pushes the fresh token into the running frame as a message. The document is not reloaded and the clinician notices nothing.

The failure path is worth wiring before you need it:

  • A re-mint that fails emits error with code embed.token_refresh_failed, severity warning and retryable: true. The SDK keeps retrying while the current token is still alive. Nothing is broken yet.
  • After repeated failures it stops and emits error with code embed.token_refresh_exhausted, severity error. This one is terminal: the frame runs until the last token expires and then goes cold. Alert on it — it means your token route or your credential is down, and every framed surface in your product is dark.
  • When the last token held actually expires, the SDK emits error with code embed.session_expired.
  • If the very first mint fails, the frame never gets a source and the SDK emits error with code embed.token_fetch_failed.

Branch on code, never on message.


What the clinician gets

The framed workspace carries these areas. Each is gated by its own capability, which is why the list above mints one per area:

Area What it shows
Home Today, summarised from the calendar, the notes and the roster — it reads appointment:read, note:read and client:read together
Video Session Today's launchable appointments. With session:launch the session itself opens in this pane
In-Person Tools The clinician's clients, to start the tools with someone who has nothing booked; gated by client:read
Calendar The clinician's own calendar, as a week or month grid. With appointment:write the clinician can also book, reschedule and cancel in it
Clients The clinician's roster, and one client's usage summary. With data:export, "Export all client data" at the bottom of the roster starts, shows and downloads a copy of the clinician's whole record
Clinical Notes The clinician's notes; opening one shows the written note and, when the session was recorded, the verbatim session transcript beside it. With note:write the clinician can also edit, sign and unlock a note they own, without leaving your product
Creative Suite Storybooks, worksheets and the Rooms that hold them
Rooms The clinician's playrooms and toolkits, opened from inside Creative Suite rather than from a navigation row of its own; gated by playroom:read, and by playroom:write to create or edit one
Forms The clinician's forms, the builder, and placing a form on a room's shelf
Internal Library What clinicians of the same clinic have shared with each other; gated by community:read, with community:write to share, copy and remove

The frame groups these the way PlaySpace does: Video Session, In-Person Tools and Creative Suite sit under a "Session Prep" heading, Calendar, Clients, Clinical Notes and Forms under "Your Practice", and Home above both.

shell:read frames the product and grants no data. On its own the navigation shows Home alone: every other row needs its area's read capability on the token, and an area opened by its address without one refuses. Mint it alongside the capability for each area you want reachable.

Forms has a standalone twin. If Forms is the only area you want, you do not have to frame the whole workspace to get it. /embed/forms renders the same forms library as a single Level 3 surface — the forms reference page is that surface in full — — the same tabs, tiles and row controls, without the workspace navigation around them — whenever the token carries one of the form template capabilities. No other area has one.

The calendar writes, if you let it. appointment:read alone gives a read-only grid. Add appointment:write and the clinician gets a booking form, a reschedule and a cancel, and PlaySpace tells you about each one through the appointment.created, appointment.updated and appointment.cancelled events on the frame — the only appointments PlaySpace will ever hold that your own system did not create. Answer them by reading the appointment back with GET /v1/partner/appointments/{id} and writing it into your own records.

A cancelled appointment leaves the calendar. PlaySpace cancels by removing the appointment, so the row disappears from the grid rather than staying on it greyed out or struck through — the same thing a clinician sees in PlaySpace itself. The appointment.cancelled event is therefore how your application learns a session is off; a later read of the calendar will simply not contain it.

Two rules follow from where the frame runs. An appointment booked in the frame always uses the organization's own platform video provider: a document your page is hosting holds no meeting URLs and PlaySpace will not accept any from it, or your application would be deciding where a PlaySpace session opens. An appointment you created carrying video links you supplied is the mirror image — it can be cancelled in the frame but never moved there, because the supplied-video contract needs both URLs resubmitted whenever the window moves and the frame has none to give. The reschedule control is simply absent on those rows, with a line telling the clinician to move it in your system. An organization with no platform video provider at all can only book in person here, and the form says so rather than failing on submit.

The calendar carries no join link, deliberately. A join URL is a live credential and your page is the host; PlaySpace will not hand one across that boundary. That is unchanged, and it is not the same thing as the clinician being unable to start a session: pressing "Open session" on a row opens the live session inside the frame's own content pane, with the workspace navigation still beside it. The frame mints the acting clinician's own credential for that, so nothing crosses into your application — mint session:launch alongside appointment:read and the control works. The PATIENT's link is the part that stays yours, and getting it is a Level 2 concern.

"Export all client data" is a deliberate grant. Mint data:export and the bottom of the Clients area offers the clinician a copy of their whole PlaySpace record — every client, with their notes, forms, worksheets, storybooks and uploaded files — as one ZIP file, the same archive POST /v1/partner/exports produces. Without it the control is simply absent. No other capability implies it. The frame shows the export's phase and counts while it builds, and it can run while the clinician works elsewhere in the workspace; nothing comes back to your application. Once an export is ready the frame offers "Download" and "Regenerate export": regenerating builds a fresh copy that includes anything added since, and from the moment it starts it is the export the frame shows, so the older copy is no longer offered and expires on its normal schedule.

The download navigates the frame to PlaySpace's file storage. The archive is a short-lived signed link on the storage origin, not on the PlaySpace host, and the browser treats that navigation as a download, so the workspace stays where it was. If your page sends a Content-Security-Policy that restricts frame-src, it must allow the storage origin as well as the PlaySpace host, or the download is blocked.


What is not in the frame

Three different things narrow the workspace. Each one removes the row outright, so what tells them apart is who can change it, not what the clinician sees:

Removed by design. The Settings navigation row is absent, and so is the PlaySpace-wide practitioner community: you own account management for the seats you provision, and the global community is not a partner-managed concern. Removing a row rather than locking it is deliberate: a locked row is pressable, and pressing one sends the clinician to PlaySpace rather than to you. In-Person Tools IS in the navigation, and what it reaches is the client chooser plus a Start control — the workspace starts that session itself, in its own content pane, and creates no appointment, so nothing about it reaches your book and nothing is asked of you. 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. Nothing here advertises PlaySpace inside your application unless you turn on upgrade_enabled on /v1/partner/session-config: it is off by default, and while it is on a clinic's owner — or its only practitioner — sees an Upgrade to PlaySpace row until the clinic has upgraded.

Removed because it is unavailable. An area the clinician's PlaySpace plan does not include, or that your organization switched off in enabled_features, is removed from the navigation, along with any Home tile that summarises it. Opening the area by its address instead renders a neutral panel — "Not part of this PlaySpace" — that points at the organisation's administrator. Entitlements belong to the seat, are resolved server-side, and no property you pass and no capability you mint widens them, so decide which areas a seat's plan covers before you frame them.

Removed because the token cannot read it. An area whose read capability your token did not carry is removed from the navigation too, and refuses if opened by its address. That one is yours to fix: add the capability to the mint.


The events a Level 1 host can ignore

Every event reaches onEvent; most of them are notifications you may log and drop.

  • ready fires on every load of the framed document, including a hard reload.
  • storybook.created, form.created, form.saved, form.deleted, form.shelf_changed, worksheet.created, worksheet.saved and worksheet.deleted report what the clinician made, changed or retired inside the frame, as identifiers and counts. form.created and form.saved are separate events on purpose: a rename is not a new instrument. Record them if you want your own index of a clinician's work; ignore them and nothing breaks.
  • error is the one to wire up. Send it to your error tracker with its code and, when present, its requestId.

Where this level stops

The frame hands anything about one patient, or anything carrying a live join credential for someone else, back to you as a request event. Opening a form for a client and asking PlaySpace to email a client a form are yours to answer.

Opening a session is the exception, and it is worth being precise about. The frame does it itself, in its own content pane, for the clinician who is signed in to your application — so session.launch_requested reaches you as a NOTIFICATION rather than an instruction, and a host that opens a tab on it puts the same clinician into the same session twice. What is still yours is the PATIENT's link, which no framed document ever receives.

Answering the rest is Level 2. Ignoring those events is a valid Level 1 integration — the controls are visible, and pressing one is simply a request nobody answered.