Quickstart

From a credential to a clinician working inside your product. Every request below is real, every path is served, and every field name is the one the contract declares.

This is Level 1: the whole PlaySpace workspace framed in your page. It is the shortest path to something a clinician can use, and the levels above it are additions to what you build here rather than replacements for it.

You need a credential first. If you do not have one, get your credentials is the application, the review and the hand-over, and it takes about ten minutes of your time.

Environment Base URL
Development https://agentic-ps-dev.playspace.health
Production https://agentic-ps.playspace.health

Build against development. A credential is bound to the tenant that issued it, so there is no configuration in which this work reaches real clinical data.


1. Get an access token

Your credential is an OAuth2 client-credentials pair issued through Auth0. The token endpoint is on the authorisation server, not on the PlaySpace host, and each environment has its own — both are listed in step 5 of get your credentials. This quickstart uses the development one throughout.

POST /oauth/token HTTP/1.1
Host: dev-hcp1velit44csg3n.us.auth0.com
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "<your client id>",
  "client_secret": "<your client secret>",
  "audience": "https://playspace-ehr-api"
}

There is no scope field. Your grant is fixed on the credential at approval and travels inside the token. Asking for a permission the credential does not hold fails the exchange with 403 access_denied.

The response carries an access_token and its lifetime in seconds. It lives 24 hours. Cache it and reuse it until shortly before it expires; minting a token per request is the most common way to hit a rate limit that has nothing to do with your actual traffic.

The permission vocabulary is one scope per resource and verb across clinics, practitioners, patients and appointments, each with :read, :write and :delete. A call outside your granted set returns 403 with both the required and the granted lists in the problem document, so the fix is visible in the error.


2. Verify the token before anything else

GET /v1/partner/health HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
{
  "data": {
    "status": "ok",
    "partner_organization_id": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
    "checked_at": "2026-08-24T15:30:00.000Z"
  }
}

This endpoint requires no permissions and answers 200 whenever the token is accepted, so it separates "my credentials are wrong" from "my request is wrong" in one call. The organisation identifier is echoed back so you can confirm the token is bound to the tenant you expected.


3. Create a clinic

Clinics are the partition everything else belongs to. Every mutation on this API requires an Idempotency-Key header, so a timeout is a retry rather than a duplicate.

POST /v1/partner/clinics HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
Idempotency-Key: 4f1c1aa7-9e3c-4f9b-9d4f-8e2c8b6a1c3d
Content-Type: application/json

{
  "name": "Northgate Family Practice",
  "timezone": "America/Toronto"
}

The response envelope is { "data": ..., "meta": ... }. Keep data.id — it is the clinic identifier every later call refers to.


4. Create a practitioner

A practitioner belongs to exactly one clinic, and is the seat the framed workspace acts as. The email maps to a PlaySpace login identity, so an address already in use answers 409 rather than creating a second record.

country is optional, and it is an uppercase ISO 3166-1 alpha-2 code. Some PlaySpace features are only offered in certain countries, so a practitioner created without one is treated as outside every country-restricted feature until you set it — you can add or change it later with PATCH /v1/partner/practitioners/{id}, or clear it again by sending null.

POST /v1/partner/practitioners HTTP/1.1
Host: agentic-ps-dev.playspace.health
Authorization: Bearer <access token>
Idempotency-Key: 6a2d3f88-4b71-4c02-8e5a-1f9b7c0d3e42
Content-Type: application/json

{
  "first_name": "Dana",
  "last_name": "Okafor",
  "email": "dana.okafor@example-practice.com",
  "partner_clinic_id": "<clinic id from step 3>",
  "role": "member",
  "country": "US"
}

Keep data.id. That is the practitioner identifier the next step names.


5. Get a delegated token for that practitioner

Everything a clinician personally owns is reached with a delegated token: the same client-credentials request, against the same endpoint, with a practitioner_id field naming one of your practitioners. The authorisation server confirms the practitioner belongs to your organisation and binds it into the token, so the token can only ever touch that clinician's data.

POST /oauth/token HTTP/1.1
Host: dev-hcp1velit44csg3n.us.auth0.com
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "<your client id>",
  "client_secret": "<your client secret>",
  "audience": "https://playspace-ehr-api",
  "practitioner_id": "<practitioner id from step 4>"
}

Delegation is on by default for an organisation that registered through the application form. If PlaySpace has turned it off for yours, a delegated token is refused with 403 and problem type delegation-not-enabled.


6. Mint an embed token and frame the workspace

Two files. The first runs only on your server, because it holds the credential; the second runs in the browser and never sees one.

Both are TypeScript here. Neither has to be: the server half is one HTTPS call any language can make, and the browser half is an iframe. Using the API from other languages is the same step written that way.

// playspace/workspace-token.ts — server only. Never runs in a browser.
import { createEmbedClient } from '@playspace-health/embed/server'
import type { EmbedCapability } from '@playspace-health/embed'

const PLAYSPACE_BASE_URL = 'https://agentic-ps-dev.playspace.health'
const TOKEN_URL = 'https://dev-hcp1velit44csg3n.us.auth0.com/oauth/token'
const HOST_ORIGINS = ['https://app.yourclinic.com']

const CLIENT_ID = process.env.PLAYSPACE_CLIENT_ID!
const CLIENT_SECRET = process.env.PLAYSPACE_CLIENT_SECRET!

/** You supply this: it reads your own session and is the whole security boundary. */
declare function signedInPractitionerId(request: Request): Promise<string | null>

/** The whole workspace: the frame itself, plus one capability per area inside it. */
export const WORKSPACE_CAPABILITIES: EmbedCapability[] = [
  'shell:read',
  'note:read',
  'client:read',
  'appointment:read',
  'storybook:read',
  'storybook:create',
  'storybook:write',
  'storybook:delete',
  'worksheet:read',
  'worksheet:create',
  'worksheet:write',
  'worksheet:delete',
  'form:read',
  'form:create',
  'form:write',
  'form:delete',
  'form:compose',
  'playroom:read',
  'playroom:write',
]

const tokenCache = new Map<string, { token: string; expiresAt: number }>()

/** A delegated access token, cached per clinician until shortly before it expires. */
async function getDelegatedPartnerToken(practitionerId: string): Promise<string> {
  const cached = tokenCache.get(practitionerId)
  if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token

  const response = await fetch(TOKEN_URL, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      grant_type: 'client_credentials',
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      audience: 'https://playspace-ehr-api',
      // No `scope` field. Your grant is fixed on the credential at approval.
      practitioner_id: practitionerId,
    }),
  })
  if (!response.ok) throw new Error(`Token exchange answered ${response.status}`)

  const body = (await response.json()) as { access_token: string; expires_in: number }
  tokenCache.set(practitionerId, {
    token: body.access_token,
    expiresAt: Date.now() + body.expires_in * 1000,
  })
  return body.access_token
}

export async function mintWorkspaceToken(practitionerId: string): Promise<string> {
  const playspace = createEmbedClient({
    baseUrl: PLAYSPACE_BASE_URL,
    getAccessToken: () => getDelegatedPartnerToken(practitionerId),
  })

  const embed = await playspace.mintEmbedToken({
    capabilities: WORKSPACE_CAPABILITIES,
    origins: HOST_ORIGINS,
  })

  return embed.token
}

/** POST /api/playspace/workspace-token — the only PlaySpace route your browser calls. */
export async function POST(request: Request): Promise<Response> {
  const practitionerId = await signedInPractitionerId(request)
  if (!practitionerId) return new Response('Not signed in', { status: 401 })

  const token = await mintWorkspaceToken(practitionerId)
  return new Response(JSON.stringify({ token }), {
    status: 200,
    headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
  })
}

Then mount the frame. Without a framework:

// playspace/workspace-frame.ts — browser, no framework.
import { createShellEmbed } from '@playspace-health/embed'

const container = document.getElementById('playspace-workspace')
if (!container) throw new Error('No #playspace-workspace element on the page')

container.style.height = '820px'

const handle = createShellEmbed(container, {
  baseUrl: 'https://agentic-ps-dev.playspace.health',
  fetchToken: async () => {
    const response = await fetch('/api/playspace/workspace-token', { method: 'POST' })
    if (!response.ok) throw new Error(`Token route answered ${response.status}`)
    const body = (await response.json()) as { token: string }
    return body.token
  },
  onEvent: (event) => {
    if (event.type === 'error') console.warn('playspace embed', event.payload.code)
  },
})

/** Call this when your user signs out. Removing the frame does not revoke the token. */
export async function signOutOfPlaySpace(): Promise<void> {
  await handle.logout()
}

Or, in React:

'use client'
// playspace/workspace-frame.tsx — browser.
import { useCallback } from 'react'
import type { ReactElement } from 'react'
import { ShellEmbed } from '@playspace-health/embed/react'
import type { EmbedEvent } from '@playspace-health/embed'

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

/** Your own error tracker. */
declare function reportEmbedFailure(code: string | undefined, requestId: string | undefined): void

export function PlaySpaceWorkspace(): ReactElement {
  const fetchToken = useCallback(async (): Promise<string> => {
    const response = await fetch('/api/playspace/workspace-token', { method: 'POST' })
    if (!response.ok) throw new Error(`Token route answered ${response.status}`)
    const body = (await response.json()) as { token: string }
    return body.token
  }, [])

  const onEvent = useCallback((event: EmbedEvent): void => {
    if (event.type === 'error' && event.payload.severity !== 'warning') {
      reportEmbedFailure(event.payload.code, event.payload.requestId)
    }
  }, [])

  // The frame fills its container, so the container is where the height lives.
  return (
    <div style={{ height: 820 }}>
      <ShellEmbed baseUrl={PLAYSPACE_BASE_URL} fetchToken={fetchToken} onEvent={onEvent} />
    </div>
  )
}

Three things about that pair are worth knowing before you run it.

origins must be the origin of the page that holds the frame — scheme and host, no trailing slash, no path. It is checked when the token is minted, and it becomes the frame's frame-ancestors directive, so a page served from anywhere else cannot render the frame at all. For a first try that is your own local or preview origin, and never this documentation domain.

Give the container a height. The frame is 100% of whatever holds it, so a container with no height renders nothing and looks like a failure it is not.

fetchToken is called once to mount and again shortly before each expiry, and the fresh token is pushed into the running frame without reloading it. That is why an embed token can stay short-lived — fifteen minutes by default, one hour at most — while a therapy session runs far longer.


What you just built

An organisation, a clinic, a clinician, and the whole PlaySpace workspace running inside your own product as that clinician, with your Partner API credential never leaving your server.


Where to go next

Make the controls work. The framed workspace hands anything about one patient, and anything carrying a live join credential, back to you as a request event. Answering the four of them is Level 2.

Frame one thing instead of everything. A storybook reader, a form, a worksheet editor, a live sandtray: Level 3. Each has its own reference page — storybooks, forms, worksheets, games — and the workspace you just framed has one of its own.

Work without a frame. Patients, appointments, session links and backend generation are Level 4.

Understand the model. The tenancy boundary, the token shapes and the retry contract are in core concepts.

See every operation. The endpoint summary is the complete external surface, and the interactive reference carries the schemas.

Get ready for production. Testing, the going-live checklist and the move off the development host are in testing and going live.