Content and generation

PlaySpace makes things. A storybook written for one child about the thing they are actually working on. A worksheet with imagery that matches it. A form built from a document a clinician uploaded. A game that did not exist an hour ago. A three-dimensional figure of a specific dog, because the child wants their dog in the sandtray.

All of it is generated on request, stored, attributed to the people involved, and exportable.


The attribution triple

Every generation call requires the same three fields, and every artifact carries them for the rest of its life.

{
  clinician: { externalId: 'staff_8842' },   // who made it — required
  subject:   { externalId: 'client_55130' }, // who it is for — optional
  session:   { externalId: 'appt_11923' },   // where it happened — optional
}

clinician is required because generation costs money and somebody has to own that. It is what makes per-clinician spend reporting possible and what appears against the artifact in every clinical context.

subject is what makes an artifact personal. Set it and the artifact appears in that client's library, in their session history, and in their export. Leave it null and you have made library content — a storybook for the clinic's shelf rather than for one child.

session ties it to a moment. Set it and the artifact shows up in that session's summary, which is how a clinician later remembers what they did in a particular appointment.

Everything downstream in this documentation — the client library, the export bundle, the spend report, the session summary — is a query over this triple. There is no second mechanism.


Storybooks

An illustrated therapeutic story, generated for one child, with characters that stay visually consistent from page to page.

const job = await playspace.storybooks.generate({
  clinician: { externalId: 'staff_8842' },
  subject:   { externalId: 'client_55130' },
  prompt: 'A story about starting at a new school, for a child who is worried nobody will sit with them at lunch.',
  pages: 8,
  ageGroup: '5-8',
  style: 'watercolour',
  characters: [
    { name: 'Nia',  role: 'protagonist', description: 'seven years old, box braids, yellow raincoat' },
    { name: 'Pip',  role: 'supporting',  description: 'a small grey cat with one white ear' },
  ],
  readingLevel: 'grade-2',
})

const storybook = await job.wait()

Character consistency is the hard part and it is handled. Naming characters up front produces a visual description that is carried into every page's image generation, so Nia's raincoat is the same yellow on page eight as on page one. Omit characters and they are inferred from the prompt and locked after the first page.

After generation:

await playspace.storybooks.get(storybook.id)                       // pages, text, images
await playspace.storybooks.pages.regenerate(pageId, { prompt })    // one page, keeping the rest
await playspace.storybooks.pages.updateText(pageId, { text })      // clinician edit
await playspace.storybooks.download(storybook.id, { format: 'pdf' })
await playspace.storybooks.assign(storybook.id, { subject })       // a personal copy that tracks reading position

Assignment creates a copy that belongs to the child. They read it between appointments and their position persists. The clinician sees how far they got.

Render it with <StorybookCreator /> and <StorybookReader />.


Worksheets

Multi-page interactive worksheets, with generated imagery, filled in together during a session or alone as homework.

const job = await playspace.worksheets.generate({
  clinician: { externalId: 'staff_8842' },
  subject:   { externalId: 'client_55130' },
  prompt: 'A feelings-identification worksheet using animals rather than faces.',
  pages: 3,
  ageGroup: '5-8',
  includeImagery: true,
})
await playspace.worksheets.assign(worksheetId, { subject, dueAt })
await playspace.worksheets.copies.list({ subject })     // what they filled in
await playspace.worksheets.download(copyId, { format: 'pdf' })

A completed copy is its own artifact, distinct from the blank worksheet it came from. The blank is library content with no subject; the copy has one.


Forms

Assessment and intake instruments. Two ways to make one.

From a prompt:

const job = await playspace.forms.generate({
  clinician: { externalId: 'staff_8842' },
  prompt: 'A brief caregiver intake covering sleep, appetite, school refusal and screen time.',
  fields: { max: 15 },
})

From a document a clinician already uses:

const job = await playspace.forms.extract({
  clinician: { externalId: 'staff_8842' },
  file: await fs.readFile('./intake.pdf'),
  filename: 'intake.pdf',
})

extract reads an existing portable-document or image intake sheet and produces a structured, fillable form from it — the single most common request from clinics with twenty years of paper.

Responses come back as structured data, never as a rendered blob:

const responses = await playspace.forms.responses.list({ subject })

responses[0].answers  // [{ fieldId, label, value, type }]
responses[0].score    // for scored instruments, or null

Render with <FormFill />. Assign for completion at home with playspace.forms.assign(formId, { subject, caregiver: true }), which routes the link to the caregiver rather than the child.


Generated games (PlayStudio)

A clinician describes a game and gets a playable one, in the session, in minutes.

const job = await playspace.studio.generate({
  clinician: { externalId: 'staff_8842' },
  subject:   { externalId: 'client_55130' },
  prompt: 'A two-player cooperative game where you take turns naming a feeling to move a boat across a river.',
  players: 2,
  duration: 'short',
})

const game = await job.wait({ timeout: '8m' })
game.playUrl

The result is a real playable application, launchable in a session like any catalog game, attachable to a playroom, and — subject to review — publishable to the clinic's shared library.

Render with <PlayStudio />.


Three-dimensional models

A figure that is not in the sandtray library. Generated from a description or from a photograph, and usable in the tray immediately.

// From text
const job = await playspace.models.generate({
  clinician: { externalId: 'staff_8842' },
  source: 'text',
  prompt: 'a small brown terrier with a red collar, sitting',
})

// From an image
const job = await playspace.models.generate({
  clinician: { externalId: 'staff_8842' },
  source: 'image',
  file: await fs.readFile('./dog.jpg'),
})

const model = await job.wait({ timeout: '6m' })
model.format   // 'glb'

This is the capability clinicians ask for by name. A child's own dog, their own house, the specific toy they will not be parted from — in the tray, in a session, without a modelling tool or a designer.

Render with <ModelStudio />.

A note on photographs. An uploaded image is used to produce the model and is then discarded. It is not retained, not used for training, and never appears in an export. If the photograph contains a person, do not upload it — the capability exists for objects, pets and places.


Jobs

Every generation call returns a job immediately. Nothing blocks.

const job = await playspace.storybooks.generate({ ... })

job.id        // 'job_5Rp1wKz'
job.status    // 'queued' | 'running' | 'complete' | 'failed' | 'cancelled'
job.progress  // 0–100
job.type      // 'storybook'

Three ways to find out it finished. Pick by where your code runs.

// In a script: block.
const result = await job.wait({ timeout: '5m' })

// In a request handler: poll and return.
const current = await playspace.jobs.get(job.id)

// In a worker: the change feed, and never poll a job identifier again.
await playspace.changes.subscribe({
  onChange: async (c) => { if (c.type === 'artifact.ready') await attach(c.artifact) },
})

Typical durations. Storybook two to four minutes for eight pages. Worksheet one to two. Form under thirty seconds. Generated game four to eight. Three-dimensional model three to six. Design your interface for minutes, not seconds — and note that both <ContentStudio /> and every individual generation component render their own progress state, so if you are using the React SDK you do not build a spinner at all.

Failures are ordinary. A model refuses a prompt, a generation times out, a source document is unreadable. Failed jobs carry a reason written for a clinician rather than an engineer, and they do not consume quota.


Quota

Generation is metered per organisation per period.

const usage = await playspace.usage.get()
// {
//   period: '2026-09',
//   storybooks: { used: 214, limit: 1000 },
//   worksheets: { used: 88,  limit: 500 },
//   forms:      { used: 41,  limit: 500 },
//   games:      { used: 12,  limit: 100 },
//   models:     { used: 37,  limit: 250 },
//   resetsAt: '2026-10-01T00:00:00Z'
// }

Exhaustion returns QuotaExceededError carrying resetsAt. Per-clinician consumption is available at playspace.usage.byClinician(), which is what you want if you intend to give practice administrators visibility over their own spend.

Warn at eighty percent. The React SDK gives you useUsage() for exactly this. A clinician who discovers an exhausted quota halfway through a session with a seven-year-old will not be reasonable about it, and they will call you rather than us.


Everything is queryable, and everything comes back out

The point of the attribution triple is that these three questions have one-line answers.

// What has this child made with us?
await playspace.artifacts.list({ subject: { externalId: 'client_55130' } })

// What has this clinician generated, and what did it cost?
await playspace.usage.byClinician({ clinician: { externalId: 'staff_8842' } })

// What happened in this appointment?
await playspace.sessions.summary({ externalId: 'appt_11923' })

And the fourth, which is the one that matters when a family leaves the practice or asks for their records: Portability and exports.