# toruk-embed

The official TORUK SDK and embeddable chat widget for the web.

`toruk-embed` ships two surfaces from the same package:

1. **`TorukClient`** — a typed TypeScript SDK for TORUK's prediction APIs. Unified `employees.*` namespace for chatflows and agentflows (`execute`, `stream`, `feedback`, `attach`, `vectorUpsert`, `getConfig`, `isStreamAvailable`), plus `sessions.*` and `artifacts.*`. Explicit `AuthConfig`, capability negotiation, response-envelope normalization, error mapping. First-class React bindings via `toruk-embed/react`.
2. **Embeddable chat widget** — `registerWebComponents`, `execute`, `executeFull`, `executeFromConfig`. The SolidJS-based Web Component, driven by the same typed client. It renders Dynamic UI blocks, artifacts and markdown at parity with TORUK Core's own chat UI, in English or Arabic, with every feature gated by what the deployment advertises.

---

## Table of Contents

- [Installation](#installation)
- [SDK — `TorukClient`](#sdk--torukclient)
  - [Migration from 0.2.x](#migration-from-02x)
  - [Quick Start](#sdk-quick-start)
  - [Authentication](#authentication)
  - [User access tokens in the widget](#user-access-tokens-in-the-widget)
  - [Do not ship API keys to the browser](#do-not-ship-api-keys-to-the-browser)
  - [`employees.execute`](#employeesexecute)
  - [`employees.stream`](#employeesstream)
  - [`employees.getConfig`](#employeesgetconfig)
  - [Capability negotiation](#capability-negotiation)
  - [`employees.isStreamAvailable`](#employeesisstreamavailable)
  - [`employees.feedback`](#employeesfeedback)
  - [`employees.attach`](#employeesattach)
  - [Other runtime methods](#other-runtime-methods)
  - [Speech](#speech) — STT, TTS, and the audio playback lifecycle
  - [`employees.vectorUpsert`](#employeesvectorupsert)
- [Sessions — `sessions.*`](#sessions--sessions)
  - [Visitor identity](#visitor-identity)
  - [`sessions.create`](#sessionscreate)
  - [`sessions.list`](#sessionslist)
  - [`sessions.get`](#sessionsget)
  - [`sessions.getMessages`](#sessionsgetmessages)
  - [`sessions.rename`](#sessionsrename)
  - [`sessions.delete`](#sessionsdelete)
  - [`sessions.truncateMessages`](#sessionstruncatemessages)
  - [`sessions.listBranches`](#sessionslistbranches)
  - [`sessions.switchBranch`](#sessionsswitchbranch)
- [Artifacts — `artifacts.*`](#artifacts--artifacts)
  - [Everything is addressed through a session](#everything-is-addressed-through-a-session)
  - [`artifacts.list`](#artifactslist)
  - [`artifacts.get`](#artifactsget)
  - [`artifacts.download`](#artifactsdownload)
  - [Public and private deployments](#public-and-private-deployments)
  - [Sessions in the widget](#sessions-in-the-widget)
  - [REST / cURL](#rest--curl)
  - [Error Handling](#error-handling)
  - [Response Envelope](#response-envelope)
  - [Subpath Entries](#subpath-entries)
  - [`fromEnv` helper (Node)](#fromenv-helper-node)
  - [SDK ↔ Backend wire mapping](#sdk--backend-wire-mapping)
- [PreviewerAPI — `createTorukPreviewer`](#previewerapi--createtorukpreviewer)
- [React Bindings — `toruk-embed/react`](#react-bindings--toruk-embedreact)
- [Widget vs Headless](#widget-vs-headless)
- [What the widget renders](#what-the-widget-renders)
  - [Dynamic UI](#dynamic-ui)
  - [Artifacts in the chat](#artifacts-in-the-chat)
  - [Markdown](#markdown)
- [Legacy Widget API](#legacy-widget-api) ← preserved from 0.1.x
  - [Usage — Script Tag (CDN)](#usage--script-tag-cdn)
  - [Usage — npm Module](#usage--npm-module)
  - [Templates](#templates)
  - [API Reference](#api-reference)
  - [Feature switches](#feature-switches)
  - [Language](#language)
  - [BotProps](#botprops)
  - [Theme Variants](#theme-variants)
  - [observersConfig](#observersconfig)
- [Demo Server](#demo-server)
- [License](#license)

---

## Installation

```bash
npm install toruk-embed
# or
pnpm add toruk-embed
# or
yarn add toruk-embed
```

### Which entry do I import?

| You want                                | Import                                                                                          |
| --------------------------------------- | ----------------------------------------------------------------------------------------------- |
| The widget, in as few lines as possible | `import Torukworkflow from 'toruk-embed/web'` — **default** export, self-registers the elements |
| The widget with typed lifecycle control | `import { createTorukPreviewer } from 'toruk-embed/previewer'`                                  |
| The widget in React                     | `import { TorukProvider, TorukPreviewer } from 'toruk-embed/react'`                             |
| The typed client only, no widget        | `import { TorukClient } from 'toruk-embed/browser'`                                             |
| The typed client in Node                | `import { TorukClient, fromEnv } from 'toruk-embed/node'`                                       |

> The package root (`'toruk-embed'`) exports the client **and** the widget functions as **named** exports — `import { executeFull } from 'toruk-embed'`. It has no default export, so `import Torukworkflow from 'toruk-embed'` yields `undefined`. Use `toruk-embed/web` when you want the default-export form.

---

# SDK — `TorukClient`

The typed TypeScript client for TORUK's prediction APIs.

> **Renamed in 0.3.0: `workflows.*` → `employees.*`.** The old names (`workflows`, `predict`, `workflowId`, `WORKFLOW_NOT_FOUND`, …) still work as deprecated aliases and emit a one-time `console.warn` on first use; they are removed in the next major. See [Migration from 0.2.x](#migration-from-02x) and [`MIGRATION.md`](./MIGRATION.md) for the full mapping.

The SDK is a client for **TORUK-CORE**'s prediction endpoint family. It handles authentication, header construction, response envelope normalization, and error mapping, so consumers don't have to wire raw HTTP calls.

## Migration from 0.2.x

```diff
- await toruk.workflows.predict({ workflowId: 'chatflow_abc', message: 'Hi' });
+ await toruk.employees.execute({ deploymentId: 'chatflow_abc', message: 'Hi' });
```

Quick reference:

| 0.2.x                   | 0.3.0                  |
| ----------------------- | ---------------------- |
| `toruk.workflows.*`     | `toruk.employees.*`    |
| `.predict(…)`           | `.execute(…)`          |
| `workflowId`            | `deploymentId`         |
| `WORKFLOW_NOT_FOUND`    | `TASK_NOT_FOUND`       |
| `WORKFLOW_BUILD_FAILED` | `TASK_BUILD_FAILED`    |
| `WorkflowPredictInput`  | `EmployeeExecuteInput` |

Wire body and URL paths are unchanged — `question`, `chatId`, `/api/v1/prediction/:id`, etc. all stay the same. Only SDK names changed.

## SDK Quick Start

```ts
import { TorukClient } from 'toruk-embed';

const toruk = new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'apiKey', apiKey: process.env.TORUK_API_KEY! },
});

// Run a chatflow OR an agentflow — same call, same shape
const result = await toruk.employees.execute({
  deploymentId: 'chatflow_abc', // or 'agentflow_xyz'
  message: 'Summarize this document',
  chatId: 'chat_xyz', // optional — omit it and Core mints one
});

if (result.success) {
  console.log(result.data.text); // bot reply
  console.log(result.data.chatId); // session id
  console.log(result.data.messageId); // optional message id
} else {
  console.error(result.error.code, result.message);
}
```

In Node-only contexts (server-to-server, CLIs, scripts), import from the dedicated entry to skip the browser-only widget code:

```ts
import { TorukClient, fromEnv } from 'toruk-embed/node';

const toruk = fromEnv();
// Reads TORUK_BASE_URL + TORUK_API_KEY (or TORUK_JWT) from process.env
```

---

## Authentication

The SDK supports the three authentication modes that TORUK-CORE accepts on its prediction routes:

| Mode     | When to use                                                                                                                                                         | Wire behavior                                                                                                                                                     |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey` | **Server-side only** — server-to-server, or your own proxy in front of TORUK-CORE. See [Do not ship API keys to the browser](#do-not-ship-api-keys-to-the-browser). | Sends `x-api-key`. On GET requests, also appends `?apikey=` as a query-param fallback so clients survive proxies that strip custom headers during CORS preflight. |
| `jwt`    | Apps where the user is already logged into TORUK and the host has an access token.                                                                                  | Sends `Authorization: Bearer <token>`. Calls a host-supplied `refresh` callback once on a `401` and retries.                                                      |
| `none`   | **The browser default.** A public deployment, or a private one reached through your own proxy.                                                                      | Sends no auth headers. Visitor identity still flows via `x-toruk-visitor`.                                                                                        |

```ts
// API key — server-side only
new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'apiKey', apiKey: process.env.TORUK_API_KEY },
});

// JWT bearer with token rotation
new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: {
    type: 'jwt',
    token: accessToken,
    refresh: async () => await refreshAccessToken(), // called once on 401
  },
});

// Public task (no credentials)
new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'none' },
});
```

> **Note on organization scoping.** The SDK does **not** send `x-organization-id` headers. TORUK-CORE derives the organization server-side from the API key or JWT membership — the client never needs to supply it.

### User access tokens in the widget

A deployment published with **User Access** in CORE's deployment wizard answers only a signed-in TORUK user, identified by their own access token, and runs every request — config, predictions, sessions, artifacts — as that user. API keys are not accepted for such a deployment.

When your visitors are already signed in to TORUK (directly, or through an app that logs them in with TORUK), hand the widget that user's access token:

```ts
executeFull({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://toruk.company.com',
  // Called on every request, so a rotated token is picked up. Return the current
  // TORUK access token your app already holds for the signed-in user.
  accessToken: () => session.torukAccessToken,
});
```

`accessToken` accepts a string or a function returning a string or a Promise of one, and is available on `execute`, `executeFull` and `executeFromConfig`. The widget sends it as `Authorization: Bearer` and CORE checks that the user belongs to the deployment's organization: their conversations and artifacts follow them across devices, and the widget hides nothing they are entitled to. Without a token, the widget reads `authMode: 'user'` from `GET /deployments/:id/config` and shows a sign-in notice instead of a composer. For the typed client and the previewer, the equivalent is `auth: { type: 'jwt', token, refresh }`.

### Do not ship API keys to the browser

Anything the browser sends, the browser holds. An API key placed in page JavaScript is readable in the source, in DevTools, in the network log, and in any CDN or proxy cache along the way — `expiresAt` shortens the window but does not close it.

**For a public deployment, send no credential at all.** This is the intended path for an embedded widget, and it is what the widget does by default:

```ts
new TorukClient({
  baseUrl: 'https://toruk.company.com',
  deploymentId: 'dep_abc',
  auth: { type: 'none' },
});
```

Visitor identity is separate from authentication. TORUK-CORE mints an opaque, signed, deployment-bound token in the `x-toruk-visitor` response header on first contact; the SDK stores and replays it so a visitor keeps their history. It grants no privileges and is not a credential — see [Visitor identity](#visitor-identity).

**For a private deployment, put the credential behind your own server** and let the SDK reach it through `onRequest`. The key lives in your backend's environment; the browser only ever talks to your origin:

```ts
// Browser: no key anywhere in this file.
new TorukClient({
  baseUrl: 'https://your-app.example.com/toruk', // your proxy, not TORUK-CORE
  deploymentId: 'dep_abc',
  auth: { type: 'none' },
  onRequest: async (request) => {
    // Attach your own session cookie / bearer, not a TORUK key.
    request.credentials = 'include';
  },
});
```

Your proxy forwards to TORUK-CORE with `x-api-key` from its own environment, and must forward the `x-toruk-visitor` header in both directions — dropping it mints a new visitor on every request and hides the user's own history from them. `demo/server.js` is a worked example of that forwarding.

If a user is already signed into TORUK, use `auth: { type: 'jwt' }` with a `refresh` callback instead of a proxy — the token is the user's own and is scoped to them.

> **If you must use a browser key anyway** — because no server tier exists — then use a **deployment-bound** key, never an organization-scoped one, set a short `expiresAt`, rotate it from a host-side endpoint, and treat it as public. TORUK-CORE accepts it via header and a query-param fallback on GETs. This is a fallback, not a recommendation.

---

## `employees.execute`

Run a chatflow or an agentflow without streaming. TORUK-CORE serves both entity types through the same endpoint, so the SDK accepts either UUID through the same method.

```ts
const result = await toruk.employees.execute({
  deploymentId: 'chatflow_abc', // or 'agentflow_xyz'
  message: 'Explain the uploaded document',
  chatId: 'chat_xyz', // optional; omit it on a new conversation and Core mints one
  overrideConfig: { variables: { region: 'eu-west-1' } }, // optional passthrough
  history: [{ role: 'user', content: 'Earlier turn' }], // optional
  signal: controller.signal, // optional AbortSignal
});

if (result.success) {
  result.data.chatId; // string — always present
  result.data.text; // string | undefined — bot reply
  result.data.messageId; // string | undefined
  result.data.sourceDocuments; // unknown[] | undefined
  result.data.usedTools; // unknown[] | undefined
  result.data.agentReasoning; // unknown | undefined
} else {
  result.error.code; // 'TASK_NOT_FOUND' | 'FORBIDDEN' | ...
  result.error.details; // unknown — backend-supplied detail payload
  result.message; // human-readable
}
```

### Input fields

| Field            | Type                      | Notes                                                                                                         |
| ---------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `deploymentId`   | `string`                  | UUID of the chatflow or agentflow. Sent as the URL path parameter, not a body field.                          |
| `message`        | `string`                  | The user message. Mapped to wire field `question` (TORUK-CORE's name for it).                                 |
| `chatId`         | `string`                  | Optional. Omit it and Core mints one, returned as `result.data.chatId`; pass it back on later turns.          |
| `overrideConfig` | `Record<string, unknown>` | Optional engine-side passthrough (variables, session overrides).                                              |
| `history`        | `ChatHistoryItem[]`       | Optional turn history.                                                                                        |
| `uploads`        | `EmployeeUpload[]`        | Optional JSON uploads (URL or data-URI). For binary file uploads, use [`employees.attach`](#employeesattach). |
| `leadEmail`      | `string`                  | Optional lead-capture email.                                                                                  |
| `action`         | `EmployeeAction`          | Optional humanInput continuation payload.                                                                     |
| `signal`         | `AbortSignal`             | Optional. Cancels the request.                                                                                |
| `headers`        | `Record<string, string>`  | Optional per-call custom headers.                                                                             |

---

## `employees.stream`

Stream tokens as they are generated via Server-Sent Events. Returns a `Promise` that resolves with `{ chatId, messageId }` when the stream ends. Omit `chatId` and Core mints one at request admission and reports it on the stream's `metadata` frame, so the resolved value is the key to continue the conversation with.

```ts
const controller = new AbortController();

const result = await toruk.employees.stream({
  deploymentId: 'chatflow_abc',
  message: 'Tell me a story',
  chatId: 'chat_xyz', // optional — resume a session
  signal: controller.signal, // optional — cancel mid-stream

  onStart: ({ chatId, messageId }) => {
    console.log('Stream started', chatId, messageId);
  },
  onToken: (token) => {
    process.stdout.write(token); // called for each text chunk
  },
  onDone: ({ chatId, messageId }) => {
    console.log('\nDone', chatId, messageId);
  },
  onError: (err) => {
    console.error(err.code, err.message);
  },
  onEvent: (ev) => {
    // catch-all — fires for every SSE frame: 'start', 'token', 'end'
  },
});

console.log(result.chatId, result.messageId);

// Cancel at any time:
controller.abort();
```

**Non-streaming fallback.** If the backend returns JSON instead of an SSE stream (task has `isStreaming: false`), `employees.stream()` automatically synthesizes `onStart`, `onToken`, and `onDone` from the JSON response — no change to the calling code needed.

All errors are surfaced as `TorukSdkError`. See [Error Handling](#error-handling) for the full code table.

---

## `employees.getConfig`

Fetch the public chatbot config for a task (theme, welcome message, etc.). Maps to `GET /api/v1/public-chatbotConfig/:id`.

```ts
const result = await toruk.employees.getConfig('chatflow_abc');

if (result.success) {
  console.log(result.data); // arbitrary config object
}
```

---

## Capability negotiation

`GET /deployments/:id/config` tells you what a deployment can actually do. Read it through `client.capabilities()` rather than guessing:

```ts
const result = await toruk.capabilities('dep_abc');
if (result.success) {
  const caps = result.data;
  caps.speechToText.enabled; // show a microphone button?
  caps.textToSpeech.enabled; // show a speak button?
  caps.textToSpeech.maxInputChars; // 4096 — reject longer text before sending
  caps.uploads.image.enabled;
  caps.uploads.image.fileTypes; // ['image/png', 'image/jpeg']
  caps.sessions.maxMessagesPerPage; // page, don't truncate
}
```

The returned object is **always fully populated** — every key present, every `enabled` a real boolean. You never write `caps?.textToSpeech?.enabled` and you never branch on which CORE version answered.

### Absent means disabled

`enabled: false` and a **missing key** are different signals on the wire. `false` means "this server knows the flag and it is off"; missing means "this server predates the flag". Both resolve to disabled here — the SDK never treats an unadvertised feature as "unknown, so try it".

That rule is what lets a new SDK run against an older CORE: with no `capabilities` block at all, every flag resolves to `false` and the widget behaves exactly as `0.6.0` did.

**One exception: `streaming`.** It falls back to the long-standing top-level `isStreaming`, and then to `true`. Streaming already worked before the capability envelope existed, so defaulting it off would remove working behaviour rather than withhold new behaviour.

`newChat` and `dynamicUi` share that exception. Both are switches the deployment owner sets in CORE's deployment wizard (see [Feature switches](#feature-switches)), both always worked before the envelope carried them, so an absent key resolves to `true` and only an explicit `enabled: false` turns them off.

### `contract.version`

Bumped only when the envelope's _shape_ changes in a way you must branch on. Adding a capability key is **not** a bump, because absent already means disabled. Read it with `resolveContractVersion(config)`; `0` means the server predates the field.

### Using it without the client

`resolveDeploymentCapabilities(configBody)` normalizes any `/config` body, and `hasCapabilityEnvelope(configBody)` tells you whether the server sent one at all — useful when you need to choose a _source_ rather than a value, and keep reading legacy config keys against an old server.

---

## `employees.isStreamAvailable`

Pre-flight check for whether a task supports streaming. Maps to `GET /api/v1/chatflows-streaming/:id`.

```ts
const result = await toruk.employees.isStreamAvailable('chatflow_abc');

if (result.success && result.data.isStreaming) {
  // safe to call employees.stream
}
```

---

## `employees.feedback`

Submit thumbs-up / thumbs-down (with optional free-text content) for a specific message. Maps to `POST /api/v1/deployments/:deploymentId/feedback`.

`rating` uses TORUK-CORE's enum values verbatim — `'THUMBS_UP'` / `'THUMBS_DOWN'`. Versions up to `0.6.0` declared these camelCase, which CORE rejects, so a rating sent through the client could not persist.

```ts
const result = await toruk.employees.feedback({
  deploymentId: 'chatflow_abc',
  chatId: 'chat_xyz',
  messageId: 'msg_123',
  rating: 'THUMBS_UP', // or 'THUMBS_DOWN'
  content: 'Nailed the cite.', // optional free-text
});

if (result.success) {
  result.data.id; // backend-assigned feedback id
}
```

`result.data` is the persisted feedback record itself. CORE wraps this route in a `{ success, message, data }` envelope; the SDK unwraps it, so you read `result.data.id` and not `result.data.data.id`. See [Response Envelope](#response-envelope).

---

## `employees.attach`

Upload one or more files into a chat session (multipart). Maps to `POST /api/v1/deployments/:deploymentId/attachments/:chatId`.

Pass `File` (browser) or `Blob` (Node 18+) instances. The optional `filenames` array overrides per-position when passing raw Blobs without a `name`.

```ts
const result = await toruk.employees.attach({
  deploymentId: 'chatflow_abc',
  chatId: 'chat_xyz',
  files: [pdfFile, screenshotBlob],
  filenames: ['contract.pdf', 'screenshot.png'], // optional
});

if (result.success) {
  for (const file of result.data) {
    file.name; // extracted filename
    file.content; // extracted text
  }
}
```

Like `feedback`, this route is enveloped by CORE and unwrapped by the SDK — `result.data` is the extracted-file array, so it is directly iterable.

---

## Speech

Both directions are gated on capabilities — the SDK renders no microphone and no speak button unless the deployment advertises them.

### Speech to text

Audio rides the prediction route, not a separate endpoint: send an `uploads[]` entry with `type: 'audio'` and CORE replaces `question` with the transcript. That is what `capabilities.speechToText.via === 'prediction-upload'` means. There is no standalone transcribe route.

The widget handles this end to end — record, upload, auto-send. A failed transcription is a typed error (`STT.TRANSCRIPTION_FAILED` when the provider threw, `STT.TRANSCRIPTION_EMPTY` when it produced nothing), not a nonsense answer to an empty question.

Clips over 25 MB are rejected locally, before any request. That ceiling is an SDK-side default: `capabilities.speechToText` advertises no size limit, and the recorder's own cap is one hour of wall time — long enough to produce something no transcription provider accepts.

### Text to speech

CORE synthesizes into one buffered response; there is no chunked audio protocol. Speech reaches a client two ways:

| Path                                      | How it arrives                                                                                    |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **On demand** — the visitor presses speak | `POST :id/tts` returns `{ data, contentType }`, `data` being base64 for the whole clip            |
| **Automatic** — the flow has auto-TTS on  | One `ttsAudio` SSE frame during the turn, carrying the same object, also persisted on the message |

Render either as `data:${contentType};base64,${data}`. On `employees.stream()`, handle it with `onTtsAudio`:

```ts
await toruk.employees.stream({
  deploymentId: 'dep_abc',
  message: 'Read me the summary.',
  onToken: (t) => process.stdout.write(t),
  onTtsAudio: ({ data, contentType }) => {
    audioEl.src = `data:${contentType};base64,${data}`;
  },
});
```

Two failure modes are distinguished, and you should treat them differently: `TTS.NOT_CONFIGURED` means the deployment has no speech connection — hide the control. `TTS.SYNTHESIS_FAILED` means the provider call failed — offer a retry.

Text above `capabilities.textToSpeech.maxInputChars` (4096) is rejected with a 400. It is not truncated.

### Audio playback lifecycle

The widget plays **one utterance at a time**, from a single shared `<audio>` element. Starting any playback stops whatever was playing, and playback stops on a new response, a session switch, and unmount.

`loading` and `playing` are separate states — the gap between them is a network round trip, and a control that shows "playing" while nothing is audible is worse than one that shows a spinner.

**Nothing auto-plays.** A clip attached to a message — whether it arrived over the stream or came back with history — sits inert until the visitor presses speak. Reopening a conversation never starts talking. Because the clip is held on the message, a second press replays it with no request and no second synthesis charge.

> If you build your own UI, do not mount one `<audio>` per message without arbitration. Nothing stops two from playing at once.

---

## Other runtime methods

The deployment runtime plane is covered end to end, so a headless consumer never has to hand-roll a request:

| Method                                                    | Route                        | Notes                                                                                                                                                                                     |
| --------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `employees.updateFeedback({ feedbackId, content })`       | `PUT …/feedback/:feedbackId` | `feedbackId` comes from `employees.feedback()`. A blank id throws `CONFIGURATION` rather than requesting `…/feedback/`, which is a different route                                        |
| `employees.createLead({ chatId, name?, email?, phone? })` | `POST …/leads`               | Gate on `capabilities.leads.enabled`                                                                                                                                                      |
| `employees.downloadUpload({ chatId, fileName })`          | `GET …/uploads`              | Returns the raw `Response`; call `.blob()`. Prefer this over an `<img src>` URL — a URL carries no headers, so it cannot present the visitor identity and a private deployment rejects it |
| `employees.generateSpeech({ text, chatId? })`             | `POST …/tts`                 | See [Speech](#speech)                                                                                                                                                                     |
| `employees.abort({ chatId })`                             | `POST …/abort`               | Cancels an in-flight run. Only `chatId` is sent — CORE resolves the flow from the deployment, so you cannot cancel a run on a flow your deployment does not point at                      |

`execute` and `stream` also accept `form` (for a `formInput` start node) and `humanInput` (to resume a paused agentflow step). Both are wire passthroughs.

---

## `employees.vectorUpsert`

Upsert one or more documents into the task's vector store for RAG (multipart). Maps to `POST /api/v1/vector/upsert/:id`.

Engine-specific fields (e.g. `splitter`, `chunkSize`) are passed through via `fields`.

```ts
const result = await toruk.employees.vectorUpsert({
  deploymentId: 'chatflow_abc',
  files: [docFile],
  fields: { splitter: 'recursive', chunkSize: '1000' }, // optional
});
```

---

# Sessions — `sessions.*`

**TORUK Core owns and stores external sessions.** Every session, its title, its
messages and its deletion state live in Core. The SDK reads and writes them; it
keeps no copy. If a session call fails, the SDK reports the failure — it never
substitutes a local history, because a second copy silently diverges from the
one Core is serving to every other client.

The SDK holds exactly two pieces of local state:

| State             | Where                                | Why                                              |
| ----------------- | ------------------------------------ | ------------------------------------------------ |
| `visitorToken`    | `localStorage['toruk:visitor:<id>']` | So a returning visitor sees their own history    |
| `activeSessionId` | host app / widget local state        | So a reload resumes the conversation you were in |

Sessions are addressed per deployment. Set `deploymentId` on the client (or pass
it per call):

```ts
import { TorukClient } from 'toruk-embed';

const toruk = new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'none' }, // public deployment — see below for private
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
});
```

`deploymentId` is validated before any request is made. Without it the SDK throws
a `TorukSdkError` with code `CONFIGURATION` rather than requesting
`/api/v1/deployments/undefined/sessions`:

```ts
const misconfigured = new TorukClient({ baseUrl, auth: { type: 'none' } });
await misconfigured.sessions.list();
// TorukSdkError: [TorukClient] deploymentId is required for sessions.list() — set it
// on the client config (new TorukClient({ deploymentId })) or pass it with the call
```

## Visitor identity

External visitors have no TORUK user account, and a deployment credential cannot
identify them — a public deployment takes anonymous callers, and a private
deployment's key is readable by every visitor of the embedding page. So Core
issues the identity itself.

On first contact Core mints an opaque, signed, deployment-bound token and returns
it in the **`x-toruk-visitor`** response header. The SDK:

- reads that header off Core responses (including error responses, so a retry is
  still the same visitor);
- stores the token under a key scoped to the `deploymentId`;
- sends it back on every later session **and prediction** request, streaming or not;
- reuses it after a page reload, so history survives;
- replaces it whenever Core returns a newer one.

A token minted for deployment A is never sent to deployment B — the storage key
embeds the deployment id, so two widgets on one page keep separate identities.

The visitor token is **not** a credential. It asserts "same visitor as before" and
nothing else: it never populates `x-api-key` or `Authorization`, and it grants no
access beyond the deployment it names.

```ts
// Normally you never touch this — the SDK manages it. Exposed for
// "log out of this chat" style controls:
toruk.visitor.get(deploymentId); // current token, or undefined
toruk.visitor.clear(deploymentId); // forget this visitor; Core mints a new one
```

By default the token is persisted in `localStorage`. Override with
`visitorStorage` (any `getItem`/`setItem`/`removeItem` object), or pass `null` for
memory-only storage:

```ts
new TorukClient({ baseUrl, auth: { type: 'none' }, deploymentId, visitorStorage: null });
```

> **CORS.** Core lists `x-toruk-visitor` in its `exposedHeaders`, which is what
> lets a browser read it off the response. If you put your own proxy in front of
> Core, forward the header in both directions and expose it — otherwise every
> request looks like a brand-new visitor.

## `sessions.create`

Registers a conversation with Core. Both fields are optional and neither is
filled in locally: omit `chatId` and Core mints the conversation key, omit
`title` and Core names the conversation with its own generator.

```ts
// Let Core decide both — this is how you obtain a conversation key for a
// request that has to precede your first prediction, such as attaching a file.
const opened = await toruk.sessions.create();
console.log(opened.success && opened.data.chatId);

// Or bind the session to a key you already hold. Create is idempotent on it,
// so calling twice with the same key returns the same session.
const created = await toruk.sessions.create({
  chatId: 'chat_xyz',
  title: 'Invoice questions', // optional, max 200 chars
});

if (created.success) {
  console.log(created.data.id); // session id — used by every other method
  console.log(created.data.status); // 'active' — Core's status values are lowercase
}
```

## `sessions.list`

Lists the calling visitor's sessions for this deployment — never another
visitor's, and never another deployment's.

```ts
const listed = await toruk.sessions.list({
  page: 1, // default 1
  limit: 20, // default 20, max 100
  search: 'invoice', // optional title search
  sortBy: 'lastActivityAt', // 'lastActivityAt' | 'createdAt' | 'title'
  sortOrder: 'DESC', // 'ASC' | 'DESC'
});

if (listed.success) {
  for (const session of listed.data.items) {
    console.log(session.id, session.title, session.messageCount, session.lastActivityAt);
  }
  console.log(listed.data.pagination.total);
}
```

## `sessions.get`

```ts
const one = await toruk.sessions.get({ sessionId });
if (one.success) console.log(one.data.title, one.data.lastMessagePreview);
```

## `sessions.getMessages`

Returns the stored transcript, oldest first.

```ts
const messages = await toruk.sessions.getMessages({
  sessionId,
  page: 1, // default 1
  limit: 50, // default 50, max 200
});

if (messages.success) {
  for (const m of messages.data.items) {
    // role is the engine's own value: 'userMessage' | 'apiMessage'
    // note the field is createdDate on messages (createdAt on sessions)
    console.log(m.role, m.content, m.createdDate);
  }
}
```

Engine passthrough fields (`sourceDocuments`, `artifacts`, `agentReasoning`,
`fileUploads`, `usedTools`, `followUpPrompts`, …) arrive as JSON **strings**,
exactly as Core stores them. Parse the ones you render.

## `sessions.rename`

Core owns the stored title — rename it there, not locally.

```ts
const renamed = await toruk.sessions.rename({ sessionId, title: 'Q3 invoices' });
// max 100 chars; Core trims and strips < >
```

## `sessions.delete`

Core soft-deletes and returns the resulting state.

```ts
const deleted = await toruk.sessions.delete({ sessionId });
if (deleted.success) console.log(deleted.data.status, deleted.data.deletedAt); // 'deleted', ISO date
```

## `sessions.truncateMessages`

Fork the conversation at a message: it and everything after it are archived as
the previous version. This is the server half of editing a turn — send the
replacement as an ordinary prediction afterwards, and the archived tail becomes
a version the visitor can page back to.

```ts
const forked = await toruk.sessions.truncateMessages({ sessionId, messageId });
if (forked.success) console.log(forked.data.deletedCount); // messages archived out of the live thread
```

Core refuses while the session is streaming (`SESSION.BUSY`, 409) — abort the
run first, and retry briefly, since the flag clears a moment after the abort.

## `sessions.listBranches`

The conversation's message versions, one group per edited turn. `forkUserOrdinal`
is the 1-based user turn a group belongs to, which is how a client attaches the
`n/m` control to the right turn.

```ts
const versions = await toruk.sessions.listBranches({ sessionId });
if (versions.success) {
  for (const group of versions.data.groups) {
    console.log(`turn ${group.forkUserOrdinal}: version ${group.current} of ${group.total}`);
  }
}
```

An unedited conversation returns `{ groups: [] }`.

## `sessions.switchBranch`

Make one version live, archiving the one that was. Re-read the messages
afterwards: the switch swaps the whole tail of the conversation.

```ts
await toruk.sessions.switchBranch({ sessionId, forkAfterMessageId, branchIndex: 1 });
const messages = await toruk.sessions.getMessages({ sessionId });
```

Omit `forkAfterMessageId` for a fork at the conversation's first turn.

# Artifacts — `artifacts.*`

**TORUK Core owns and stores artifacts.** They are produced by the flow during a
conversation — a generated document, spreadsheet, image, chart or code file —
and arrive in the stream as an `artifact-ref` Dynamic UI block carrying an
`artifactId`. `artifacts.*` is how you turn that id into the real thing.

Read-only, by design. Artifacts are created by the flow, never by the client, so
there is no `create`, `update` or `delete` here.

## Everything is addressed through a session

Every artifact call takes a `sessionId`, and that is the security model rather
than an ergonomic choice. An external artifact has no owner — a visitor is not a
TORUK user — so the conversation you already own is the only thing that
authorizes the read. Core's routes are nested under the session for the same
reason, and there is deliberately no way to fetch an artifact by id alone.

Core answers every refusal identically — a missing artifact, one from another of
your sessions, one from another deployment, one belonging to another visitor —
with `ARTIFACT_NOT_FOUND`. The boundary does not tell you which it was, so
artifact ids cannot be confirmed by guessing.

Check the capability first; it is false when the deployment cannot scope
artifacts or the server has generation switched off:

```ts
const caps = await toruk.capabilities();
if (caps.success && caps.data.artifacts.enabled) {
  // artifacts.* will work for this deployment
}
```

## `artifacts.list`

The artifacts generated in one of your conversations, newest first.

```ts
const listed = await toruk.artifacts.list({
  sessionId,
  page: 1, // optional, Core defaults to 1
  limit: 20, // optional, Core defaults to 20, max 100
  type: 'pdf', // optional — omit for every kind
});

if (listed.success) {
  for (const artifact of listed.data.items) {
    console.log(artifact.id, artifact.type, artifact.title);
  }
}
```

## `artifacts.get`

One artifact, with its inline content when it has any. Text-based kinds
(`markdown`, `code`, `mermaid`, `html`, …) carry `content`; binary kinds return
`content: null` and are fetched with `download`.

```ts
const result = await toruk.artifacts.get({ sessionId, artifactId });

if (result.success) {
  console.log(result.data.title, result.data.content);
} else if (result.error.code === 'ARTIFACT_NOT_FOUND') {
  // missing, or not yours — Core does not distinguish
}
```

## `artifacts.download`

The bytes, as a `Blob`, plus the filename Core derived from the title. This one
rejects rather than returning an envelope — there is no partial file:

```ts
const { blob, fileName, mimeType } = await toruk.artifacts.download({ sessionId, artifactId });

const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: fileName });
a.click();
URL.revokeObjectURL(url);
```

A `Blob` rather than a URL on purpose: the bytes come back over the same
authenticated transport as everything else, and an object URL is not a durable
link you can store or share.

## Public and private deployments

**Public deployment — no credential needed.** Anonymous callers are admitted, and
the visitor token still isolates one visitor's sessions from another's.

```ts
const toruk = new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'none' },
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
});

await toruk.sessions.create({ chatId: 'chat_xyz' });
```

**Private deployment — explicit configuration only.** Use the deployment's bound
API key, or a member JWT when the user is already signed into TORUK.

```ts
// Bound API key
const toruk = new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'apiKey', apiKey: process.env.TORUK_API_KEY! },
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
});

// Member JWT (with rotation)
const toruk = new TorukClient({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'jwt', token: accessToken, refresh: refreshAccessToken },
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
});
```

Authentication and visitor headers are applied identically to normal requests and
to streams.

> **Do not ship a secret credential to the browser.** The SDK reads no credential
> from the page — not `localStorage.API_KEY`, not `localStorage.ORG_ID`, not
> `localStorage.accessToken` — and sends no `X-Organization-Id` and no
> `x-request-from: internal`. An embedding page cannot classify its request as
> internal or choose an organization; Core derives the organization from the
> deployment. For a private deployment in a browser, prefer a backend proxy that
> holds the key server-side (see [Demo Server](#demo-server)), or a bound key with
> a short expiry that you rotate host-side.

## Sessions in the widget

The widget, the React bindings, the CDN build and the previewer all use this same
typed client — there is no second session implementation in the package. History
in the sidebar and the history panel is read from Core, and rename/delete go to
Core. When a session call fails, the widget shows a controlled error with a retry
rather than switching to a local history; a permanent failure (401/403/404) stops
retrying instead of looping.

```html
<script type="module">
  import Torukworkflow from 'https://unpkg.com/toruk-embed/dist/web.js';
  Torukworkflow.execute({
    deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
    apiHost: 'https://toruk.company.com',
  });
</script>
```

For a private deployment, pass credentials explicitly through `onRequest` — the
widget's only credential path:

```ts
Torukworkflow.execute({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://toruk.company.com',
  onRequest: async (req) => {
    req.headers = { ...req.headers, 'x-api-key': await fetchShortLivedKey() };
  },
});
```

React:

```tsx
import { TorukProvider, TorukPreviewer } from 'toruk-embed/react';

<TorukProvider config={{ baseUrl: 'https://toruk.company.com', auth: { type: 'none' } }}>
  <TorukPreviewer deploymentId="d290f1ee-6c54-4b01-90e6-d701748f0851" mode="inline" />
</TorukProvider>;
```

To drive sessions yourself from React, reach for the client:

```tsx
import { useTorukClient } from 'toruk-embed/react';

function History({ deploymentId }: { deploymentId: string }) {
  const toruk = useTorukClient();
  const [sessions, setSessions] = useState<TorukSession[]>([]);
  const [error, setError] = useState<string>();

  useEffect(() => {
    toruk.sessions.list({ deploymentId }).then((result) => {
      if (result.success) setSessions(result.data.items);
      else setError(result.message); // show the failure; do not fall back to local data
    });
  }, [deploymentId]);

  // …
}
```

## REST / cURL

The SDK is a thin typed layer over these routes. `<VISITOR>` is the token from the
`x-toruk-visitor` response header — omit it on the very first call and Core mints
one for you.

```bash
BASE=https://toruk.company.com
DEP=d290f1ee-6c54-4b01-90e6-d701748f0851

# Create — public deployment, first contact. -i so you can read the minted token.
curl -i -X POST "$BASE/api/v1/deployments/$DEP/sessions" \
  -H 'Content-Type: application/json' \
  -d '{"chatId":"chat_xyz","title":"Invoice questions"}'
# → 201, and a response header: x-toruk-visitor: <VISITOR>

# List — now presenting that identity
curl "$BASE/api/v1/deployments/$DEP/sessions?page=1&limit=20&sortBy=lastActivityAt&sortOrder=DESC" \
  -H "x-toruk-visitor: <VISITOR>"

# Get one
curl "$BASE/api/v1/deployments/$DEP/sessions/<SESSION_ID>" -H "x-toruk-visitor: <VISITOR>"

# Messages
curl "$BASE/api/v1/deployments/$DEP/sessions/<SESSION_ID>/messages?page=1&limit=50" \
  -H "x-toruk-visitor: <VISITOR>"

# Rename
curl -X PATCH "$BASE/api/v1/deployments/$DEP/sessions/<SESSION_ID>" \
  -H 'Content-Type: application/json' -H "x-toruk-visitor: <VISITOR>" \
  -d '{"title":"Q3 invoices"}'

# Delete
curl -X DELETE "$BASE/api/v1/deployments/$DEP/sessions/<SESSION_ID>" \
  -H "x-toruk-visitor: <VISITOR>"

# Private deployment — add the bound key to any of the above
curl "$BASE/api/v1/deployments/$DEP/sessions" \
  -H "x-api-key: $TORUK_API_KEY" -H "x-toruk-visitor: <VISITOR>"
```

Core wraps these responses as `{ success, message, data }`. The SDK unwraps that
envelope, so `result.data` is the session payload itself.

---

## Error Handling

The SDK distinguishes **predictable errors** (returned in the response envelope) from **structural failures** (thrown).

### Predictable errors — response envelope

Returned without a throw. The `result.success === false` branch carries a typed error code.

```ts
const result = await toruk.employees.execute({ deploymentId: 'missing', message: 'hi' });

if (!result.success) {
  switch (result.error.code) {
    case 'TASK_NOT_FOUND':
      // 404 — task ID is wrong
      break;
    case 'UNAUTHORIZED':
      // 401 — bad/missing credentials
      break;
    case 'FORBIDDEN':
      // 403 — auth OK but task not accessible
      break;
    case 'RATE_LIMITED':
      // 429 — slow down
      break;
    // ... see below for the full list
  }
}
```

Known codes:

| Code                    | When                                                    |
| ----------------------- | ------------------------------------------------------- |
| `TASK_NOT_FOUND`        | 404 — task does not exist                               |
| `DEPLOYMENT_NOT_FOUND`  | 404 — deployment missing, draft, or inactive            |
| `SESSION_NOT_FOUND`     | 404 — no such session for this deployment and visitor   |
| `UNAUTHORIZED`          | 401 — auth failed                                       |
| `FORBIDDEN`             | 403 — auth OK but task not accessible                   |
| `BAD_REQUEST`           | 400 — malformed request body                            |
| `TASK_BUILD_FAILED`     | 500 — backend build error                               |
| `STREAMING_UNAVAILABLE` | 503 — streaming requested but not available             |
| `RATE_LIMITED`          | 429 — too many requests                                 |
| `CONFIGURATION`         | thrown before any request — e.g. missing `deploymentId` |
| `UNSUPPORTED`           | no deployment runtime endpoint for this operation       |
| `UNKNOWN`               | other 4xx/5xx without a known backend code              |

`SESSION_NOT_FOUND` is deliberately indistinguishable from "belongs to another
visitor" and "belongs to another deployment" — Core returns the same not-found
for all three so a session's existence never leaks across scopes.

The legacy codes `WORKFLOW_NOT_FOUND` and `WORKFLOW_BUILD_FAILED` remain in the `TorukSdkErrorCode` union as deprecated string aliases so existing consumer `switch` statements still compile — but the SDK emits only the new codes. Update your switches before the next major release.

### Structural failures — thrown

A `TorukSdkError` is thrown when the failure is structural and can't be expressed in the envelope: network down, request aborted, response parse failure.

```ts
import { TorukSdkError } from 'toruk-embed';

try {
  await toruk.employees.execute({ deploymentId: 'x', message: 'hi' });
} catch (err) {
  if (err instanceof TorukSdkError) {
    err.code; // 'NETWORK' | 'ABORTED' | 'TIMEOUT' | 'MALFORMED_RESPONSE' | ...
    err.status; // HTTP status if applicable
    err.details; // backend-supplied detail payload if applicable
  }
}
```

---

## Response Envelope

Every SDK method returns a discriminated union:

```ts
type TorukApiResponse<T> = { success: true; data: T } | { success: false; message: string; error: { code: string; details?: unknown } };
```

Error responses already carry the envelope shape; the SDK normalizes the `error.code` against the table above.

Success responses are not uniform on the CORE side, and the SDK hides the difference:

| CORE route                              | What CORE returns                 | What `result.data` gives you                    |
| --------------------------------------- | --------------------------------- | ----------------------------------------------- |
| `POST …/predictions`                    | bare body — `{ chatId, text, … }` | that body, wrapped as `{ success: true, data }` |
| `GET …/config`                          | bare body                         | that body                                       |
| `POST …/feedback`, `PUT …/feedback/:id` | `{ success, message, data }`      | the record inside `data`                        |
| `POST …/leads`                          | `{ success, message, data }`      | the record inside `data`                        |
| `POST …/attachments/:chatId`            | `{ success, message, data }`      | the array inside `data`                         |
| `…/sessions/*`                          | `{ success, message, data }`      | the payload inside `data`                       |

So `result.data` is always the payload, never CORE's wrapper. There is no `result.data.data`. Unwrapping is shape-driven rather than route-configured: a route that returns a bare body — or that CORE later flattens — passes through unchanged.

> **Changed in 0.7.0.** Up to `0.6.0` only the session methods unwrapped. `employees.feedback`, `employees.attach` and the widget's feedback / lead / attachment calls returned the raw envelope, so `result.data.id` was `undefined` and the attachment result was not iterable. If you wrote `result.data.data` to work around this, drop the extra hop.

---

## Subpath Entries

| Entry                   | Use when                                                                                                                                                                                                                           | What's exported                                                                                                           |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `toruk-embed`           | Browser-bundler consumers (Vite, Next.js, Webpack).                                                                                                                                                                                | `TorukClient`, `TorukSdkError`, types, **AND** the legacy widget surface (`registerWebComponents`, `init`, …).            |
| `toruk-embed/browser`   | Browser consumers who only want the SDK — no widget transitive imports. Smaller bundle.                                                                                                                                            | `TorukClient`, `TorukSdkError`, types.                                                                                    |
| `toruk-embed/node`      | Pure-Node consumers (server-to-server, CLIs, tests). Skips the widget code that loads `solid-element` at module top-level.                                                                                                         | Same as `/browser` — including `SessionsClient`, `VisitorTokenStore` and the session types — plus the `fromEnv()` helper. |
| `toruk-embed/previewer` | Embed the TORUK chat widget with typed lifecycle controls. Prefer over legacy `execute()` for new projects.                                                                                                                        | `createTorukPreviewer`, `TorukPreviewerInstance`, `PreviewerMode`, `PreviewerMountOptions`                                |
| `toruk-embed/react`     | React apps — context provider + hook + `<TorukPreviewer />` component. SSR-safe (no-ops on the server).                                                                                                                            | `TorukProvider`, `useTorukClient`, `TorukPreviewer`                                                                       |
| `toruk-embed/web`       | Drop-in widget for a bundler — the same build the CDN serves. Self-registers the custom elements on import and **default-exports** the `Torukworkflow` handle. Self-contained: bundles Solid/Three rather than externalizing them. | `default` → `{ execute, executeFull, executeFromConfig, destroy }`                                                        |

---

## `fromEnv` helper (Node)

In Node-only contexts you can construct a client from environment variables:

```ts
import { fromEnv } from 'toruk-embed/node';

// Reads:
//   TORUK_BASE_URL  (required)
//   TORUK_API_KEY   (preferred for server-to-server) OR
//   TORUK_JWT       (alternative)
const toruk = fromEnv();
```

Throws a plain `Error` with a clear message if either variable is missing — misconfiguration fails loudly at boot instead of silently at the first request.

---

## SDK ↔ Backend wire mapping

The SDK uses developer-friendly field names; TORUK-CORE uses different names on the wire. The translation happens at the boundary.

| SDK input field                                               | Wire body field                                    |
| ------------------------------------------------------------- | -------------------------------------------------- |
| `deploymentId`                                                | (path `:id` — not a body field)                    |
| `message`                                                     | `question`                                         |
| `stream` _(set by `employees.stream`)_                        | `streaming`                                        |
| `chatId`                                                      | `chatId` _(passthrough; omitted when absent)_      |
| `overrideConfig`, `history`, `uploads`, `leadEmail`, `action` | _(passthrough verbatim)_                           |

| Wire response field                                    | SDK envelope path                                   |
| ------------------------------------------------------ | --------------------------------------------------- |
| `chatId`                                               | `result.data.chatId`                                |
| `text`                                                 | `result.data.text`                                  |
| `chatMessageId`                                        | `result.data.messageId` _(renamed at the boundary)_ |
| `sourceDocuments`, `usedTools`, `agentReasoning`, etc. | `result.data.<same name>` _(passthrough)_           |

This mapping is the spec — it does not change in patch releases.

---

# PreviewerAPI — `createTorukPreviewer`

> Prefer this over the legacy `execute()` / `executeFull()` helpers for new projects.

`createTorukPreviewer` mounts the TORUK chat widget with typed lifecycle controls, without requiring you to call `registerWebComponents()` manually.

```ts
import { createTorukPreviewer } from 'toruk-embed/previewer';

const previewer = createTorukPreviewer({
  baseUrl: 'https://toruk.company.com',
  auth: { type: 'apiKey', apiKey: 'tk_live_…' },
});
```

## `previewer.mount(target, options)` — returns `Promise<void>`

Awaiting the returned Promise ensures the widget element is in the DOM before you inspect or interact with it.

```ts
// Floating bubble (default) — appended to document.body
await previewer.mount(null, {
  deploymentId: 'chatflow_abc',
  mode: 'floating', // default; 'inline' | 'modal' | 'floating'
});

// Full-page inline inside a container
await previewer.mount('#chat-host', {
  deploymentId: 'chatflow_abc',
  mode: 'inline',
  variant: 'neptune', // 'luna' | 'erebus' | 'neptune'
  withSidebar: true,
});

// Modal overlay with backdrop
await previewer.mount(null, {
  deploymentId: 'chatflow_abc',
  mode: 'modal',
});
```

## Lifecycle methods

```ts
previewer.unmount(); // remove widget from DOM
previewer.open(); // show widget (floating/modal)
previewer.close(); // hide widget (floating/modal)
previewer.updateConfig({ deploymentId: 'xyz' }); // hot-update props in place
const el = previewer.element; // mounted DOM element, or undefined
```

## Mode reference

| mode         | DOM element                                  | target                                       |
| ------------ | -------------------------------------------- | -------------------------------------------- |
| `'floating'` | `<toruk-chatbot>` (bubble)                   | ignored — mounted on `document.body`         |
| `'inline'`   | `<toruk-fullchatbot>`                        | CSS selector or `Element`                    |
| `'modal'`    | `<toruk-fullchatbot>` inside a fixed overlay | ignored — overlay mounted on `document.body` |

## Mount options

| field          | type                                  | description                                  |
| -------------- | ------------------------------------- | -------------------------------------------- |
| `deploymentId` | `string`                              | Chatflow or agentflow UUID                   |
| `mode`         | `'floating' \| 'inline' \| 'modal'`   | Defaults to `'floating'`                     |
| `variant`      | `'luna' \| 'erebus' \| 'neptune'`     | Full-page visual variant (inline/modal only) |
| `withSidebar`  | `boolean`                             | Show sidebar in full-page modes              |
| `features`     | `WidgetFeatureOverrides`              | Feature switches; only `false` has an effect |
| `language`     | `'en' \| 'ar'`                        | Widget language; Arabic is right-to-left     |
| `theme`        | `Record<string, unknown>`             | Theme override passed to the widget          |
| `taskConfig`   | `Record<string, unknown>`             | Per-task engine config                       |
| `onRequest`    | `(req: RequestInit) => Promise<void>` | Extra request hook (composes with auth)      |

---

# React Bindings — `toruk-embed/react`

First-class React surface: a context provider for sharing a `TorukClient`, a hook to access it, and a `<TorukPreviewer />` component that wraps the imperative previewer. SSR-safe (no-ops on the server; mounts in `useEffect` after hydration).

`react` and `react-dom` are **optional** peer dependencies. Only consumers who import `toruk-embed/react` need them installed.

## `TorukProvider`

Wrap your tree once. Pass either `config` (the provider constructs the client) or `client` (you manage it):

```tsx
import { TorukProvider } from 'toruk-embed/react';

export function App() {
  return (
    <TorukProvider config={{ baseUrl: 'https://toruk.company.com', auth: { type: 'apiKey', apiKey: 'tk_live_…' } }}>
      <Page />
    </TorukProvider>
  );
}
```

## `useTorukClient`

Access the shared client anywhere below the provider. Throws if used outside a `TorukProvider`.

```tsx
import { useTorukClient } from 'toruk-embed/react';

function SendButton() {
  const toruk = useTorukClient();
  const onClick = async () => {
    const r = await toruk.employees.execute({ deploymentId: 'chatflow_abc', message: 'Hi' });
    // ...
  };
  return <button onClick={onClick}>Send</button>;
}
```

## `<TorukPreviewer />`

Drop-in component that mounts the chat widget. Sources auth from the surrounding provider.

```tsx
import { TorukPreviewer } from 'toruk-embed/react';

// Floating bubble — appended to <body>
<TorukPreviewer deploymentId="chatflow_abc" />

// Inline, full-page
<TorukPreviewer
  deploymentId="chatflow_abc"
  mode="inline"
  variant="neptune"
  withSidebar
  className="chat-host"
  style={{ height: 600 }}
  fallback={<Skeleton />}
/>
```

| Prop           | Type                                  | Description                                                 |
| -------------- | ------------------------------------- | ----------------------------------------------------------- |
| `deploymentId` | `string`                              | Required — chatflow or agentflow UUID.                      |
| `mode`         | `'floating' \| 'inline' \| 'modal'`   | Defaults to `'floating'`.                                   |
| `variant`      | `'luna' \| 'erebus' \| 'neptune'`     | Full-page variant for inline/modal.                         |
| `withSidebar`  | `boolean`                             | Show sidebar in full-page modes.                            |
| `features`     | `WidgetFeatureOverrides`              | Feature switches; only `false` has an effect.               |
| `language`     | `'en' \| 'ar'`                        | Widget language; Arabic is right-to-left.                   |
| `className`    | `string`                              | Class forwarded onto the host `<div>` (inline/modal only).  |
| `style`        | `CSSProperties`                       | Style forwarded onto the host `<div>` (inline/modal only).  |
| `fallback`     | `ReactNode`                           | Rendered while the widget mounts (skeleton, spinner, etc.). |
| `theme`        | `Record<string, unknown>`             | Theme override passed to the widget.                        |
| `taskConfig`   | `Record<string, unknown>`             | Per-task engine config.                                     |
| `onRequest`    | `(req: RequestInit) => Promise<void>` | Extra request hook (composes with auth).                    |

---

# Widget vs Headless

|                    | `TorukClient` (headless)                     | `createTorukPreviewer` / `init` (widget)                               |
| ------------------ | -------------------------------------------- | ---------------------------------------------------------------------- |
| **DOM dependency** | None — Node.js, edge, workers                | Browser only                                                           |
| **Auth**           | Typed `AuthConfig` — no localStorage         | Explicit `onRequest` hook only — never reads credentials from the page |
| **UI**             | You own the UI                               | Ships the TORUK SolidJS chat UI                                        |
| **Use case**       | Custom UI, server-side calls, data pipelines | Drop-in embeds for web pages                                           |
| **Entry point**    | `toruk-embed` or `toruk-embed/node`          | `toruk-embed/previewer` (preferred) or `toruk-embed` (legacy `init`)   |
| **Mount control**  | n/a                                          | `mount()` / `unmount()` / `open()` / `close()`                         |
| **Streaming**      | `employees.stream()` with callbacks          | Handled internally by the widget UI                                    |
| **Sessions**       | `sessions.*` — you drive it                  | Same typed client, driven by the widget UI                             |

Use `TorukClient` when you need to call prediction APIs directly — from a Node.js server, a custom React/Vue UI, or a data pipeline. Use `createTorukPreviewer` when you want to drop the full chat UI into a web page with minimal code.

# What the widget renders

A TORUK answer is not only prose. The flow can emit interactive components, generate
documents, and stream structured activity alongside the text. The widget draws all of
it, and this section describes what arrives and how it is handled.

None of this needs configuration. It is here because the behaviour is observable — you
will see these surfaces in your embed — and because a custom UI built on `TorukClient`
has to handle the same wire.

## Dynamic UI

TORUK Core injects a fence contract into the flow's system prompt, and the model answers
with fenced blocks — ` ```ui ` and ` ```artifact ` — interleaved with ordinary markdown. The
SDK splits a stream into `md` and `ui` blocks as it arrives, so prose keeps rendering
token-by-token while a component is still being written.

**The parser is vendored from Core verbatim.** `split-blocks` is a byte-comparable copy of
Core's own module, including the prop normalization and clamping that make model-authored
props safe to render. The SDK runs Core's parser rather than re-deriving the contract,
because a second implementation of an untrusted-input parser is a second set of bugs.

Twelve component types are drawn:

| type                                       | what it is                                          |
| ------------------------------------------ | --------------------------------------------------- |
| `table`                                    | Tabular data, with its own column model             |
| `chart`                                    | A plotted series                                    |
| `card`, `infocard`                         | Summary surfaces                                    |
| `mermaid`                                  | A Mermaid diagram                                   |
| `form`                                     | A `formInput` start node's fields                   |
| `artifact`, `artifact-ref`                 | A generated document — see below                    |
| `approval`, `rating`, `feedback`, `choice` | **Interactive** — the answer goes back to the model |

The four interactive types stay live until answered, and the visitor's answer is sent
back into the conversation as a follow-up message. Core only emits them when it intends
to act on the reply.

**An unrecognised type never breaks the chat.** A block whose `type` this build predates
resolves to an `unsupported` renderer that draws a small notice. Renderer selection is
pure and lives apart from the components, so that path is testable without mounting a
widget.

### The AG-UI wire

Core streams predictions in one of two formats:

| wire       | frames               | when                                                                   |
| ---------- | -------------------- | ---------------------------------------------------------------------- |
| **legacy** | `{ event, data }`    | The default                                                            |
| **AG-UI**  | flat `{ type, ... }` | Dynamic UI is enabled **and** the client sent `streamProtocol: 'agui'` |

The choice is wholesale — in AG-UI mode Core emits no legacy frames at all, so a client
handles one format or the other, never a mix. Dynamic UI blocks arrive as `CUSTOM` events
named `toruk.*`, alongside sideband payloads the widget acts on: `toruk.metadata`,
`toruk.action`, `toruk.sourceDocuments`, `toruk.artifacts`, `toruk.fileAnnotations`,
`toruk.agentReasoning`, `toruk.agentflow.status`, `toruk.agentflow.executedData` and the
artifact-generation markers. Sideband names with no widget surface resolve to `ignore`
rather than being silently mishandled.

Agentflow node events become a step list under the message — the activity trail that shows
which nodes ran, in order, with their status.

Turn the whole thing off with the [`dynamicUi` feature switch](#feature-switches). The
widget then sends no `streamProtocol` at all, and Core answers on the legacy wire.

## Artifacts in the chat

An artifact generated during a conversation arrives as an `artifact-ref` block carrying
an `artifactId`. The widget draws a card in the transcript, and opening it loads the real
thing through [`artifacts.*`](#artifacts--artifacts).

Fifteen artifact types are recognised, in two families:

- **Inline** — `markdown`, `code`, `html`, `interactive`, `svg`, `mermaid`, `chart`,
  `table`, `python_code`, `python_result`. Rendered in place, in the chat or the viewer.
- **Binary** — `pdf`, `docx`, `xlsx`, `pptx`, `image`. Fetched as an authenticated blob
  and handed to the visitor to download.

A markdown artifact opens in a **document viewer** with a table of contents built from its
`h1`–`h3` headings, scroll-spy that tracks the active heading, and scrolling that yields
as soon as the reader takes over. `prefers-reduced-motion` is honoured.

**`html` and `interactive` artifacts run inside a sandboxed iframe under a restrictive
Content-Security-Policy** — `default-src 'none'`, no network of any kind (`connect-src
'none'`), no form submission, no base-URI rewriting; images and fonts only as `data:`
URIs. Scripts are allowed inline for those two types and the iframe is sandboxed to
`allow-scripts allow-popups`; an `svg` artifact renders through the same iframe with
scripts off entirely. A generated document is model output, so it is treated as untrusted.

The Artifacts library and the cards follow the [`artifacts` feature switch](#feature-switches),
which in turn follows `sessions` — an artifact is addressed through the session it was
generated in.

## Markdown

Every prose surface — chat messages, artifact documents, Dynamic UI card bodies — goes
through one shared rendering pipeline: preprocess, parse, treat, morph. **HTML in markdown
is sanitized** unless a surface explicitly opts in.

Two parts of it are worth knowing about:

**Streaming does not thrash the DOM.** Re-rendering a growing message by resetting
`innerHTML` would drop text selection, fight the scroll position and reflow the whole
subtree on every token. Instead the new tree is diffed into the live one node by node,
so only what actually changed is touched.

**Direction is decided per block, and latches.** In mixed English/Arabic content each
block gets its own `dir` from the strongly-directional characters it contains. While text
is still streaming a block needs a minimum of strong signal before it commits, so a
paragraph that begins with a number or a bracket does not flip direction as the rest of
the sentence arrives. Code blocks are always LTR regardless of the surrounding text.

Fenced code renders with its language label and a copy button, and `code` / `python_code`
artifacts reuse the same block.

---

# Legacy Widget API

> Preserved from `toruk-embed@0.1.x`. The widget renders a fully-featured chat UI as a Web Component, built with [SolidJS](https://www.solidjs.com/) and compiled to native Custom Elements. Widget code written against 0.1.x still works — the mount functions gained options (`features`, `language`, `accessToken`), none of them required.

## Usage — Script Tag (CDN)

Embed the chatbot on any page with a single script tag. The script registers the Web Components and exposes `window.Torukworkflow`.

```html
<script type="module">
  import Torukworkflow from 'https://unpkg.com/toruk-embed/dist/web.js';

  Torukworkflow.execute({
    deploymentId: 'YOUR_DEPLOYMENT_ID',
    apiHost: 'https://your-toruk-api.com',
  });
</script>
```

Or with UMD (no ES modules required):

```html
<script src="https://unpkg.com/toruk-embed/dist/web.umd.js"></script>
<script>
  window.TorukEmbed.execute({
    deploymentId: 'YOUR_DEPLOYMENT_ID',
    apiHost: 'https://your-toruk-api.com',
  });
</script>
```

---

## Usage — npm Module

Import and use inside any JS/TS bundler project (Vite, Next.js, webpack, etc.).

```ts
import { registerWebComponents, execute } from 'toruk-embed';

// Register custom elements once — SSR-safe (no-op on server)
registerWebComponents();

// Mount the bubble chatbot
execute({
  deploymentId: 'YOUR_DEPLOYMENT_ID',
  apiHost: 'https://your-toruk-api.com',
  theme: {
    button: { backgroundColor: '#3385FF' },
    chatWindow: {
      title: 'TORUK Assistant',
      welcomeMessage: 'Hello! How can I help?',
    },
  },
});
```

**Next.js (SSR) — use dynamic import:**

```tsx
// app/components/ChatWidget.tsx
'use client';

import { useEffect } from 'react';

export default function ChatWidget() {
  useEffect(() => {
    import('toruk-embed').then(({ registerWebComponents, execute }) => {
      registerWebComponents();
      execute({
        deploymentId: 'YOUR_DEPLOYMENT_ID',
        apiHost: 'https://your-toruk-api.com',
      });
    });
  }, []);

  return null;
}
```

---

## Templates

Three display modes are available:

| Template | Description                                  |
| -------- | -------------------------------------------- |
| `bubble` | Floating chat bubble in the corner (default) |
| `full`   | Full-page embedded chat with sidebar         |
| `popup`  | Popup overlay triggered by user action       |

Use `execute()` for bubble, `executeFull()` for full-page, or `executeFromConfig()` for flexible config-driven setup.

---

## API Reference

### `execute()`

Mounts the bubble chatbot. Removes any existing widget first.

```ts
execute(props: BotProps): void
```

```ts
import { registerWebComponents, execute } from 'toruk-embed';

registerWebComponents();
execute({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://api.example.com',
});
```

---

### `executeFull()`

Mounts the full-page chatbot.

```ts
executeFull(props: BotProps & {
  id?: string;
  variant?: 'luna' | 'erebus' | 'neptune';
  fullPageStyle?: 'luna' | 'erebus' | 'neptune';
  withSidebar?: boolean;
  features?: WidgetFeatureOverrides;
  language?: 'en' | 'ar';
  accessToken?: string | (() => string | Promise<string>);
}): void
```

```ts
import { registerWebComponents, executeFull } from 'toruk-embed';

registerWebComponents();
executeFull({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://api.example.com',
  variant: 'neptune',
  withSidebar: true,
});
```

---

### `executeFromConfig()`

Flexible initializer — accepts a `TemplateConfig` object. Used for dynamic/server-driven configuration.

```ts
executeFromConfig(config: {
  template: 'bubble' | 'chatbubble' | 'full' | 'fullPage';
  flowId: string;
  apiHost?: string;
  theme?: BubbleTheme;
  variant?: 'luna' | 'erebus' | 'neptune';
  fullPageStyle?: 'luna' | 'erebus' | 'neptune';
  withSidebar?: boolean;
  features?: WidgetFeatureOverrides;
  language?: 'en' | 'ar';
  accessToken?: string | (() => string | Promise<string>);
  chatflowConfig?: Record<string, unknown>;
  observersConfig?: observersConfigType;
  onRequest?: (request: RequestInit) => Promise<void>;
}): void
```

```ts
import { registerWebComponents, executeFromConfig } from 'toruk-embed';

registerWebComponents();
executeFromConfig({
  template: 'fullPage',
  flowId: 'abc-123',
  apiHost: 'https://api.example.com',
  variant: 'luna',
  withSidebar: true,
});
```

---

### Feature switches

Every mount point (`execute`, `executeFull`, `executeFromConfig`, the previewer and `<TorukPreviewer />`) accepts a `features` object:

```ts
executeFull({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://api.example.com',
  features: { sessions: false, newChat: false },
});
```

| key               | what it controls                                                              |
| ----------------- | ----------------------------------------------------------------------------- |
| `sessions`        | Chat history: saving to CORE, the Recent list, search and the history overlay |
| `newChat`         | The New Chat control (rail, top bar, bubble title bar, conversation back)     |
| `artifacts`       | The Artifacts library and artifact cards — follows `sessions`                 |
| `dynamicUi`       | Dynamic UI components and the AG-UI wire                                      |
| `fileUploads`     | The attach control (image, RAG and full-file uploads)                         |
| `speechToText`    | The microphone                                                                |
| `textToSpeech`    | The read-aloud button                                                         |
| `feedback`        | Thumbs up / down                                                              |
| `followUpPrompts` | Suggested follow-up prompts                                                   |

Two inputs decide each switch and both must agree: the capability envelope from `GET /deployments/:id/config` — into which CORE already folds the switches the deployment owner saved in the deployment wizard — and this `features` object. **Only `false` has an effect.** An override can hide a control CORE would serve (the wizard preview uses this to reflect toggles before they are saved), but it never switches on something CORE has not advertised. For a shipped embed you normally pass nothing here: the owner's saved switches arrive with `/config` and apply on their own, and CORE enforces them server-side too (session and artifact routes refuse, the prediction wire is pinned to legacy).

`resolveWidgetFeatures(overrides, capabilities)` and `provisionalWidgetFeatures(overrides)` are exported for custom UIs that want the same resolution.

---

### Language

The widget ships in English and Arabic. English is the default; pass `language` on any mount point to switch:

```ts
executeFull({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://api.example.com',
  language: 'ar',
});
```

Arabic renders right-to-left: the templates set `dir="rtl"` and `lang="ar"` on their root, so the rail, the header and the composer mirror. Region tags are accepted (`'ar-SA'` is Arabic) and anything unsupported falls back to English rather than to a half-translated surface.

Two sources decide the language, in this order: the `language` you pass, then the language the deployment owner saved in CORE's deployment wizard (it arrives with `GET /deployments/:id/config`). Pass nothing and the deployment's own choice applies.

Every string the widget draws is localized — sidebar, history, composer, recording, feedback, lead capture, Dynamic UI blocks, artifacts, and the default theme copy (welcome message, headline, starter prompts, placeholder, footer). Text you set yourself in `theme` is shown as-is in any language. The sentences Dynamic UI sends back to the model when a visitor answers an interactive block stay English on purpose: they are part of the interaction contract with CORE.

`WIDGET_LANGUAGES`, `DEFAULT_WIDGET_LANGUAGE`, `resolveWidgetLanguage()` and `directionFor()` are exported for hosts that want the same resolution.

---

### `destroy()`

Removes the mounted widget from the DOM.

```ts
destroy(): void
```

```ts
import { destroy } from 'toruk-embed';

destroy();
```

---

### `registerWebComponents()`

Registers the `<toruk-chatbot>` and `<toruk-fullchatbot>` custom elements (plus the deprecated `<flowise-chatbot>` / `<flowise-fullchatbot>` aliases for backward compatibility). Must be called once before `execute()`, `executeFull()`, or `executeFromConfig()`.

- Safe to call on SSR/Node — is a no-op when `window` is undefined.
- Safe to call multiple times — custom elements self-skip re-registration.

```ts
import { registerWebComponents } from 'toruk-embed';

registerWebComponents();
```

---

## BotProps

All properties accepted by `execute()` and `executeFull()`.

| Prop                                           | Type                                  | Required | Description                                                    |
| ---------------------------------------------- | ------------------------------------- | -------- | -------------------------------------------------------------- |
| `deploymentId`                                 | `string`                              | ✅       | The deployment UUID from your TORUK instance                   |
| `chatflowid`                                   | `string`                              | —        | Deprecated alias for `deploymentId`, still accepted            |
| `apiHost`                                      | `string`                              | —        | Base URL of the TORUK API (e.g. `https://api.example.com`)     |
| `onRequest`                                    | `(req: RequestInit) => Promise<void>` | —        | Hook to modify every outgoing request (add auth headers, etc.) |
| `chatflowConfig`                               | `Record<string, unknown>`             | —        | Extra config passed to the chatflow                            |
| `observersConfig`                              | `observersConfigType`                 | —        | Reactive callbacks for messages, loading state                 |
| `theme.button.backgroundColor`                 | `string`                              | —        | Bubble button background color (hex)                           |
| `theme.button.iconColor`                       | `string`                              | —        | Bubble button icon color (hex)                                 |
| `theme.chatWindow.title`                       | `string`                              | —        | Chat window header title                                       |
| `theme.chatWindow.titleBackgroundColor`        | `string`                              | —        | Header background color                                        |
| `theme.chatWindow.titleTextColor`              | `string`                              | —        | Header text color                                              |
| `theme.chatWindow.titleAvatarSrc`              | `string`                              | —        | URL for the avatar shown in the header                         |
| `theme.chatWindow.welcomeMessage`              | `string`                              | —        | First message shown before user types                          |
| `theme.chatWindow.heroGreeting`                | `string`                              | —        | Large greeting text in the hero section                        |
| `theme.chatWindow.heroHeadline`                | `string`                              | —        | Subtitle under the greeting                                    |
| `theme.chatWindow.backgroundColor`             | `string`                              | —        | Chat window background color                                   |
| `theme.chatWindow.fontSize`                    | `number`                              | —        | Base font size (px)                                            |
| `theme.chatWindow.userMessage.backgroundColor` | `string`                              | —        | User bubble background                                         |
| `theme.chatWindow.userMessage.textColor`       | `string`                              | —        | User bubble text color                                         |
| `theme.chatWindow.botMessage.backgroundColor`  | `string`                              | —        | Bot bubble background                                          |
| `theme.chatWindow.botMessage.textColor`        | `string`                              | —        | Bot bubble text color                                          |
| `theme.chatWindow.textInput.backgroundColor`   | `string`                              | —        | Input area background                                          |
| `theme.chatWindow.textInput.textColor`         | `string`                              | —        | Input text color                                               |
| `theme.chatWindow.textInput.sendButtonColor`   | `string`                              | —        | Send button color                                              |
| `theme.chatWindow.textInput.placeholder`       | `string`                              | —        | Input placeholder text                                         |
| `theme.chatWindow.showTitle`                   | `boolean`                             | —        | Show/hide the title bar                                        |
| `theme.chatWindow.showAgentMessages`           | `boolean`                             | —        | Show/hide agent reasoning messages                             |
| `theme.chatWindow.starterPrompts`              | `string[]`                            | —        | Suggested prompts shown before first message                   |
| `theme.chatWindow.clearChatOnReload`           | `boolean`                             | —        | Clear chat history on page reload                              |
| `theme.chatWindow.renderHTML`                  | `boolean`                             | —        | Allow HTML rendering in bot messages                           |
| `theme.customCSS`                              | `string`                              | —        | Raw CSS injected into the widget shadow scope                  |
| `isFullPage`                                   | `boolean`                             | —        | Internal flag; set automatically by `executeFull()`            |

---

## Theme Variants

Full-page mode (`executeFull` / `executeFromConfig` with `template: 'fullPage'`) supports three visual variants:

| Variant   | Description                                                            |
| --------- | ---------------------------------------------------------------------- |
| `luna`    | Clean light/dark default layout                                        |
| `erebus`  | Dark gradient with glowing accents                                     |
| `neptune` | Deep-space dark with animated WebGL laser beam (Three.js; lazy-loaded) |

```ts
executeFull({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://api.example.com',
  variant: 'neptune',
  withSidebar: true,
});
```

> **Note:** The Neptune variant loads Three.js dynamically only when rendered. Users of the `luna` and `erebus` variants do not download Three.js.

---

## observersConfig

Subscribe to reactive state changes inside the widget.

```ts
import { registerWebComponents, execute } from 'toruk-embed';

registerWebComponents();
execute({
  deploymentId: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
  apiHost: 'https://api.example.com',
  observersConfig: {
    observeUserInput: (input) => {
      console.log('User typed:', input);
    },
    observeLoading: (isLoading) => {
      console.log('Loading:', isLoading);
    },
    observeMessages: (messages) => {
      console.log('Messages updated:', messages);
    },
  },
});
```

| Observer           | Payload type    | Fires when                                |
| ------------------ | --------------- | ----------------------------------------- |
| `observeUserInput` | `string`        | User types in the input box               |
| `observeLoading`   | `boolean`       | Bot starts or stops generating a response |
| `observeMessages`  | `MessageType[]` | Any message is added or updated           |

---

## Demo Server

A local development server lives in `demo/`. It renders `demo/public/index.html`, substituting `API_HOST`, `DEPLOYMENT_ID` and `TORUK_API_KEY` from `demo/.env` — so nothing is hardcoded and no credential is committed.

```bash
npm run build          # the demo serves dist/web.js

cd demo
cp .env.example .env
# Fill in API_HOST, DEPLOYMENT_ID and TORUK_API_KEY
npm install
npm start
# Open http://localhost:3001/ — the root path, not /index.html, so the template renders
```

Point the page at a different deployment without editing `.env`:

```
http://localhost:3001/?deploymentId=<other-uuid>
```

`chatflow_*` entries are optional. They feed only the legacy proxy routes, which the widget does not use; leave them blank and those routes are disabled.

Under `npm run dev` (rollup watch) the page is served statically with **no** substitution, so pass config in the URL instead:

```
http://localhost:3001/?apiHost=http://localhost:3000&deploymentId=<uuid>
```

> **The demo is not a model proxy.** `demo/server.js` forwards only the _legacy_ routes (`/api/v1/prediction/:id`, `/public-chatbotConfig`, `/chatflows-streaming`, `/get-upload-file`) and holds the key for those. It does **not** proxy `/api/v1/deployments/*`, which is what the widget calls today — so the demo page still sends the key from the browser for real widget traffic. Use a short-lived, deployment-bound key locally, and follow [Do not ship API keys to the browser](#do-not-ship-api-keys-to-the-browser) for anything real. The visitor-header forwarding in `demo/server.js` (`buildUpstreamHeaders` / `forwardVisitorHeader`) is still the correct pattern to copy into your own proxy.

---

## License

MIT — see [LICENSE](LICENSE).
