Level 2: the whole workspace, plus host controls
Level 1 mounts the workspace. Level 2 answers the four things the workspace cannot do for itself.
The token that authorises the frame is bound to a clinician. Anything about one patient, and anything carrying a live join credential, is therefore handed back to you as a request event: PlaySpace takes no action, the frame does not navigate, and it expects nothing back. You spend your own credential on your own server and render the result in your own interface.
There are three such events, and this page is one section each. A fourth, session.in_person_requested, is deprecated and never sent — see its section below.
| Event | The clinician pressed | You do |
|---|---|---|
session.launch_requested |
Open session, on an appointment | Mint the session links and open the clinician one |
session.in_person_requested |
(deprecated — never sent) | Nothing. The framed workspace starts this session itself |
form.fill_requested |
Fill this form with a client | Mint a patient-bound token and frame the form yourself |
form.send_requested |
Email this form to a client | Mint a patient-bound token and ask PlaySpace to send it |
Deduplicate on the type and the attempt id together
Every request event carries an attempt id — launchAttemptId or requestAttemptId — that names one press. A repeat delivery of the same press reuses the value; a second press mints a new one, which is exactly what a clinician who moves between appointments and comes back expects.
The id is a counter behind a timestamp prefix, so two frame loads cannot mint the same one and a key derived from it is safe to send. The four flows count independently, though, so the key you deduplicate on is the event type and the attempt id together.
The Idempotency-Key on each write below is the operation name plus the attempt id, because one press can make more than one write — starting an in-person session creates an appointment and then mints its session links, and each needs a key of its own. The two guards cover different halves of the problem: your own dedupe stops one press being answered twice, while a key derived from the press stops a retried answer booking twice. Do not persist that key beyond the press it answers. An attempt id is not durable, and a press in a later frame load is a new request that deserves its own appointment.
Everything on this page runs on your server
// playspace/host-actions.ts — server only. Every function here spends a credential.
import { createEmbedClient } from '@playspace-health/embed/server'
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
const HOST_ORIGINS = ['https://app.yourclinic.com']
/** Your own delegated client-credentials exchange, acting as one practitioner. */
declare function getDelegatedPartnerToken(practitionerId: string): Promise<string>
export interface SessionLinks {
clinician_video_url: string | null
patient_video_url: string | null
video_provider: 'Whereby' | 'Dailyco' | 'EightxEight' | 'None'
waiting_room_enabled: boolean
}
/** One request against the Partner API, with the envelope and the problem document unwrapped. */
async function partnerRequest<T>(
path: string,
options: {
accessToken: string
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
body?: unknown
/** Key this on the press it answers, not on a fresh value per attempt. */
idempotencyKey?: string
}
): Promise<T> {
const method = options.method ?? 'GET'
const headers: Record<string, string> = { authorization: `Bearer ${options.accessToken}` }
if (options.body !== undefined) headers['content-type'] = 'application/json'
// Every write on this API requires one. A key derived from the press replays on a
// retry; the fallback names one attempt only, which is what a settings write wants.
if (method !== 'GET') headers['idempotency-key'] = options.idempotencyKey ?? crypto.randomUUID()
const response = await fetch(`${PLAYSPACE_BASE_URL}${path}`, {
method,
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
})
if (!response.ok) {
// RFC 9457. Branch on `type`; `title` and `detail` are prose and may be reworded.
const problem = (await response.json()) as { type: string }
throw new Error(`${response.status} ${problem.type}`)
}
const envelope = (await response.json()) as { data: T }
return envelope.data
}
/** session.launch_requested — the appointment already exists, so this is one call. */
export async function openSession(
practitionerId: string,
partnerAppointmentId: string,
launchAttemptId: string
): Promise<SessionLinks> {
const accessToken = await getDelegatedPartnerToken(practitionerId)
return partnerRequest<SessionLinks>(
`/v1/partner/appointments/${partnerAppointmentId}/session-links`,
{
accessToken,
method: 'POST',
// One press, one set of links: a retry of this call replays the first response.
idempotencyKey: `session-links:${partnerAppointmentId}:${launchAttemptId}`,
}
)
}
/** form.fill_requested — a patient-bound token for a SECOND frame, beside the workspace. */
export async function mintFormFillToken(
practitionerId: string,
partnerPatientId: string
): Promise<string> {
const playspace = createEmbedClient({
baseUrl: PLAYSPACE_BASE_URL,
getAccessToken: () => getDelegatedPartnerToken(practitionerId),
})
const embed = await playspace.mintEmbedToken({
// form:read travels with form:submit: the surface must render before it can be answered.
capabilities: ['form:read', 'form:submit'],
origins: HOST_ORIGINS,
patientId: partnerPatientId,
})
return embed.token
}
/** form.send_requested — the one call a host makes with an embed token rather than its own. */
export async function sendFormToClient(
practitionerId: string,
formId: string,
partnerPatientId: string
): Promise<string> {
const playspace = createEmbedClient({
baseUrl: PLAYSPACE_BASE_URL,
getAccessToken: () => getDelegatedPartnerToken(practitionerId),
})
const embed = await playspace.mintEmbedToken({
capabilities: ['form:send'],
origins: HOST_ORIGINS,
patientId: partnerPatientId,
})
const response = await fetch(`${PLAYSPACE_BASE_URL}/api/embed/forms/${formId}/send`, {
method: 'POST',
headers: {
authorization: `Bearer ${embed.token}`,
'content-type': 'application/json',
},
// The request names no recipient. Both sender and recipient come from the token.
body: '{}',
})
if (!response.ok) {
const problem = (await response.json()) as { type: string }
// recipient-address-missing, not-found and feature-not-entitled each need
// their own sentence in your interface.
throw new Error(`Send refused: ${problem.type}`)
}
const body = (await response.json()) as { data: { submission_id: string } }
return body.data.submission_id
}
/** Before the in-frame Rooms picker can place a form, the form must be shelf-ready. */
export async function makeFormShelfReady(practitionerId: string, formId: string): Promise<void> {
const accessToken = await getDelegatedPartnerToken(practitionerId)
await partnerRequest(`/v1/partner/forms/${formId}`, {
accessToken,
method: 'PATCH',
body: { shelf_ready: true },
})
}
/** Organisation-wide session defaults. An organisation token is the right credential here. */
export async function setSessionDefaults(organisationAccessToken: string): Promise<void> {
await partnerRequest('/v1/partner/session-config', {
accessToken: organisationAccessToken,
method: 'PATCH',
body: {
video_provider: 'None',
waiting_room_enabled: false,
recording_enabled: false,
},
})
}
Check the shape of every identifier that arrives from the frame before you spend a credential on it. A frame is not a trust boundary. The ids in a request event are your own PlaySpace identifiers and carry the 8-4-4-4-12 hexadecimal UUID shape, so a shape check is cheap and catches the interesting cases. Check the shape rather than the UUID version: the partner surface accepts any well-formed UUID, so a version check would refuse ids the platform itself considers valid.
And this is the page that wires it up
'use client'
// playspace/workspace-frame.tsx — browser. The Level 1 mount, plus four handlers.
import { useCallback, useRef, useState } from 'react'
import type { ReactElement } from 'react'
import { FormEmbed, ShellEmbed } from '@playspace-health/embed/react'
import type { EmbedEvent } from '@playspace-health/embed'
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
/**
* Three calls into your own server, each wrapping one function from the file
* above. None of them may run in the browser: they spend your partner credential.
*/
declare function openSessionAction(
partnerAppointmentId: string,
launchAttemptId: string
): Promise<{ clinician_video_url: string | null }>
declare function mintFormFillTokenAction(partnerPatientId: string): Promise<string>
declare function sendFormToClientAction(formId: string, partnerPatientId: string): Promise<string>
/** Your own token route, exactly as at Level 1. */
declare function fetchWorkspaceToken(): Promise<string>
interface FillRequest {
formId: string
token: string
}
export function PlaySpaceWorkspace(): ReactElement {
const [fill, setFill] = useState<FillRequest | null>(null)
// An attempt id names ONE press and is unique only within one frame load, and
// the four flows count independently — so the dedupe key is type plus id.
const handled = useRef(new Set<string>())
const firstDelivery = useCallback((type: string, attemptId: string): boolean => {
const key = `${type}:${attemptId}`
if (handled.current.has(key)) return false
handled.current.add(key)
return true
}, [])
const onEvent = useCallback(
(event: EmbedEvent): void => {
switch (event.type) {
case 'session.launch_requested': {
const { partnerAppointmentId, launchAttemptId } = event.payload
if (!firstDelivery(event.type, launchAttemptId)) return
// A NOTIFICATION: the frame is opening this session itself. Do not
// open a tab. Mint here only if you need the PATIENT link for a
// patient-facing surface of your own — the attempt id is what the
// server keys the Idempotency-Key on, so one press mints once.
void openSessionAction(partnerAppointmentId, launchAttemptId).then(({ patient_video_url }) => {
// A credential: sent to the patient, never rendered, logged or stored.
if (patient_video_url) sendToPatient(patient_video_url)
})
return
}
// `session.in_person_requested` is DEPRECATED and never sent. There is
// no case for it: the framed workspace starts that session itself and
// creates no appointment. A handler that still booked would put a block
// nobody scheduled into your own book.
case 'form.fill_requested': {
const { formId, partnerPatientId, requestAttemptId } = event.payload
if (!firstDelivery(event.type, requestAttemptId)) return
void mintFormFillTokenAction(partnerPatientId).then((token) => {
// A second frame, beside the workspace. The patient-bound token
// never enters the workspace frame.
setFill({ formId, token })
})
return
}
case 'form.send_requested': {
const { formId, partnerPatientId, requestAttemptId } = event.payload
if (!firstDelivery(event.type, requestAttemptId)) return
void sendFormToClientAction(formId, partnerPatientId)
return
}
default:
return
}
},
[firstDelivery]
)
return (
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ flex: 2, height: 820 }}>
<ShellEmbed
baseUrl={PLAYSPACE_BASE_URL}
fetchToken={fetchWorkspaceToken}
onEvent={onEvent}
/>
</div>
{fill ? (
<div style={{ flex: 1, height: 820 }}>
<FormEmbed
baseUrl={PLAYSPACE_BASE_URL}
token={fill.token}
mode="fill"
formId={fill.formId}
onEvent={(event) => {
if (event.type === 'form.submitted' && event.payload.status === 'completed') {
setFill(null)
}
}}
/>
</div>
) : null}
</div>
)
}
session.launch_requested
Payload: partnerAppointmentId, launchAttemptId.
This is a NOTIFICATION, not an instruction. Do not open a tab. The frame opens the session ITSELF, inside its own content pane, with the workspace navigation still beside it — so a host that also opens a tab puts the same clinician in the same session twice, on two devices, and the second arrival is the one that looks like a bug. The event stays because you may still want it: for your own call log, for your analytics, or to mint the PATIENT link, which a framed document never receives.
Where the clinician presses it. On an appointment row, in both the Video Session list and Calendar, where the control reads "Open session" — or "Start in-person tools" when that appointment is already an in-person one, which still raises this event and not session.in_person_requested.
What the frame does with it. It mints the acting clinician's OWN join credential on the embed tier, licensed by the session:launch capability, and renders the session in the content pane. The credential never crosses into your application, and it never appears in a URL, an attribute or a log on either side of the boundary. Mint session:launch on the workspace token, or the control is refused when pressed.
The patient's link is still yours to mint. POST /v1/partner/appointments/{partnerAppointmentId}/session-links, no body, with an Idempotency-Key header derived from launchAttemptId, using your partner credential and the appointments:read scope. The identifier in the payload is the partner_appointments identifier PlaySpace assigned and handed back when you created or promoted the appointment, which is exactly what that path takes — PlaySpace accepts no identifier of your own on create, so keep your own mapping from it back to your record. It answers clinician_video_url, patient_video_url, video_provider and waiting_room_enabled, minted fresh on every call.
Treat anything it returns as the credential it is: never render it as text, never put it in a DOM attribute, never log it, never store it. If you open the clinician link at all, open it in a new tab with noopener,noreferrer and understand that you are opening a SECOND seat beside the one the frame already opened.
An organisation that runs video elsewhere. When video_provider is None and the appointment is virtual, PlaySpace mints nothing — the frame says so in the content pane and the clinician joins your call the way they normally do. The event still reaches you. That covers the clinician's seat only: the patient always arrives on the standalone PlaySpace link you mint, which under None has no video pane, so your application has to deliver the patient's video call as well. If you would rather PlaySpace show your video to both parties, book the appointment with a supplied video pair instead; see Use your own appointment video.
Permissions the session needs from your page. The session runs one frame below the workspace you place, and every level must delegate. The SDK sets the allow attribute for you; if your page (or a CDN in front of it) sends its own Permissions-Policy header, it must name the PlaySpace origin for camera, microphone, display-capture, fullscreen, autoplay and picture-in-picture, or the header wins and the feature is denied with no prompt. See the SDK reference.
Ignoring the event is valid. The session still opens in the frame.
session.in_person_requested — deprecated, never sent
Status: deprecated. PlaySpace never emits this event. Nothing on your side is required, and a handler that still answers it will never run.
It used to fire when a clinician chose a client in the In-Person Tools area with nothing on the book, and answering it meant creating a 50-minute in-person appointment starting now, minting its links, and opening the clinician one. That was the wrong answer to the right problem: the session was already happening, so the appointment landed in your book unscheduled, collided with whatever the clinician really had booked (a second press came back 409 appointment-conflict by design), and had to be cancelled afterwards.
The framed workspace now starts that session itself, in its own content pane, and creates no appointment. Nothing about it reaches your book, which is correct — an ad-hoc session with a child already in the room is not a scheduled event.
What to do with an existing handler. Delete it, at your convenience. The event type stays declared in @playspace-health/embed so your code keeps compiling in the meantime; it is marked @deprecated and will never fire.
If your product needs a record of ad-hoc sessions in your own system, tell us — there is no event for it today, and inventing one from a deprecated request event would give you a notification you must not act on.
form.fill_requested
Payload: formId, partnerPatientId, requestAttemptId.
Where the clinician presses it. In the "Fill with a client" chooser on a form's preview page in Forms, by picking a client from it.
Why the frame cannot do it. The workspace token is clinician-bound and can never carry form:submit, because a submission is patient data and needs a subject. The subject is set when a token is minted and cannot be chosen in a browser.
Your call. POST /v1/partner/embed-tokens with capabilities form:read and form:submit, your origins, and patient_id set to the patient from the payload, using a delegated token. form:read travels with form:submit because the surface has to render before it can be answered. The patient is checked at mint against both your organisation and that clinician's roster, so one outside either is a 403 here rather than a token that fails later.
What you render. A second frame beside the workspace: FormEmbed in fill mode with that form's identifier. The patient-bound token never enters the workspace frame. Re-mint per press, and let form.submitted with status: "completed" tell you when to take the frame down.
form.send_requested
Payload: formId, partnerPatientId, requestAttemptId. No address travels, in either direction.
Where the clinician presses it. In the "Send to client" chooser on the same form preview page in Forms, directly below "Fill with a client".
Why the frame cannot do it. Same reason as fill, plus one more: the fill credential PlaySpace mints for the email must never reach your page.
Your calls. POST /v1/partner/embed-tokens with the single capability form:send, your origins and patient_id. Then POST {baseUrl}/api/embed/forms/{formId}/send with that embed token as the bearer, content-type: application/json, and an empty JSON body. This is the only call a host ever makes with an embed token rather than its partner credential, and the empty body is the point: naming a recipient is refused, because the recipient comes from the token. PlaySpace emails the address stored on the client record itself — the email you set on POST /v1/partner/patients or changed with PATCH /v1/partner/patients/{id} — so recipient-address-missing means that field is empty and the fix is to write an address there, not to change this call.
What comes back. 201 with data.submission_id, the pending response row. On refusal, an RFC 9457 problem document whose type ends in a stable slug — recipient-address-missing when PlaySpace holds no address for the client, not-found when the form or the client is unreachable by this token, feature-not-entitled when the practice does not have forms. Each deserves its own sentence in your interface.
What you render. A status line. There is no frame: PlaySpace mints the fill credential and hands it to the email vendor, and you never see the link, the address or the credential.
Two configuration writes that belong here too
PATCH /v1/partner/forms/{id} with { "shelf_ready": true }. A form has to be shared to playrooms and toolkits before the in-frame Rooms picker can place it anywhere. The picker reports shelf-readiness and cannot change it, deliberately — that is not something a browser-side token should do.
PATCH /v1/partner/session-config. Your organisation's session defaults: the video provider (including None, when you run video elsewhere), the waiting room, camera default, recording, branding, the enabled-features mask, and upgrade_enabled — whether the framed workspace offers eligible clinic owners the Upgrade to PlaySpace page, off unless you turn it on. This is an organisation-tier write, so an organisation token is the right credential. The fields are in the endpoint summary and the interactive reference.
Where to go next
One surface at a time, without the workspace, is Level 3, with a reference page for each: storybooks, forms, worksheets, games.
No frame at all, is Level 4.
Every event, mode and capability is in the @playspace-health/embed reference.