Guide: Generate content from your backend

A clinician finishes a session, and by the next morning there is an illustrated storybook about exactly what the child is working on, attached to their chart in your system. No user interface, no iframe.


The attribution triple

Every generation call takes the same three fields, and every artifact carries them for life.

{
  clinician: { externalId: 'staff_8842' },   // required — somebody owns the spend
  subject:   { externalId: 'client_55130' }, // optional — makes it personal
  session:   { externalId: 'appt_11923' },   // optional — ties it to a moment
}

Omit subject and you have made library content for the clinic's shelf. Set it and the artifact appears in that client's library, their session history, and their export.

This is the only mechanism. Everything downstream — the client library, the export bundle, the spend report — is a query over these three fields.


Generate a storybook

const job = await playspace.storybooks.generate({
  clinician: { externalId: session.clinicianId },
  subject:   { externalId: session.clientId },
  session:   { externalId: session.appointmentId },
  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',
  readingLevel: 'grade-2',
  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' },
  ],
})

Name the characters. The description 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 — usually fine, but you have less control.

Write the prompt like a referral note, not a search query. "A story about starting at a new school, for a child who is worried nobody will sit with them at lunch" produces a far better book than "school anxiety". The specific worry is what the story needs.


Wait, three ways

Pick by where your code runs.

In a script or a queue worker — block.

const storybook = await job.wait({ timeout: '5m' })

In a request handler — return the job identifier and poll from the client.

return Response.json({ jobId: job.id })
// later
const current = await playspace.jobs.get(jobId)

In a long-running worker — the change feed, and never poll a job identifier again.

await playspace.changes.subscribe({
  cursor: await store.getCursor(),
  onChange: async (change) => {
    if (change.type === 'artifact.ready') {
      const artifact = await playspace.artifacts.get(change.artifact!.id)
      await attachToChart(artifact)
    }
  },
  onCursor: (cursor) => store.setCursor(cursor),
  interval: '30s',
})

The cursor advances only after your handler resolves, so a throw stops at the last successfully processed entry. Nothing lost, nothing silently skipped.

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 for minutes, not seconds.


Attach the result to your own record

const storybook = await job.wait()

const { url } = await playspace.storybooks.download(storybook.id, { format: 'pdf' })
const pdf = await fetch(url).then((r) => r.arrayBuffer())

await yourDocuments.create({
  clientId: session.clientId,
  title: storybook.title,
  kind: 'therapeutic-storybook',
  file: pdf,
  externalRef: { system: 'playspace', id: storybook.id },
})

Download links are short-lived and single-use. Request one when you are ready to stream it, not in advance.

Store externalRef so you can find the live artifact later — for regeneration, for a reading-progress check, or for an export.


Respect the quota

Generation is metered per organisation per period, and there is no soft landing: an exhausted allowance is a hard 429.

const usage = await playspace.usage.get()
const { used, limit } = usage.resources.storybooks

if (used / limit > 0.8) {
  await notifyPracticeAdmin(`PlaySpace storybook allowance at ${Math.round((used / limit) * 100)}%`)
}

Warn at eighty percent in your own interface. A clinician who discovers an exhausted quota halfway through a session with a seven-year-old will contact you, not us.

Handle exhaustion as a scheduling problem rather than an error:

import { QuotaExceededError } from '@playspace/sdk'

try {
  await playspace.storybooks.generate({ ... })
} catch (error) {
  if (error instanceof QuotaExceededError) {
    await queue.scheduleAt(error.resetsAt, { job: 'generate-storybook', args })
    return
  }
  throw error
}

A failed generation does not consume quota, so retrying after a failure is free.


A nightly batch, done properly

const sessions = await yourDb.sessionsCompletedYesterday()

for (const s of sessions) {
  if (!s.storybookRequested) continue

  const usage = await playspace.usage.get()
  if (usage.resources.storybooks.used >= usage.resources.storybooks.limit) {
    log.warn('storybook allowance exhausted, stopping batch', { resetsAt: usage.resetsAt })
    break
  }

  await playspace.storybooks.generate({
    clinician: { externalId: s.clinicianId },
    subject:   { externalId: s.clientId },
    session:   { externalId: s.appointmentId },
    prompt: s.storybookPrompt,
    pages: 8,
    idempotencyKey: `storybook:${s.appointmentId}`,   // one per appointment, ever
  })
}

The explicit idempotency key is the important line. Keyed on your appointment identifier, a re-run of the batch after a partial failure will not generate a second book for an appointment that already has one — the original response is replayed instead. Without it, a retried batch doubles your spend.

Do not await job.wait() inside the loop. Submit everything, then let the change feed tell you what finished.


The other generators

Same shape, same attribution, same job.

await playspace.worksheets.generate({
  clinician, subject,
  prompt: 'A feelings-identification worksheet using animals rather than faces.',
  pages: 3, ageGroup: '5-8', includeImagery: true,
})

await playspace.forms.generate({
  clinician,
  prompt: 'A brief caregiver intake covering sleep, appetite, school refusal and screen time.',
  maxFields: 15,
})

// Build a form from the paper sheet a clinic has used for twenty years
await playspace.forms.extract({
  clinician,
  file: await fs.readFile('./intake.pdf'),
  filename: 'intake.pdf',
})

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

await playspace.models.generate({
  clinician,
  source: 'text',
  prompt: 'a small brown terrier with a red collar, sitting',
})

On model generation from a photograph. The image is used to produce the model and then discarded — not retained, not used for training, never in an export. The capability exists for objects, pets and places. Do not upload an image containing a person.