Surface: storybooks
An illustrated story written for one child. create frames the authoring flow; reader frames a finished book; storybook-workspace frames the whole library with both inside it. All are practitioner-owned — the book belongs to the clinician the token names, and it stays in that clinician's library whether your application ever mentions it again or not.
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 |
|---|---|---|
create |
the storybook authoring flow: the clinician describes what the child is working on, and the book illustrates in the frame | /embed/storybooks |
reader |
one finished storybook, read-only, page by page | /embed/storybooks/{storybookId} |
storybook-workspace |
the clinician's whole storybook library, with its own navigation: the list, New storybook, the illustrating screen, and a reader with Add to my rooms, Rooms and Send to client | /embed/storybooks/workspace |
storybook-workspace is the one mode here that navigates. It is the storybooks 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 storybook: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 authoring screen and a book's rooms; those are screen changes inside one document, not loads. Its own url rather than the bare /embed/storybooks, because that url already serves create and no token could tell the two apart.
Illustration takes minutes, not seconds, and the frame stays on it. The storybook row exists from the moment the generation is accepted, which is why storybook.created arrives before any page has been written — record the identifier there and you still have it if the clinician closes the frame while it is illustrating.
The reader carries its own chrome: a title bar, a New storybook control when the token can create one, and Send to client when the token carries storybook:share and names a patient. Publishing a finished book and putting it on a room's shelf are storybook:write, offered on the reader inside storybook-workspace and inside the framed workspace — neither create nor reader offers them.
The same two surfaces carry an Edit control on a finished book, which opens the editor: rewrite a page's text, regenerate its illustration and keep or discard the preview, add a page (written by the clinician or generated from a prompt), reorder the pages, and delete the book. The text, illustration-accept and reorder edits are storybook:write; Regenerate image and Add page each spend a generation and are storybook:create, so a seat without it sees neither control; Delete is storybook:delete, its own capability that storybook:write never implies, so a host can mint an editor without the destructive half. A control the seat cannot honour is absent, not disabled. Every confirmed edit emits storybook.saved; a delete emits storybook.deleted.
The identifier it needs
reader takes a storybook identifier, which is PlaySpace's own — the id on a row from listStorybooks(), or the storybookId an earlier storybook.created handed you. There is no way to address a book by an identifier of yours.
create and storybook-workspace take no identifier. There is nothing to open yet, and the workspace navigates to a book itself.
patientId is not required by either mode, and that is the point of a storybook: it is a practitioner's artifact, reused and adapted across a caseload, not a row in one child's record. The single exception is storybook:share, which emails one named client — that capability is refused at mint time without a patientId.
Capabilities
| Capability | What it adds |
|---|---|
storybook:read |
required by both modes. The reader cannot load a book without it, and the create flow uses it to show the finished result. |
storybook:create |
the authoring flow itself, and the New storybook control in the reader. |
storybook:share |
Send to client in the reader, which asks PlaySpace to email that one patient a secure, time-limited link. Needs a patientId on the same token; the frame shows no recipient picker, because the recipient was decided on your server. |
storybook:write |
publishes and shelves a book that already exists: Add to my rooms and the Rooms picker on the reader inside storybook-workspace and inside the framed workspace. Neither create nor reader offers it. |
Mint the narrowest set the screen needs. A reader panel beside a chart wants storybook:read alone; adding storybook:create puts a create control on it, which is a product decision rather than a technical one.
Mint the token
// playspace/storybook-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),
})
}
/** The authoring flow. No patient: a storybook belongs to the clinician. */
export async function mintStorybookAuthoringToken(practitionerId: string): Promise<string> {
const embed = await client(practitionerId).mintEmbedToken({
capabilities: ['storybook:read', 'storybook:create'],
origins: HOST_ORIGINS,
})
return embed.token
}
/** A reader that can also email the book to one named client. */
export async function mintStorybookReaderToken(
practitionerId: string,
partnerPatientId: string
): Promise<string> {
const embed = await client(practitionerId).mintEmbedToken({
capabilities: ['storybook:read', 'storybook:share'],
origins: HOST_ORIGINS,
patientId: partnerPatientId,
})
return embed.token
}
The access token you hand getAccessToken must be delegated — issued acting as one practitioner. PlaySpace reads the acting clinician from the token's own claim and refuses an organisation-wide one, because the book created in the frame has to belong to somebody. mintEmbedToken returns practitioner_id so you can record attribution against what PlaySpace resolved rather than against what you sent.
Mount it
'use client'
// playspace/storybook-panel.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 mint from the file above. */
declare function mintStorybookAuthoringTokenAction(): Promise<string>
declare function recordStorybook(storybookId: string): void
declare function markReadable(storybookId: string): void
export function StorybookAuthoringPanel(): ReactElement {
return (
<div style={{ height: 900 }}>
<StorybookEmbed
baseUrl={PLAYSPACE_BASE_URL}
fetchToken={mintStorybookAuthoringTokenAction}
mode="create"
onEvent={(event) => {
if (event.type === 'storybook.created') recordStorybook(event.payload.storybookId)
if (event.type === 'storybook.ready') markReadable(event.payload.storybookId)
}}
/>
</div>
)
}
The reader is the same component with mode="reader" and a storybookId:
<StorybookEmbed
baseUrl={PLAYSPACE_BASE_URL}
fetchToken={mintStorybookReaderTokenAction}
mode="reader"
storybookId={storybookId}
onEvent={handleEvent}
/>
Without React, the same two surfaces mount through createStorybookEmbed(container, { baseUrl, fetchToken, mode, storybookId }), 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/storybooks/STORYBOOK_ID?token=EMBED_TOKEN"
title="PlaySpace storybook"
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 } |
storybook.created |
the generation was accepted and the row exists — before any page is written | { storybookId } |
storybook.ready |
the book is readable | { storybookId, title, pageCount } |
storybook.shared |
the clinician pressed Send to client and PlaySpace emailed the link | { storybookId, shareId, expiresAt } |
storybook.saved |
one confirmed edit in the storybook-workspace editor: a page's text, an accepted illustration, an added page or a new page order. One delivery per edit |
{ storybookId, pageCount } |
storybook.deleted |
the clinician deleted the book from inside the storybook-workspace editor. Terminal for that id |
{ storybookId } |
error |
something worth surfacing; branch on code, never on message |
{ message, code?, severity?, retryable?, requestId? } |
title on storybook.ready is model-generated from the clinician's prompt and can echo details of the child the story was written for. Render it; treat it as patient-adjacent everywhere else — not in a log, not in an analytics property, not in a URL.
The error codes this surface can raise are storybook.load_failed from inside the frame, storybook.share_failed when a send was refused, and the embed.* token codes the SDK raises on your own page. storybook.share_failed with retryable: false means PlaySpace refused the send outright — the patient has no address on file, is no longer on that clinician's roster, or the book is not ready — and message then carries the instruction the frame is showing the clinician.
Things that will bite you
- A share is refused, not queued, when the patient has no email address. The address comes off the patient's record and neither the frame nor the server call accepts one, so a patient created without an address cannot be sent anything until the record carries one.
- Re-sending extends the existing link rather than issuing a second one. A clinician who presses Send to client twice has not created two links to chase.
- Nothing you get back carries the link. The share response and the
storybook.sharedevent carry identifiers and an expiry; the link is a live credential and only the patient ever holds it. - Give the frame height. Around 900 pixels is comfortable for the create flow; below that it scrolls rather than breaking.
- Changing
storybookIdortokenremounts the frame. PassfetchTokenso a re-mint reaches the running frame instead of rebuilding it.
Related
- Generate a storybook is this surface end to end, with the server-side generation path beside it for comparison.
- Content and generation maps which content PlaySpace generates and which of it a partner can reach.
- Level 3: single surfaces is the table of every framed surface, and where
tokenandfetchTokenare explained once for all of them. @playspace-health/embedcarries the full component, event and error reference.- The endpoint summary covers the server side: listing a clinician's storybooks, generating one without a frame, and downloading a cover or a PDF.