@playspace/embed

The framework-agnostic browser transport underneath every PlaySpace surface.

npm

npm install @playspace/embed

Use this directly on Vue, Svelte, Angular, or no framework at all. On React, use @playspace/react — it is a thin component layer over exactly this.

Same surfaces, same events, same error taxonomy, no components.


Mount a surface

import { mount } from '@playspace/embed'

const surface = mount(document.querySelector('#play'), {
  surface: 'sandtray',
  fetchToken: () => fetch('/api/playspace/token').then((r) => r.text()),
  onEvent: (event) => console.log(event.type, event.payload),
  onError: (error) => console.error(error.code, error.requestId),
})

// later
surface.update({ saveId: 'art_5Kd2' })
surface.unmount()

Or from a content delivery network, with no build step:

<div id="play" style="width: 100%; max-height: 80vh; overflow: auto"></div>

<script type="module">
  import { mount } from 'https://cdn.playspace.health/embed/v1/index.js'

  mount(document.querySelector('#play'), {
    surface: 'sandtray',
    fetchToken: () => fetch('/api/playspace/token').then((r) => r.text()),
  })
</script>

The content delivery network path is immutable within a major version. We will not swap the runtime under a page you have already shipped.


Options

Option Type Notes
surface Surface Required. sandtray, dollhouse, whiteboard, games, playroom, storybooks, worksheets, forms, studio, models
fetchToken () => Promise<string> Required. Called on mount and again before expiry
onEvent (e) => void Lifecycle events
onError (e) => void The fixed error taxonomy. Must be idempotent
theme object accent, radius, font, colorScheme
locale string Defaults to the clinician's account setting, then en
props object Surface-specific, e.g. { saveId, storybookId, formId }

The returned handle exposes update(props) and unmount().


What this package actually does

It is small, and knowing what it does explains most of its behaviour.

Builds the frame source and keeps it stable. The src is rebuilt only when baseUrl, token, surface or the surface's own identity prop changes. Anything else — theme, locale, arbitrary props — travels over the message channel instead. A host re-render that changes the src remounts the frame and destroys in-progress work, which is why this rule exists and why you should preserve it if you write your own wrapper.

Renews the token silently. fetchToken is called at eighty percent of the current lifetime and the new token is delivered to a live frame without remounting it.

Verifies every message. Anything on the channel that is not ours — wrong source marker, wrong origin — is dropped. Unknown event types are dropped rather than thrown on, so a host built against today's contract keeps working against a newer surface.

Deduplicates events. Each event type carries a dedupe key, so a development-mode double-invoke does not deliver artifact.created twice. network_error deliberately opts out: a second transport failure is new information.


Events

onEvent: (event) => {
  switch (event.type) {
    case 'ready':            break  // { role, surfaces }
    case 'surface.opened':   break  // { surface }
    case 'surface.closed':   break  // { surface, durationMs }
    case 'participant.joined': break
    case 'participant.left':   break
    case 'artifact.created': break  // { artifactId, type } — exists, may be unfinished
    case 'artifact.ready':   break  // { artifactId, type } — finished and usable
    case 'job.progress':     break  // { jobId, percent }
    case 'session.ended':    break  // { durationMs, surfacesUsed }
  }
}

At-most-once, and never carrying clinical content — identifiers, counts, durations and enumerated values only.


Errors

token_expired, token_invalid, token_refresh_failed, origin_not_allowed, capability_missing, entitlement_denied, role_not_permitted, surface_unavailable, network_error, render_blocked.

Full causes and fixes: error reference.


Sizing

The host sets width and an optional maximum height. The surface owns its own height. A fixed container height clips.

<div id="play" style="width: 100%; max-height: 80vh; overflow: auto"></div>

Sandtray and dollhouse need 640 by 480.


Why an iframe

Worth stating plainly, because it is the first question every partner engineer asks.

Your protected health information stays yours and ours stays ours. A cross-origin frame is a real boundary, not a convention. Nothing in the surface can read your page, and nothing in your page can read a client's session.

No cookies, by design. Session cookies are not sent into a cross-site frame, Safari blocks third-party storage and Chrome partitions it. The token lives in the frame's memory for the life of the document, which is why renewal is a callback rather than a refresh cookie.

Your bundle does not carry a three-dimensional engine. The sandtray is a real-time rendered scene. Delivering it as components would put megabytes of engine into your application and couple your release cycle to ours.

Origins are allowlisted at mint. An origin no browser will honour as a frame source becomes a 422 you can read rather than a blank rectangle you have to diagnose. Register the domains your application is served from and a typo fails loudly in development.


Content Security Policy

If your application sends a Content-Security-Policy, allow the frame:

frame-src https://embed.playspace.health;

We set frame-ancestors on our side from the origins on the token, so both directions are explicit. Neither of us relies on the other getting it right.