Surface: worksheets
A multi-page worksheet a clinician brings to a session: a PDF they already had, turned into something they can draw on, reorder and hand to a child. worksheet frames one read-only, worksheet-upload turns a PDF into a new one, worksheet-edit opens the real PlaySpace drawing editor, and worksheet-workspace frames the whole library with all of those inside it.
This is one Level 3 surface. The shape of the integration — a token minted on your server, a component given a callback, an event you act on — is the same for every surface on this site; what changes is the mode, the identifier and the events.
What it renders
mode |
What the clinician sees | The URL the SDK builds |
|---|---|---|
worksheet |
one worksheet, read-only | /embed/worksheets/{worksheetId} |
worksheet-upload |
a file picker; the chosen PDF becomes a worksheet in the clinician's library | /embed/worksheets/upload |
worksheet-edit |
one worksheet in the drawing editor: a page rail, pencil, text, stickers and images, saving as it goes | /embed/worksheets/{worksheetId}/edit |
worksheet-workspace |
the clinician's whole worksheet library, with its own navigation: the list, upload, a blank worksheet straight into the editor, and a reader with Edit, Duplicate, Add to my rooms, Rooms and Send to client | /embed/worksheets |
worksheet-workspace is the one mode here that navigates. It is the worksheets half of the Creative Suite area of Level 1 without the workspace navigation around it, so mount it once and leave mode alone: changing mode remounts the frame and throws away wherever the clinician had got to. Every control is drawn from the token, so a worksheet:read-only seat gets a library it can only read — a verb the seat cannot spend is absent rather than disabled. It emits ready once, at mount, and never again as the clinician moves between the library, the editor and a worksheet's rooms; those are screen changes inside one document, not loads. Every write still announces itself.
Duplicate copies the clinician's own worksheet into a new library row — its title (suffixed (copy)), description, category, tags, page images and drawings. It does not carry page text, a page background image or a slide render, and the copy is a fresh worksheet rather than a tracked derivative of the original. It spends worksheet:create, emits worksheet.created with the new identifier and page count, and is not offered over a client's annotated copy.
worksheet-upload takes no identifier — the worksheet does not exist yet. The clinician picks a PDF, PlaySpace renders its pages in the frame (nothing uploads the PDF itself), and the new worksheet is owned by the practitioner the token names. The frame then shows the finished worksheet, so worksheet:read alongside worksheet:create is what makes it a complete flow. Your own list of the clinician's worksheets is not refetched for you — that is what worksheet.created is for.
worksheet-edit is the clinician's own library worksheet, opened in the real editor, with the author of record always the practitioner the token names. The rail adds a page, reorders pages by dragging one thumbnail onto another, and deletes a page behind a confirm step — a worksheet always keeps its last page, so that control is not offered on a single-page worksheet. Reset view returns the canvas to the zoom the surface opened at, which is fitted to whatever height you gave the frame rather than a fixed 100%. There is no Save button: every change is written automatically about a second after it stops changing, and each confirmed write emits worksheet.saved.
A client's annotated copy is not editable here, and never listed. It is clinical content; these surfaces serve the clinician's library only.
The identifier it needs
worksheet and worksheet-edit take a worksheet identifier, which is PlaySpace's own — the id on a row from listWorksheets(), or the worksheetId an earlier worksheet.created handed you.
worksheet-upload and worksheet-workspace take none. The workspace navigates to a worksheet itself.
patientId is not required by any of the four. A worksheet is a practitioner-owned artifact, reused across a caseload. The single exception is worksheet:share, which emails one named client — that capability is refused at mint time without a patientId.
Capabilities
| Capability | What it adds |
|---|---|
worksheet:read |
required by all four modes. The editor cannot load the worksheet without it, the upload flow uses it to show the finished result, and the workspace needs it to list the library. |
worksheet:create |
a new worksheet in the clinician's library, three ways: the upload flow, New worksheet (a blank one, straight into the editor) and Duplicate on the reader. The last two exist only in worksheet-workspace. |
worksheet:write |
the drawing editor and its autosave; in worksheet-workspace also Add to my rooms and the Rooms picker on the reader. |
worksheet:delete |
a Delete button, behind a confirm step, in the editor's header. Leave it out and the button is not rendered at all. |
worksheet:generate |
a Sparkles button in the editor that opens the image generator. The only capability in PlaySpace that spends money on your account. |
worksheet:share |
Send to client on the read-only surface and on the workspace's reader, which asks PlaySpace to email that one patient a secure, time-limited link. Needs a patientId on the same token. |
Deleting is a separate capability from editing. Editing a worksheet and destroying it are different powers and the token says which you granted, so a host that wants an editor but not a destructive one simply does not ask for it. On confirmation the worksheet is removed from the clinician's library, the frame becomes a "worksheet deleted" panel, and worksheet.deleted fires once. Only the clinician's OWN library worksheets can be deleted — a copy a client has annotated is refused.
AI images are a separate capability, and the only one that spends money. With worksheet:generate the clinician describes a picture, picks a style, and chooses one of four results to place on the page. Leave it out and the button is not rendered, which is how a host mints a seat that may draw on worksheets and may not bill. It is checked again on the request, so hiding the button is presentation and the refusal is enforcement. Generation is additionally rate limited across the embed tier, so a burst can be refused even with the capability held.
Mint the token
The editor is the one surface on the platform worth a long token.
// playspace/worksheet-tokens.ts — server only.
import { createEmbedClient } from '@playspace-health/embed/server'
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
const HOST_ORIGINS = ['https://app.yourclinic.com']
declare function getDelegatedPartnerToken(practitionerId: string): Promise<string>
function client(practitionerId: string) {
return createEmbedClient({
baseUrl: PLAYSPACE_BASE_URL,
getAccessToken: () => getDelegatedPartnerToken(practitionerId),
})
}
/** Read-only, and able to email the worksheet to one named client. */
export async function mintWorksheetReaderToken(
practitionerId: string,
partnerPatientId: string
): Promise<string> {
const embed = await client(practitionerId).mintEmbedToken({
capabilities: ['worksheet:read', 'worksheet:share'],
origins: HOST_ORIGINS,
patientId: partnerPatientId,
})
return embed.token
}
/** A PDF becomes a worksheet, and the frame then shows the finished result. */
export async function mintWorksheetUploadToken(practitionerId: string): Promise<string> {
const embed = await client(practitionerId).mintEmbedToken({
capabilities: ['worksheet:create', 'worksheet:read'],
origins: HOST_ORIGINS,
})
return embed.token
}
/**
* The drawing editor is the one surface worth a long token: a clinician draws
* for as long as a session lasts, and a re-mint here would be a remount.
*
* `worksheet:generate` is the only capability in PlaySpace that spends money on
* your account. Leave it out and the editor renders without its Sparkles
* control; everything else in the editor is unaffected.
*/
export async function mintWorksheetEditorToken(practitionerId: string): Promise<string> {
const embed = await client(practitionerId).mintEmbedToken({
capabilities: [
'worksheet:read',
'worksheet:write',
'worksheet:delete',
'worksheet:generate',
],
origins: HOST_ORIGINS,
ttlSeconds: 3600,
})
return embed.token
}
Mint edit-mode tokens with ttlSeconds: 3600. Editing is a sitting, not a page view: the default fifteen minutes will expire under a clinician mid-session, and while nothing already saved is lost, autosave stops and the frame shows an expiry banner until the host reopens it. One hour is the maximum the API allows. It is worth the exception here precisely because a fetchToken re-mint is the better answer everywhere else — see below.
The access token you hand getAccessToken must be delegated — issued acting as one practitioner. A worksheet belongs to one clinician, and an organisation-wide token has nobody to act as.
Mount it
Worksheets mount through StorybookEmbed / createStorybookEmbed, which carries the storybook and worksheet modes together; the mode is what separates them.
'use client'
// playspace/worksheet-editor.tsx — browser.
import { StorybookEmbed } from '@playspace-health/embed/react'
import type { ReactElement } from 'react'
const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
/** Your own server route, wrapping the editor mint from the file above. */
declare function mintWorksheetEditorTokenAction(): Promise<string>
declare function markSaved(worksheetId: string, revision: number): void
declare function dropFromMyList(worksheetId: string): void
export function WorksheetEditor({ worksheetId }: { worksheetId: string }): ReactElement {
return (
<div style={{ height: 900 }}>
<StorybookEmbed
baseUrl={PLAYSPACE_BASE_URL}
fetchToken={mintWorksheetEditorTokenAction}
mode="worksheet-edit"
worksheetId={worksheetId}
onEvent={(event) => {
if (event.type === 'worksheet.saved') {
markSaved(event.payload.worksheetId, event.payload.revision)
}
if (event.type === 'worksheet.deleted') dropFromMyList(event.payload.worksheetId)
}}
/>
</div>
)
}
The upload flow is the same component with mode="worksheet-upload" and no identifier; the read-only surface is mode="worksheet" with a worksheetId; the whole library is mode="worksheet-workspace", also with no identifier.
Without React, all four mount through createStorybookEmbed(container, { baseUrl, fetchToken, mode, worksheetId }), and handle.destroy() takes them down.
Without the package at all, the frame is an ordinary iframe pointed at the URL from the table above, with the token in the query string:
<iframe
src="https://agentic-ps.playspace.health/embed/worksheets/WORKSHEET_ID/edit?token=EMBED_TOKEN"
title="PlaySpace worksheet"
allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
style="width: 100%; height: 900px; border: 0"
></iframe>
Percent-encode the identifier you interpolate. A bare iframe cannot renew its own token and hears no events unless you listen for them — Using the API from other languages is the whole path, including the small amount of plain JavaScript that replaces each thing the SDK was doing.
Events it emits
| Event | When it fires | Payload |
|---|---|---|
ready |
the surface mounted and is interactive. Fires on every load of the framed document, including a reload | { mode } |
worksheet.created |
a new worksheet exists — from a PDF, from a blank create, or from a Duplicate — the moment it exists and before the frame navigates to it | { worksheetId, pageCount } |
worksheet.ready |
the worksheet is readable | { worksheetId, pageCount } |
worksheet.saved |
an edit reached the server. One per confirmed save, with revision counting up from 1 within a frame load |
{ worksheetId, revision } |
worksheet.deleted |
the clinician deleted the worksheet | { worksheetId } |
worksheet.shared |
the clinician pressed Send to client and PlaySpace emailed the link | { worksheetId, shareId, expiresAt } |
error |
something worth surfacing; branch on code, never on message |
{ message, code?, severity?, retryable?, requestId? } |
worksheet.created fires before the frame navigates to the viewer, so a host that records the identifier there does not also have to wait for worksheet.ready.
worksheet.saved is the one event that repeats: the editor saves continuously, so 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 identifier and the counter, and two revisions are never deduplicated against each other.
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 identifier from your own list, because it will not resolve again and reopening it renders an error rather than an editor.
Worksheet payloads carry identifiers and counts only. Titles come from the clinician's own filename or the PDF's metadata and are treated as patient-adjacent: render them, but keep them out of logs, analytics properties and URLs.
The error codes this surface raises are worth handling individually, because they differ in what the clinician has lost:
| Code | Severity | What it means for the clinician |
|---|---|---|
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. |
worksheet.share_failed |
error | The worksheet was not emailed; nothing was sent. retryable: false when PlaySpace refused the send outright, and message then carries the instruction the frame is showing. |
Things that will bite you
- A fifteen-minute token under a drawing clinician. This is the surface where the default TTL is wrong. Ask for the full hour, or pass
fetchTokenand let the SDK re-mint into the running frame. - Re-minting by changing the
tokenprop. That rebuilds the iframe and throws away the canvas the clinician was working on.fetchTokenpushes a fresh token into the running frame without touching thesrc; atokenprop is for a credential whose subject genuinely changed. - Expecting a Save button. There is not one.
worksheet.savedis how you know a change landed. - A single-page worksheet has no delete-page control. A worksheet always keeps its last page; that is not a rendering bug.
- A share is refused, not queued, when the patient has no email address, and nothing you get back carries the link or the address. Re-sending extends the existing link rather than issuing a second one.
listWorksheets()covers the library only. A copy a client annotated is clinical content and is never returned, and it cannot be opened in the editor either.- Give the frame height. Around 900 pixels is comfortable for the editor; the canvas fits itself to whatever you give it, so a short frame produces a small drawing area rather than a scrollbar.
Related
- Level 3: single surfaces is the table of every framed surface, and where
tokenandfetchTokenare explained once for all of them. - Level 1: the whole workspace puts these same worksheet screens inside Creative Suite, with the rest of PlaySpace around them.
- Content and generation maps which content PlaySpace generates and which of it a partner can reach.
@playspace-health/embedcarries the full component, event and error reference.- The endpoint summary covers the server side: listing a clinician's worksheets, reading one, downloading it, and deleting one.