Guide: Generate a storybook
A clinician sits in your application, describes what a child is working on, and a few minutes later there is an illustrated storybook they can read with them — created inside a PlaySpace surface framed in your page, owned by that clinician, and listed back to you through your own server.
This is the one generation capability on the partner surface. Worksheets, forms, games and three-dimensional models are generated inside PlaySpace and are not reachable from a partner application; see Content and generation for the full reach map.
The framed half of this guide is a Level 3 integration, and the backend generation at the end of it is Level 4. The storybooks surface reference is the shorter companion to this guide — the capabilities, the events and the traps, without the walkthrough.
What you are building
Three pieces, and the order matters.
- A server route of your own that mints a short-lived embed capability token. Your partner credential never leaves your server.
- A framed PlaySpace surface in create mode, rendered with
@playspace-health/embed. The clinician writes the prompt and picks the settings inside it. - A record on your side of what they made, built from the events the frame reports and the list your server can read back.
Before you start
-
An approved partner organisation with credentials for the environment you are working in. See Authentication.
-
A delegated token. Every call in this guide reads or writes one practitioner's own content, so the partner token you hand the SDK must be issued acting as a specific practitioner. An organisation-wide token is refused with a
403, not silently widened. -
Storybook generation enabled for the practice. It is a per-clinic entitlement. If it is off, generation returns
403wherever it is called from, including inside the frame. -
Your host origin registered at mint time, exactly — scheme and host, no trailing slash, no path. It becomes the frame's
frame-ancestors, and a mismatch means the browser refuses to render the surface at all. -
The
@playspace-health/embedpackage:npm install @playspace-health/embed. It is pre-release (0.x) — see the package page for the install notes.
Step 1 — Mint the token on your server
import { createEmbedClient } from '@playspace-health/embed/server'
const playspace = createEmbedClient({
baseUrl: 'https://agentic-ps.playspace.health',
getAccessToken: () => getMyDelegatedPartnerToken(), // must be DELEGATED
})
const embed = await playspace.mintEmbedToken({
capabilities: ['storybook:read', 'storybook:create'],
origins: ['https://app.yourclinic.com'],
ttlSeconds: 900,
})
embed.token // hand this to the browser
embed.practitioner_id // who PlaySpace resolved from the token's claim
The SDK never fetches or stores your credential — you hand it one through getAccessToken, so whatever you already do (a client-credentials exchange, a cached token, a vault lookup) keeps working unchanged.
Ask for both capabilities. storybook:create opens the authoring flow; storybook:read is what lets the frame render the finished book afterwards. Create alone gets you a surface that can start a story and then cannot show it.
Record practitioner_id rather than what you sent. It is the practitioner PlaySpace actually resolved from the token, which is the one who will own every book made in this frame.
Failures throw PlaySpaceApiError, carrying the API's problem document verbatim so you can branch on status and problem.type instead of parsing strings.
import { PlaySpaceApiError } from '@playspace-health/embed/server'
try {
const embed = await playspace.mintEmbedToken({ /* … */ })
} catch (error) {
if (error instanceof PlaySpaceApiError && error.status === 403) {
// No delegated claim on the token, or the practitioner is not yours.
}
throw error
}
Step 2 — Frame the create surface
import { StorybookEmbed } from '@playspace-health/embed/react'
<div style={{ height: 900 }}>
<StorybookEmbed
baseUrl="https://agentic-ps.playspace.health"
fetchToken={mintTokenOnMyServer}
mode="create"
onEvent={handleEvent}
/>
</div>
Pass fetchToken, not token. Embed tokens are short-lived on purpose — fifteen minutes by default, one hour at most — because they ride in an iframe URL. A clinician writing a book takes longer than that. fetchToken is called once to mount the frame and again shortly before each expiry, and the SDK pushes the fresh token into the running frame without touching the iframe src, so nothing in progress is lost.
mintTokenOnMyServer is a call to your own route, which does the Step 1 mint and returns the token string. Your partner credential still never reaches the browser.
A changed token prop remounts the frame and throws away whatever it was showing. That is the most common way this integration breaks: a host re-mints as a side effect of its own data refresh and the clinician loses a half-written book. Either use fetchToken, or pin the token in your own state and replace it only when its authority genuinely changes.
Give it height. Around 900 pixels for a comfortable create flow. It scrolls below that rather than breaking, but the flow is cramped. Resize with CSS and never by re-rendering the embed.
Step 3 — Record what the clinician made
import type { EmbedEvent } from '@playspace-health/embed'
function handleEvent(event: EmbedEvent) {
switch (event.type) {
case 'ready':
// event.payload.mode
break
case 'storybook.created':
// The row exists. No pages yet.
recordStorybook(event.payload.storybookId)
break
case 'storybook.ready':
// event.payload.storybookId, .title, .pageCount
markStorybookReady(event.payload.storybookId, event.payload.pageCount)
break
case 'error':
reportToYourErrorTracker(event.payload.message)
break
}
}
storybook.created fires as soon as the row exists — before any page is written. That is deliberate, and it is the event to write your own record from: a clinician who closes the frame while the book is still illustrating has still made a book, and you still hold its id.
storybook.ready carries the title. Storybook titles are model-generated from the clinician's prompt and can echo details of the child the story was written for. Render it in a clinical interface; keep it out of logs, analytics and support tickets. pageCount and the id are safe everywhere.
Every message is verified to come from the PlaySpace origin and from that specific iframe before it reaches your handler, and unknown event types are dropped — so a handler written today keeps working against a newer PlaySpace release.
Step 4 — List the library, and open a book
The frame tells you what happened while it was open. Your server tells you what the clinician has.
const { data, nextCursor, hasMore } = await playspace.listStorybooks({ limit: 25 })
for (const book of data) {
book.id
book.status // 'generating' | 'ready' | 'failed'
book.page_count
book.created_at
}
The list is scoped to the practitioner the delegated token acts as — there is no organisation-wide variant, by design.
To open one, render the same component in reader mode with the id. The token you already minted covers it, because it carries storybook:read.
<StorybookEmbed
baseUrl="https://agentic-ps.playspace.health"
fetchToken={mintTokenOnMyServer}
mode="reader"
storybookId={book.id}
onEvent={handleEvent}
/>
Only offer books whose status is ready. A book still generating has nothing to render yet.
Step 5 — End the session properly
Unmounting the iframe does not invalidate the token. It stays a live bearer credential until it expires, so when your user logs out, revoke it.
import { useRef } from 'react'
import type { StorybookEmbedHandle } from '@playspace-health/embed'
const embedHandle = useRef<StorybookEmbedHandle | null>(null)
<StorybookEmbed
baseUrl="https://agentic-ps.playspace.health"
fetchToken={mintTokenOnMyServer}
mode="create"
onHandle={(handle) => { embedHandle.current = handle }}
onEvent={handleEvent}
/>
// on host logout:
await embedHandle.current?.logout()
Revocation is server-side: a revoked token is refused by every embed endpoint from the next request on.
Without React
import { createStorybookEmbed } from '@playspace-health/embed'
const handle = createStorybookEmbed(container, {
baseUrl: 'https://agentic-ps.playspace.health',
fetchToken: mintTokenOnMyServer,
mode: 'create',
})
// later
handle.destroy()
Same events, same modes, same lifecycle. handle.logout() is available here too.
Generating without a frame
If nobody is sitting in front of the surface — an overnight batch, a book generated after every intake — start it from your backend instead.
POST /v1/partner/storybooks
Authorization: Bearer <delegated partner token>
Idempotency-Key: storybook-for-appointment-8f42c1
Content-Type: application/json
{
"prompt": "A brave fox who learns to ask a grown-up for help when they feel worried",
"settings": {
"target_age": "6-12",
"number_of_pages": "short",
"lines_per_page": "standard",
"style": "emotional",
"image_style": "watercolor"
}
}
The 201 is the book in its generating state with an empty page list. Poll GET /v1/partner/storybooks/{id} until status is ready or failed — there is no change feed and no callback. Then GET /v1/partner/storybooks/{id}/download for the PDF, or GET /v1/partner/storybooks/{id}/pages for the text and signed image links.
Idempotency-Key is mandatory here and it is the only thing standing between a retried batch and a second invoice. Key it on something stable in your own system. Generation cannot be cancelled once accepted, and there is no per-organisation quota to stop a runaway loop — see Content and generation.
The prompt is written into the book, so keep it to what the story needs. It is a clinical description of a child's situation and it reaches a model provider; that is inherent in the capability, and it is worth your clinicians understanding it.
Things that will bite you
- An organisation-wide token. Every call in this guide is delegated-only. The failure is a
403at mint, which is the good outcome; the bad one is discovering it in production because your delegation only happens for some code paths. - Origins that nearly match. A trailing slash,
httpwhere you registeredhttps, a preview deployment on a domain you never registered. The frame simply refuses to render, with nothing in your own logs to explain it. - Re-minting on every render. See Step 2. Use
fetchToken. - Treating
storybook.readyas the only event worth handling. You lose the id of every book a clinician abandons mid-generation, and those books still exist and still cost money. - Caching a signed image link. They expire. Re-read the page rather than storing the URL.
Next
- Embed a sandtray — the live two-seat surface, which follows the same mint-and-frame shape.
@playspace-health/embed— every mode, every event, and the surfaces beyond storybooks.- Testing and going live.