Surface: forms

Intake and assessment instruments a clinician authors once and reuses across a caseload. Forms is the widest of the framed surfaces: it covers picking a form, answering one, building one, deciding which rooms a form appears in, and the whole forms library with its own navigation.

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
list the clinician's published forms, to pick one out of /embed/forms
fill one form, answerable /embed/forms/{formId}
form-create the form builder /embed/forms/new
form-shelf which of the clinician's rooms one form appears in /embed/forms/{formId}/shelf
form-workspace the clinician's whole forms library, with its own navigation /embed/forms

list and form-workspace resolve to the same document, and the TOKEN decides which of the two it renders. A token carrying any of the four TEMPLATE capabilities — form:create, form:write, form:delete, form:compose — gets the workspace: the Templates and Responses tabs, search, the AI-generate and file-import tiles, and whichever row verbs the token carries — Duplicate on form:create, Edit on form:write, Delete on form:delete, Rooms on form:compose, and Send, which needs no template capability of its own. A verb the seat cannot spend is absent from the menu rather than disabled. A token carrying none of them gets the picker, unchanged. So nothing you already ship moves — a picker token cannot hold a template capability — and asking for one is how you opt in. The mode you pass is your declaration; the token is the fact.

form-workspace is the one framed surface here that navigates. It is the Forms 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. It is also the answer for a host that wants the whole Forms screen rather than the whole product. It emits ready once, at mount, and never again as the clinician moves between the library, the builder and a form's rooms — those are screen changes inside one document, not loads.

form-create is spelled with its surface prefix, not a bare create. That name belongs to the storybook flow, and it is what an unrecognised mode falls back to, so mode="create" on a <FormEmbed /> would quietly frame storybooks. The builder offers every field type the embedded form can actually render; table, signature and image are absent because the fill surface refuses a form containing one, and a file-upload field is absent because it needs a PlaySpace session the frame does not have.

form-shelf is the step between authoring a form and it being usable in a live session. A form only appears on a playroom or toolkit activity shelf if it has been placed there, and the clinician is the person who knows which room it belongs in — so this surface lists their own playrooms and toolkits with a switch each, and every toggle is its own idempotent request. It reports shelf-readiness and cannot change it: a form has to be shared to playrooms and toolkits before it can go on a shelf at all, and the frame says so up front rather than failing one switch at a time. That sharing is PATCH /v1/partner/forms/{id} with { "shelf_ready": true } from your server, or the clinician's own control in PlaySpace — deliberately not something a browser-side token can do.


The identifier it needs

fill and form-shelf take a form identifier, which is PlaySpace's own — the id on a row from listForms(), or the formId an earlier form.created or form.opened handed you.

list, form-create and form-workspace take none. The picker and the workspace navigate to a form themselves, and the builder has nothing to open yet.

patientId is required for fill whenever the token carries form:submit, and only then. A submission is a write to one child's clinical record, so the mint refuses to issue a submit token without naming the patient it will be recorded against; the framed surface has no patient picker and the browser cannot override the one on the token. patientId is a PlaySpace Partner API patient identifier, checked at mint time against both your organisation and the acting clinician's own roster — a patient who is neither is a 403 at the mint rather than a token that fails later.

fill with form:read alone still renders, read-only, with no way to save. That is the right token for a preview and the wrong one if you expected answers to be recorded.


Capabilities

Capability What it adds
form:read required by every mode here. Lists the clinician's published forms and loads one.
form:submit makes fill answerable. Patient-bound: the mint refuses it without a patientId.
form:create the builder, the AI-generate and file-import tiles in the workspace, and the row menu's Duplicate. A template capability.
form:write reopens an existing form in the builder and saves the edit. It cannot promote a draft to published and it does not shelve. A template capability.
form:delete the row menu's Delete. A soft delete: the form stops being listed and fillable and comes off every room it sat on, while responses already recorded against it stay readable. A template capability.
form:compose form-shelf, and Rooms on a workspace row. A template capability.
client:read optional, and it is what puts Fill out and Send to client on a workspace row. Both start by listing the clinician's clients so the clinician can choose who the form is for, and that roster read is what the capability buys.
form:send asks PlaySpace to email one named client a link to fill the form in. Patient-bound like form:submit, so it is never a workspace capability — you mint it per client on your own server.

The three authoring verbs are separate on purpose: a seat can be minted to author new forms without being able to rewrite the ones a clinician already relies on, or the reverse, and destroying a form is a third power again.

form:submit and form:send are not workspace capabilities. Both are bound to one named client, and the workspace's token is bound to a clinician, so the frame asks instead of acting: it emits form.fill_requested when the clinician picks one of your patients to fill a form in, and form.send_requested when they ask PlaySpace to email one. You answer each by minting the bound token on your own server — Level 2 is that pattern in full.

Those two row verbs are offered only when the token also carries client:read. It is optional, it never changes which of the two products the URL renders, and if you mint it, handle both events — nothing happens inside the frame after the press, so a clinician who presses one sees a control that appears to do nothing. If your organisation does not license Clients, the roster read is refused and each control says the clinician's clients could not be loaded; everything else on the surface keeps working.


Mint the token

The pair worth writing out puts the clinician-bound mint and the patient-bound mint side by side, because the difference between them is the whole design of this surface.

// playspace/form-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),
  })
}

/** Clinician-bound: the clinician's published forms, to pick from. No patient. */
export async function mintFormListToken(practitionerId: string): Promise<string> {
  const embed = await client(practitionerId).mintEmbedToken({
    capabilities: ['form:read'],
    origins: HOST_ORIGINS,
  })
  return embed.token
}

/** Patient-bound: one form, answerable. `patientId` is required by `form:submit`. */
export async function mintFormFillToken(
  practitionerId: string,
  partnerPatientId: string
): Promise<string> {
  const embed = await client(practitionerId).mintEmbedToken({
    capabilities: ['form:read', 'form:submit'],
    origins: HOST_ORIGINS,
    patientId: partnerPatientId,
  })
  return embed.token
}

/**
 * Clinician-bound: the whole forms library. Any ONE template capability is what
 * makes `/embed/forms` render the workspace instead of the picker; `client:read`
 * is optional and is what offers "Fill out" and "Send to client" on each row.
 */
export async function mintFormWorkspaceToken(practitionerId: string): Promise<string> {
  const embed = await client(practitionerId).mintEmbedToken({
    capabilities: [
      'form:read',
      'form:create',
      'form:write',
      'form:delete',
      'form:compose',
      'client:read',
    ],
    origins: HOST_ORIGINS,
  })
  return embed.token
}

The access token you hand getAccessToken must be delegated — issued acting as one practitioner. The SDK takes camelCase options and posts the API's snake_case body, so patientId goes on the wire as patient_id and ttlSeconds as ttl_seconds; if you call POST /v1/partner/embed-tokens over raw HTTP instead, send the snake_case names, because an unknown field is rejected rather than ignored.


Mount it

The picker and the fill surface are the pair a host usually builds first: the clinician chooses an instrument, then answers it for one child.

'use client'
// playspace/forms-panel.tsx — browser. One surface at a time, in your own layout.
import { useCallback, useEffect, useState } from 'react'
import type { ReactElement } from 'react'
import { FormEmbed } from '@playspace-health/embed/react'

const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'

/** Two calls into your own server, wrapping the two mints from the file above. */
declare function mintFormListTokenAction(): Promise<string>
declare function mintFormFillTokenAction(partnerPatientId: string): Promise<string>

export function FormsPanel({ partnerPatientId }: { partnerPatientId: string }): ReactElement {
  const [formId, setFormId] = useState<string | null>(null)
  const [fillToken, setFillToken] = useState<string | null>(null)

  // The list surface outlives no session, so a callback is the right shape:
  // the SDK re-mints before expiry and pushes the new token into the frame.
  const fetchListToken = useCallback(() => mintFormListTokenAction(), [])

  // The fill surface is patient-bound, so its token is minted once the patient
  // is known and pinned. Changing a `token` prop remounts the frame.
  useEffect(() => {
    let live = true
    if (!formId) return
    void mintFormFillTokenAction(partnerPatientId).then((token) => {
      if (live) setFillToken(token)
    })
    return () => {
      live = false
    }
  }, [formId, partnerPatientId])

  if (formId && fillToken) {
    return (
      <div style={{ height: 720 }}>
        <FormEmbed
          baseUrl={PLAYSPACE_BASE_URL}
          token={fillToken}
          mode="fill"
          formId={formId}
          onEvent={(event) => {
            if (event.type === 'form.submitted' && event.payload.status === 'completed') {
              setFormId(null)
              setFillToken(null)
            }
          }}
        />
      </div>
    )
  }

  return (
    <div style={{ height: 720 }}>
      <FormEmbed
        baseUrl={PLAYSPACE_BASE_URL}
        fetchToken={fetchListToken}
        mode="list"
        onEvent={(event) => {
          if (event.type === 'form.opened') setFormId(event.payload.formId)
        }}
      />
    </div>
  )
}

The workspace is the same component with mode="form-workspace", no formId, and the two request events wired up:

<FormEmbed
  baseUrl={PLAYSPACE_BASE_URL}
  fetchToken={mintFormWorkspaceTokenAction}
  mode="form-workspace"
  onEvent={(event) => {
    if (event.type === 'form.fill_requested') {
      openYourOwnFillSurface(event.payload.formId, event.payload.partnerPatientId)
    }
    if (event.type === 'form.send_requested') {
      askPlaySpaceToEmail(event.payload.formId, event.payload.partnerPatientId)
    }
  }}
/>

Without React, the same surfaces mount through createFormEmbed(container, { baseUrl, fetchToken, mode, formId }), 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/forms/FORM_ID?token=EMBED_TOKEN"
  title="PlaySpace form"
  allow="camera; microphone; fullscreen; display-capture; autoplay; picture-in-picture"
  allowfullscreen
  referrerpolicy="strict-origin-when-cross-origin"
  style="width: 100%; height: 720px; 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. The workspace fires it once, at mount, and not on its own internal navigation { mode }
form.opened a form was opened for filling. In list, choosing a form navigates the frame to it and the event arrives then { formId }
form.created a form was authored in the frame, as soon as the row exists { formId, fieldCount, status }
form.saved an existing form was edited in the frame. Needs form:write { formId, fieldCount, status }
form.deleted the clinician deleted the form. Needs form:delete { formId }
form.shelf_changed a form was placed on, or taken off, a playroom or toolkit shelf { formId, containerType, containerId, attached }
form.submitted a submission row exists { formId, submissionId, status }
form.fill_requested the clinician asked you to open a form for one patient { formId, partnerPatientId, requestAttemptId }
form.send_requested the clinician asked you to have PlaySpace email a form to one patient { formId, partnerPatientId, requestAttemptId }
error something worth surfacing; branch on code, never on message { message, code?, severity?, retryable?, requestId? }

form.created carries the authoring state in status'draft' or 'published' — and only a published form can be opened for filling or appears in listForms(), so check it rather than assuming a new form is usable. form.saved is the EDIT's event and carries the same payload shape: they are two events on purpose, because if you keep your own index of a clinician's instruments, a rename must not be counted as a new one. It fires once per successful save, so a clinician who edits the same form three times produces three form.saved events for one formId.

form.submitted carries status, which is 'in_progress' or 'completed'. Check it: a saved draft fires this event too, and treating any submission as a finished instrument will mark work complete that nobody finished.

form.deleted is terminal for that form. The row is soft-deleted, so the form stops appearing in listForms(), stops being fillable and comes off every room it sat on, while responses already recorded against it stay readable.

requestAttemptId on the two request events names the press, not the form. It is unique within one frame load, so use it to collapse a duplicate delivery in the moment rather than as an idempotency key you store, and it is counted separately for the fill control and the send control — a fill press and a send press on the same form never share one.

form.shelf_changed reports each successful toggle, and a room's name never travels on that channel — only the container's type and identifier.

A form's name never travels on this channel either, and neither do answers. The name is clinician free text, a submission is patient data, and PlaySpace keeps both inside the frame where the clinician reads them.


Answering the two requests

form.fill_requested. Mint a fill token for that patient on your own server — form:read plus form:submit, with patientId — and render the fill surface yourself, exactly as FormsPanel above does.

form.send_requested. PlaySpace does the work here. Mint an embed token carrying form:send for that patient from your own server, then call POST /api/embed/forms/{id}/send with it once. The request body carries no fields: the recipient is the token's, and so is the sender. PlaySpace mints the fill credential, builds the link and hands it to the email provider — you never receive the link, the address, or the credential. What comes back is the pending response's identifier, a status, and, on a refusal, a problem type whose last segment is a stable slug you can branch on. A form you cannot reach and a patient you cannot reach answer the same 404, deliberately, so the status cannot be used to discover which patients exist; a patient on the roster with no address on file is the distinct recipient-address-missing. Pressing again reuses the outstanding pending response rather than recording a second, so a resend is another email against one row.


Things that will bite you

  • A picker token that quietly became a workspace token. Adding form:create to an existing list integration changes what that URL renders. If you mean to keep the picker, do not mint a template capability.
  • A workspace mounted as a controlled component. Driving mode from your own state remounts the frame on every change and throws the clinician back to the library. Mount it once.
  • fill with no patientId. Asking for form:submit without one is a 422 at the mint, not a token that records answers attached to nobody.
  • A form the embed cannot render. listForms() rows carry embeddable and unsupported_field_types; a form using a field type the framed surface does not render yet comes back embeddable: false, and framing it is a 422 rather than a half-rendered instrument. They are listed rather than hidden so you can say why one is unavailable.
  • shelf_ready is not the same question as "is it listed". Being listed means the form is finished and fillable through the embed. shelf_ready means the clinician additionally shared it to their playrooms and toolkits, which is what makes it eligible to open from inside a live PlaySpace session — a separate step that is off by default, so expect false for most of a library. Eligible is not placed: placement is form-shelf, or the clinician's own control in PlaySpace.
  • The builder does not navigate after a save. It shows its own confirmation and leaves the next move to you, deliberately: the form's own page is the fill surface and needs a patient-bound token, so auto-navigating there would strand an authoring-only token on a 403.
  • Changing formId or token remounts the frame, half-answered questions included. Pass fetchToken so a re-mint reaches the running frame instead of rebuilding it, and pin token only where the token's subject genuinely changes — a different patient is a new frame.

  • Level 1: the whole workspace is the Forms area with the rest of PlaySpace around it. Forms is the only area with a standalone twin.
  • Level 2: host controls is how to answer form.fill_requested and form.send_requested from your own server, in full.
  • Level 3: single surfaces is the table of every framed surface, and where token and fetchToken are explained once for all of them.
  • @playspace-health/embed carries the full component, event and error reference.
  • The endpoint summary covers the server side: listing published forms, making one shelf-ready, deleting one, and reading submissions back.