npm package: @statelyai/sdk
Description: Embed the [Stately editor](https://stately.ai) in your app and talk to the Stately Studio API. Zero dependencies, fully typed.
Latest version: 0.4.1

# @statelyai/sdk

Embed the [Stately editor](https://stately.ai) in your app and talk to the Stately Studio API. Zero dependencies, fully typed.

## Install

```bash
npm install @statelyai/sdk
```

## Authentication

The editor uses a pluggable authentication system. How you configure it depends on whether you're using the hosted Stately platform or self-hosting.

### With Stately (default)

An API key is required. To get one:

1. Go to your [Stately settings](https://stately.ai/settings)
2. Select the **API Key** tab
3. Click **Create API Key** (Project or Account scope)
4. Copy and store it securely

See the [Studio API docs](https://stately.ai/docs/studio-api) for more details.

Pass the key to the SDK:

```ts
const embed = createStatelyEmbed({
  baseUrl: 'https://stately.ai',
  apiKey: 'your-api-key',
});
```

The server validates the key against the Stately registry API.

### Self-hosting

When self-hosting the editor, authentication is controlled by the `AUTH_PROVIDER` environment variable on the server:

| Value | Behavior |
| ---------- | ----------------------------------------------------------- |
| `stately` | Validates tokens against the Stately registry API |
| `none` | Allows all requests (no authentication) |
| _(unset)_ | `none` in development, `stately` in production |

For a fully self-contained deployment with no Stately dependency:

```bash
AUTH_PROVIDER=none
```

### Custom authentication

You can implement your own auth by writing a custom `AuthValidator` — an async function that receives a token and returns whether it's valid:

```ts
import type { AuthValidator } from '@/lib/auth';

const myValidator: AuthValidator = async (token) => {
  const res = await fetch('https://my-auth-server.com/verify', {
    headers: { Authorization: `Bearer ${token}` },
  });
  return res.ok;
};
```

The built-in adapters are:

- **`createStatelyValidator(baseUrl?)`** — verifies against the Stately registry API (`/registry/api/v1/verify`)
- **`allowAllValidator`** — always returns `true`

To use a custom validator, call `getAuthValidator()` from `src/lib/auth` in the server-side page code, or replace the resolver logic in `src/lib/auth/index.tsx` to return your implementation.

### Environment variables

| Variable | Purpose |
| ----------------------- | -------------------------------------------- |
| `AUTH_PROVIDER` | Auth strategy: `stately`, `none`, or unset |
| `STATELY_API_KEY` | Server-side API key for Stately data fetching |
| `STATELY_API_URL` | Stately API base URL (server-side override) |
| `NEXT_PUBLIC_BASE_URL` | Public-facing base URL |

## Quick start

### Third-party embed (with API key)

```ts
import { createStatelyEmbed } from '@statelyai/sdk';

const embed = createStatelyEmbed({
  baseUrl: 'https://stately.ai',
  apiKey: 'your-api-key',
});

embed.mount(document.getElementById('editor')!);

embed.init({
  machine: myMachineConfig,
  mode: 'editing',
  theme: 'dark',
});
```

### Same-origin embed (cookie auth)

When the embed host and the editor share a domain (e.g. Stately Studio embedding the beta editor), Supabase session cookies handle auth automatically — no API key needed:

```ts
const embed = createStatelyEmbed({
  baseUrl: process.env.NEXT_PUBLIC_BETA_EDITOR_URL ?? window.location.origin,
});

embed.mount(container);
```

### Self-hosted (no auth)

When self-hosting with `AUTH_PROVIDER=none`, no token is required:

```ts
const embed = createStatelyEmbed({
  baseUrl: 'https://your-editor.example.com',
});

embed.mount(container);
```

### Comments

Comments are optional and integrator-configured. Pass a `comments` object to
`embed.init()` when you want Liveblocks-backed commenting enabled.

```ts
embed.init({
  machine: machineConfig,
  mode: 'editing',
  comments: {
    roomId: 'machine:checkout',
    publicApiKey: 'pk_live_...',
    userId: currentUserId ?? null,
  },
});
```

You can also use a custom auth endpoint instead of a public key:

```ts
embed.init({
  machine: machineConfig,
  comments: {
    roomId: 'machine:checkout',
    authEndpoint: '/api/liveblocks-auth',
    userId: currentUserId ?? null,
  },
});
```

`roomId` is required when comments are enabled. `userId` is optional and only
used for comment identity metadata.

## Module layout

The SDK ships direct root exports for the common entry points:

```ts
import {
  createStatelyInspector,
  createStatelyEmbed,
  createStatelyClient,
} from '@statelyai/sdk';
```

It also supports subpath imports when you want a narrower surface:

```ts
import { createStatelyClient } from '@statelyai/sdk/studio';
import { createStatelyInspector } from '@statelyai/sdk/inspect';
import { createStatelyEmbed } from '@statelyai/sdk/embed';
```

## Studio API client

```ts
import { createStatelyClient } from '@statelyai/sdk';

const studio = createStatelyClient({
  apiKey: process.env.STATELY_API_KEY,
});

const project = await studio.projects.get('project-id');
const machine = await studio.machines.get('machine-id', { version: '42' });
const extracted = await studio.code.extractMachines(sourceCode);
```

## API

### `createStatelyEmbed(options)`

Creates an embed instance.

| Option     | Type                             | Description                               |
| ---------- | -------------------------------- | ----------------------------------------- |
| `baseUrl`  | `string`                         | **Required.** Base URL of the Stately app  |
| `apiKey`   | `string`                         | API key for authentication (see [Authentication](#authentication)) |
| `origin`   | `string`                         | Custom target origin for postMessage      |
| `assets`   | `AssetConfig`                    | Asset upload configuration (see [Asset uploads](#asset-uploads)) |
| `onReady`  | `() => void`                     | Called when embed is ready                |
| `onLoaded` | `(graph) => void`                | Called when machine is loaded             |
| `onChange` | `(graph, machineConfig) => void` | Called on every change                    |
| `onSave`   | `(graph, machineConfig) => void` | Called on save                            |
| `onError`  | `({ code, message }) => void`    | Called on error                           |

### Embed methods

#### `embed.mount(container)` / `embed.attach(iframe)`

Mount creates an iframe inside a container element. Attach connects to an existing iframe.

```ts
// Create a new iframe
const iframe = embed.mount(document.getElementById('editor')!);

// Or attach to an existing one
embed.attach(document.querySelector('iframe')!);
```

#### `embed.init(options)`

Initialize the embed with a machine and display options.

```ts
embed.init({
  machine: machineConfig,
  format: 'xstate', // optional
  mode: 'editing', // 'editing' | 'viewing' | 'simulating'
  theme: 'dark', // 'light' | 'dark'
  readOnly: false,
  depth: 3,
  panels: {
    leftPanels: ['code'],
    rightPanels: ['events'],
    activePanels: ['code'],
  },
  comments: {
    roomId: 'machine:checkout',
    publicApiKey: 'pk_live_...',
  },
});
```

`comments` accepts:

| Field | Type | Description |
| ---------- | ---------- | ----------------------------------------------- |
| `roomId` | `string` | **Required.** Liveblocks room identifier |
| `publicApiKey` | `string` | Liveblocks public key |
| `authEndpoint` | `string` | Custom Liveblocks auth endpoint |
| `baseUrl` | `string` | Custom Liveblocks base URL for self-hosting |
| `userId` | `string \| null` | Optional user identity metadata |

#### `embed.updateMachine(machine, format?)`

Update the displayed machine.

#### `embed.setMode(mode)` / `embed.setTheme(theme)`

Change the embed mode or theme at runtime.

#### `embed.setSettings(settings)`

Update editor settings at runtime. Merges with existing settings.

```ts
embed.setSettings({
  appearance: { colorMode: 'light' },
  canvas: { showGrid: false },
});
```

Available core settings:

| Path | Type | Default |
|------|------|---------|
| `appearance.colorMode` | `'light' \| 'dark' \| 'system'` | `'dark'` |
| `canvas.showGrid` | `boolean` | `true` |
| `canvas.viewMode` | `'graph' \| 'list'` | `'graph'` |
| `canvas.enableSnapLines` | `boolean` | `true` |
| `canvas.dimUnselected` | `boolean` | `true` |
| `validation.showValidations` | `boolean` | `true` |
| `autolayout.autoEnabled` | `boolean` | `false` |
| `developer.devMode` | `boolean` | `false` |

#### `embed.export(format, options?)`

Export the current machine. Returns a promise.

```ts
const code = await embed.export('xstate', { version: 5 });
const json = await embed.export('json');
const mermaid = await embed.export('mermaid');
```

Supported formats: `xstate`, `json`, `digraph`, `mermaid`, `scxml`

#### `embed.on(event, handler)` / `embed.off(event, handler)`

Subscribe/unsubscribe to embed events: `ready`, `loaded`, `change`, `save`, `error`.

```ts
embed.on('change', ({ graph, machineConfig }) => {
  console.log('Machine changed', machineConfig);
});
```

#### `embed.toast(message, type?)`

Show a toast notification in the embed. Type: `'success' | 'error' | 'info' | 'warning'`

#### `embed.destroy()`

Tear down the embed. Removes listeners, rejects pending promises, and removes the iframe if it was created via `mount()`.

### Asset uploads

By default, dropped images are stored as base64 data URLs. To upload assets to your own storage, pass an `assets` config:

```ts
const embed = createStatelyEmbed({
  baseUrl: 'https://stately.ai',
  assets: {
    onUploadRequest: async (file, { stateNodeId }) => {
      // upload `file` (a File object) to your storage
      // return { url } at minimum
    },
    accept: ['image/*'],          // MIME patterns (default: ['image/*'])
    maxFileSize: 5 * 1024 * 1024, // bytes (default: 10 MB)
  },
});
```

| Option | Type | Description |
| --- | --- | --- |
| `onUploadRequest` | `(file: File, context: { stateNodeId: string }) => Promise<UploadResult>` | **Required.** Called when the editor needs to upload a file. Return the hosted URL. |
| `accept` | `string[]` | Accepted MIME types. Supports wildcards (`image/*`). Default: `['image/*']` |
| `maxFileSize` | `number` | Max file size in bytes. Default: `10_485_760` (10 MB) |

`UploadResult`:

```ts
interface UploadResult {
  url: string;                        // hosted URL (required)
  name?: string;                      // display name (defaults to filename)
  metadata?: Record<string, unknown>; // arbitrary metadata stored on the asset
}
```

#### Example: AWS S3 (presigned URL)

```ts
import { createStatelyEmbed } from '@statelyai/sdk';

const embed = createStatelyEmbed({
  baseUrl: 'https://stately.ai',
  assets: {
    onUploadRequest: async (file) => {
      // 1. Get a presigned upload URL from your backend
      const { uploadUrl, publicUrl } = await fetch('/api/s3/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
        }),
      }).then((r) => r.json());

      // 2. PUT the file directly to S3
      await fetch(uploadUrl, {
        method: 'PUT',
        headers: { 'Content-Type': file.type },
        body: file,
      });

      return { url: publicUrl, name: file.name };
    },
    accept: ['image/*'],
    maxFileSize: 10 * 1024 * 1024,
  },
});
```

#### Example: Cloudflare R2 (worker upload)

```ts
import { createStatelyEmbed } from '@statelyai/sdk';

const embed = createStatelyEmbed({
  baseUrl: 'https://stately.ai',
  assets: {
    onUploadRequest: async (file) => {
      const form = new FormData();
      form.append('file', file);

      // Upload via a Cloudflare Worker that writes to R2
      const { url } = await fetch('https://uploads.example.com/assets', {
        method: 'POST',
        body: form,
      }).then((r) => r.json());

      return { url, name: file.name };
    },
    accept: ['image/*', 'application/pdf'],
    maxFileSize: 25 * 1024 * 1024,
  },
});
```

If `onUploadRequest` throws or rejects, the editor shows an error toast. If no `assets` config is provided, files are stored as inline base64 data URLs (no network requests).