> ## Documentation Index
> Fetch the complete documentation index at: https://docs.open.cx/llms.txt
> Use this file to discover all available pages before exploring further.

# Headless

> Build custom widget rendering with the headless packages when you need your own UI, routing, or storage model.

Use this page when the default widget UI is not the right fit and you want to own the rendering layer yourself.

<Tip>
  Need the broader concept first? Start with **[Custom Components](/widget/custom-components)**.
</Tip>

<Info>
  The headless path skips the default UI, but the widget options behave the same. Use the **[Playground](/widget/playground)** to validate option shapes live before wiring them into your headless setup.
</Info>

## How Headless Component Rendering Works

For a headless implementation, you wire the widget engine into your own UI and decide which custom component to render for each action result.

That usually means:

* wrapping your app in <Tooltip tip="The React context provider that initializes the headless widget engine and gives child components access to chat hooks and state.">`WidgetProvider`</Tooltip>
* rendering your own message UI
* switching on `props.data.action?.name`
* owning fallback states and storage behavior

## Step 1: Build The Message Component

Use <Tooltip tip="A typed React prop interface that provides the AI action result data to your custom component.">`WidgetComponentProps`</Tooltip> so your renderer has access to the action name and payload.

```tsx AIAgentMessage.tsx theme={"dark"}
import { WidgetComponentProps } from '@opencx/widget-react-headless';
import { AccountBalanceCard } from './AccountBalanceCard';
import { SpendingSummaryCard } from './SpendingSummaryCard';

export function AIAgentMessage(props: WidgetComponentProps) {
  switch (props.data.action?.name) {
    case 'getAccountBalance':
      return <AccountBalanceCard {...props} />;
    case 'getSpendingSummary':
      return <SpendingSummaryCard {...props} />;
    default:
      return <div>{props.data.content}</div>;
  }
}
```

## Step 2: Wire It Into The Headless Widget

Start with `WidgetProvider`, then render your own chat UI with the hooks and components you need.

```tsx App.tsx theme={"dark"}
import { WidgetProvider } from '@opencx/widget-react-headless';

export function App() {
  return (
    <WidgetProvider options={{ token: '<WIDGET_TOKEN>' }}>
      <CustomWidget />
    </WidgetProvider>
  );
}
```

From there, your custom widget can reach for the hooks below — each backed by `WidgetProvider`. The [Hooks Reference](#hooks-reference) documents every input, return field, and provider requirement.

## Providers Reference

The headless package ships two providers. `WidgetProvider` is required; `WidgetTriggerProvider` is only needed when a launcher button lives outside the widget surface (e.g. in your site header).

### `WidgetProvider`

Initializes the widget engine and powers every hook below it in the tree.

**Props**

| Prop               | Type                    | Required | Description                                                                                                                   |
| ------------------ | ----------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `options`          | `WidgetConfig`          | yes      | Same shape as the default `<Widget>` options — see [Configuration](/widget/configuration).                                    |
| `children`         | `React.ReactNode`       | yes      | Your custom widget UI.                                                                                                        |
| `components`       | `WidgetComponentType[]` | no       | Custom renderers keyed by slot (see [Types Reference](#types-reference)).                                                     |
| `storage`          | `ExternalStorage`       | no       | Override the default browser storage adapter.                                                                                 |
| `loadingComponent` | `React.ReactNode`       | no       | UI shown while the widget engine initializes. Distinct from the `loading` custom component, which renders during bot replies. |

```tsx theme={"dark"}
import { WidgetProvider } from '@opencx/widget-react-headless';

<WidgetProvider
  options={{ token: '<WIDGET_TOKEN>' }}
  loadingComponent={<Spinner />}
>
  <CustomWidget />
</WidgetProvider>
```

### `WidgetTriggerProvider`

Scopes `useWidgetTrigger` so a trigger button can live outside the widget surface while still toggling the widget open.

**Props**

| Prop       | Type              | Required | Description                                        |
| ---------- | ----------------- | -------- | -------------------------------------------------- |
| `children` | `React.ReactNode` | yes      | Any subtree that needs to read or toggle `isOpen`. |

```tsx theme={"dark"}
import { WidgetTriggerProvider } from '@opencx/widget-react-headless';

<WidgetTriggerProvider>
  <HeaderLauncher />    {/* calls useWidgetTrigger() for an open/close button */}
  <WidgetProvider options={{ token }}>
    <CustomWidget />
  </WidgetProvider>
</WidgetTriggerProvider>
```

## Hooks Reference

Every hook must be called inside `<WidgetProvider>`, with one exception: `useWidgetTrigger` requires `<WidgetTriggerProvider>`.

### Conversation

#### `useMessages()`

Read the message stream and send new user messages.

```ts theme={"dark"}
function useMessages(): {
  messagesState: {
    messages: WidgetMessageU[];
    isSendingMessage: boolean;
    isSendingMessageToAI: boolean;
    lastAIResMightSolveUserIssue: boolean;
    isInitialFetchLoading: boolean;
  };
  sendMessage: (input: {
    content: SendMessageDto['content'];
    attachments?: SendMessageDto['attachments'];
    customData?: SendMessageDto['custom_data'];
    exitModePrompt?: string;
  }) => Promise<void>;
};
```

| Field                                        | Type                       | Description                                                                       |
| -------------------------------------------- | -------------------------- | --------------------------------------------------------------------------------- |
| `messagesState.messages`                     | `WidgetMessageU[]`         | Ordered message list for the active session.                                      |
| `messagesState.isSendingMessage`             | `boolean`                  | True while the user's message is in flight.                                       |
| `messagesState.isSendingMessageToAI`         | `boolean`                  | True while awaiting the AI pass that follows a send.                              |
| `messagesState.lastAIResMightSolveUserIssue` | `boolean`                  | Hint that the last AI reply likely resolved the question — surface CSAT off this. |
| `messagesState.isInitialFetchLoading`        | `boolean`                  | True during the first history fetch.                                              |
| `sendMessage(input)`                         | `(input) => Promise<void>` | Send a user message. `content` required; the rest optional.                       |

```tsx theme={"dark"}
const { messagesState, sendMessage } = useMessages();
await sendMessage({ content: [{ type: 'text', text: 'Hi' }] });
```

#### `useSessions()`

Access session state, switch sessions, resolve the current session, and persist state checkpoints.

```ts theme={"dark"}
function useSessions(): {
  sessionState: {
    session: SessionDto | null;
    isCreatingSession: boolean;
    isResolvingSession: boolean;
  };
  sessionsState: {
    data: SessionDto[];
    cursor: string | undefined;
    isLastPage: boolean;
    didStartInitialFetch: boolean;
    isInitialFetchLoading: boolean;
  };
  loadMoreSessions: () => Promise<void>;
  resolveSession: () => Promise<
    | { success: true; data: SessionDto }
    | { success: false; error: 'Session is not opened' | { statusCode?: number; message?: string; error?: string } }
  >;
  createStateCheckpoint: (payload: Record<string, unknown>) => Promise<
    { success: true } | { success: false } | undefined
  >;
  openSessions: SessionDto[];
  closedSessions: SessionDto[];
  canCreateNewSession: boolean;
};
```

| Field                                 | Type                        | Description                                                                      |
| ------------------------------------- | --------------------------- | -------------------------------------------------------------------------------- |
| `sessionState.session`                | `SessionDto \| null`        | The active session, if any.                                                      |
| `sessionState.isCreatingSession`      | `boolean`                   | True while creating a new session.                                               |
| `sessionState.isResolvingSession`     | `boolean`                   | True while closing the active session.                                           |
| `sessionsState.data`                  | `SessionDto[]`              | All fetched sessions, newest-first.                                              |
| `sessionsState.cursor`                | `string \| undefined`       | Pagination cursor for `loadMoreSessions`.                                        |
| `sessionsState.isLastPage`            | `boolean`                   | True when no more pages remain.                                                  |
| `sessionsState.didStartInitialFetch`  | `boolean`                   | True once the initial fetch has been kicked off.                                 |
| `sessionsState.isInitialFetchLoading` | `boolean`                   | True during the initial fetch.                                                   |
| `loadMoreSessions()`                  | `() => Promise<void>`       | Fetch the next page into `sessionsState.data`.                                   |
| `resolveSession()`                    | `() => Promise<...>`        | Close the active session. Returns a discriminated union keyed on `success`.      |
| `createStateCheckpoint(payload)`      | `(payload) => Promise<...>` | Attach a structured state payload to the current session — used by guided modes. |
| `openSessions`                        | `SessionDto[]`              | Convenience slice: sessions with `isOpened: true`.                               |
| `closedSessions`                      | `SessionDto[]`              | Convenience slice: sessions with `isOpened: false`.                              |
| `canCreateNewSession`                 | `boolean`                   | Whether a new session can be started right now.                                  |

#### `useCsat()`

Drive the CSAT survey flow — detect when the server requested feedback and submit a score.

```ts theme={"dark"}
function useCsat(): {
  submitCsat: (body: { score: number; feedback?: string }) => Promise<
    | { data: null; error: string }
    | {
        data: { success: boolean } | undefined;
        error: { statusCode?: number; message?: string; error?: string } | undefined;
      }
  >;
  csatRequestedMessage: WidgetSystemMessage__CsatRequested | undefined;
  isCsatRequested: boolean;
  csatSubmittedMessage: WidgetSystemMessage__CsatSubmitted | undefined;
  isCsatSubmitted: boolean;
  submittedScore: number | null | undefined;
  submittedFeedback: string | null | undefined;
};
```

| Field                  | Type                                              | Description                                                           |
| ---------------------- | ------------------------------------------------- | --------------------------------------------------------------------- |
| `submitCsat(body)`     | `({ score, feedback? }) => Promise<...>`          | Submit a CSAT score with optional free-text feedback.                 |
| `csatRequestedMessage` | `WidgetSystemMessage__CsatRequested \| undefined` | The system message that triggered the CSAT prompt.                    |
| `isCsatRequested`      | `boolean`                                         | True when the server has asked for CSAT but it hasn't been submitted. |
| `csatSubmittedMessage` | `WidgetSystemMessage__CsatSubmitted \| undefined` | The system message recording the submitted score.                     |
| `isCsatSubmitted`      | `boolean`                                         | True once the score has been submitted for this session.              |
| `submittedScore`       | `number \| null \| undefined`                     | The submitted score, if any.                                          |
| `submittedFeedback`    | `string \| null \| undefined`                     | The submitted feedback text, if any.                                  |

#### `useIsAwaitingBotReply()`

Lightweight flag for "is the bot replying?" — use this for typing indicators without subscribing to the full message list.

```ts theme={"dark"}
function useIsAwaitingBotReply(): { isAwaitingBotReply: boolean };
```

### Widget state

#### `useWidget()`

Access the low-level widget context. Prefer the higher-level hooks unless you need raw context, the component registry, or the engine version.

```ts theme={"dark"}
function useWidget(): {
  widgetCtx: WidgetCtx;
  config: WidgetConfig;
  components?: WidgetComponentType[];
  componentStore: ComponentRegistry;
  version: string;
  contentIframeRef?: React.MutableRefObject<HTMLIFrameElement | null>;
};
```

| Field              | Type                                   | Description                                                   |
| ------------------ | -------------------------------------- | ------------------------------------------------------------- |
| `widgetCtx`        | `WidgetCtx`                            | Internal widget context. Prefer the scoped hooks above.       |
| `config`           | `WidgetConfig`                         | The live config passed to `WidgetProvider`.                   |
| `components`       | `WidgetComponentType[] \| undefined`   | Custom components registered on the provider.                 |
| `componentStore`   | `ComponentRegistry`                    | Registry used internally to resolve renderers by key.         |
| `version`          | `string`                               | Widget engine version — useful for debugging.                 |
| `contentIframeRef` | `RefObject<HTMLIFrameElement \| null>` | Ref to the content iframe when the widget renders inside one. |

#### `useConfig()`

Shortcut to the live `WidgetConfig`. Equivalent to `useWidget().config`.

```ts theme={"dark"}
function useConfig(): WidgetConfig;
```

#### `useContact()`

Read the current contact (verified or unverified) and create an unverified contact on the fly when you collect identity inside your own UI.

```ts theme={"dark"}
function useContact(): {
  contactState: {
    contact: { token: string; externalId: string | undefined } | null;
    extraCollectedData: Record<string, string> | undefined;
    isCreatingUnverifiedContact: boolean;
    isErrorCreatingUnverifiedContact: boolean;
  };
  createUnverifiedContact: (
    payload: {
      email?: string;
      non_verified_name?: string;
      non_verified_custom_data?: Record<string, string | number | boolean>;
    },
    extraCollectedData?: Record<string, string>,
  ) => Promise<void>;
};
```

| Field                                           | Type                                  | Description                                                              |
| ----------------------------------------------- | ------------------------------------- | ------------------------------------------------------------------------ |
| `contactState.contact`                          | `{ token; externalId? } \| null`      | Current contact, or `null` if none.                                      |
| `contactState.extraCollectedData`               | `Record<string, string> \| undefined` | Any extra data collected alongside the contact.                          |
| `contactState.isCreatingUnverifiedContact`      | `boolean`                             | True while creating an unverified contact.                               |
| `contactState.isErrorCreatingUnverifiedContact` | `boolean`                             | True if the last create attempt failed.                                  |
| `createUnverifiedContact(payload, extra?)`      | `(payload, extra?) => Promise<void>`  | Create an unverified contact with optional email, name, and custom data. |

#### `useDocumentDir()`

Resolved text direction — `'ltr'` or `'rtl'` — so your custom UI can mirror it.

```ts theme={"dark"}
function useDocumentDir(): { dir: 'ltr' | 'rtl' };
```

### Navigation & trigger

#### `useWidgetRouter()`

Navigate between the built-in screens (sessions list ↔ chat) from your custom UI.

```ts theme={"dark"}
function useWidgetRouter(): {
  routerState: { screen: ScreenU };
  toSessionsScreen: () => void;
  toChatScreen: (sessionId?: string) => void;
};
```

| Field                      | Type                   | Description                                                                                         |
| -------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------- |
| `routerState.screen`       | `ScreenU`              | The current screen identifier.                                                                      |
| `toSessionsScreen()`       | `() => void`           | Switch to the sessions list.                                                                        |
| `toChatScreen(sessionId?)` | `(sessionId?) => void` | Switch to a chat screen. Pass a session id to open a specific session; omit to open the active one. |

#### `useWidgetTrigger()`

Read and toggle the widget's open/closed state from anywhere inside `<WidgetTriggerProvider>`.

**Requires:** Inside `<WidgetTriggerProvider>`.

```ts theme={"dark"}
function useWidgetTrigger(): {
  isOpen: boolean;
  setIsOpen: Dispatch<SetStateAction<boolean>>;
};
```

```tsx theme={"dark"}
function HeaderLauncher() {
  const { isOpen, setIsOpen } = useWidgetTrigger();
  return (
    <button onClick={() => setIsOpen((v) => !v)}>
      {isOpen ? 'Close chat' : 'Chat with us'}
    </button>
  );
}
```

### Modes & files

#### `useModes()`

Read the configured modes, the currently active mode, and the component that renders it.

```ts theme={"dark"}
function useModes(): {
  modes: { id: string; name: string; slug?: string | null }[];
  modesComponents: ModeComponent[] | undefined;
  activeModeId: string | null | undefined;
  activeMode: { id: string; name: string; slug?: string | null } | undefined;
  Component: ((props: ModeComponentProps) => React.ReactElement) | undefined;
};
```

| Field             | Type                                                       | Description                                                              |
| ----------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------ |
| `modes`           | `{ id; name; slug? }[]`                                    | All modes configured for this widget.                                    |
| `modesComponents` | `ModeComponent[] \| undefined`                             | Mode components registered on the provider.                              |
| `activeModeId`    | `string \| null \| undefined`                              | Id of the mode currently driving the flow.                               |
| `activeMode`      | `{ id; name; slug? } \| undefined`                         | The resolved active mode record.                                         |
| `Component`       | `(props: ModeComponentProps) => ReactElement \| undefined` | Component to render for the active mode. Render `null` when `undefined`. |

#### `useUploadFiles()`

Manage attachment uploads — queue, progress, cancel, retrieve URLs — to pair with `useMessages().sendMessage`.

```ts theme={"dark"}
function useUploadFiles(): {
  allFiles: FileWithProgress[];
  appendFiles: (files: File[]) => void;
  handleCancelUpload: (fileId: string) => void;
  successFiles: FileWithProgress[];
  emptyTheFiles: () => void;
  getFileById: (id: string) => FileWithProgress | undefined;
  getUploadProgress: (id: string) => number;
  getUploadStatus: (id: string) => 'pending' | 'uploading' | 'success' | 'error' | undefined;
  hasErrors: boolean;
  isUploading: boolean;
};
```

| Field                    | Type                                                  | Description                                          |
| ------------------------ | ----------------------------------------------------- | ---------------------------------------------------- |
| `allFiles`               | `FileWithProgress[]`                                  | Every queued upload, in any status.                  |
| `appendFiles(files)`     | `(File[]) => void`                                    | Queue new files for upload.                          |
| `handleCancelUpload(id)` | `(string) => void`                                    | Cancel an in-flight upload by id.                    |
| `successFiles`           | `FileWithProgress[]`                                  | Convenience slice: only successfully uploaded files. |
| `emptyTheFiles()`        | `() => void`                                          | Clear the queue (e.g. after attaching to a message). |
| `getFileById(id)`        | `(string) => FileWithProgress \| undefined`           | Look up a single entry.                              |
| `getUploadProgress(id)`  | `(string) => number`                                  | Upload progress (0–100) for a file.                  |
| `getUploadStatus(id)`    | `(string) => FileWithProgress['status'] \| undefined` | Current status for a file.                           |
| `hasErrors`              | `boolean`                                             | True if any file in the queue errored.               |
| `isUploading`            | `boolean`                                             | True while any file is still uploading.              |

### Utilities

#### `usePrimitiveState<T>()`

Subscribe a React component to a `PrimitiveState<T>` published by the widget core. Returns the current value and re-renders on change.

```ts theme={"dark"}
function usePrimitiveState<T>(p: PrimitiveState<T>): T;
```

Use this only when wiring advanced integrations that read a primitive state directly from `@opencx/widget-core`. Most app code should prefer the higher-level hooks above.

## Types Reference

### `WidgetComponentProps<TData = unknown>`

Prop type passed to custom renderers. It is a discriminated union of the three message kinds — switch on the message shape (or `data.action?.name` for AI action results) to decide what to render.

```ts theme={"dark"}
type WidgetComponentProps<TData = unknown> =
  | WidgetAiMessage<TData>
  | WidgetAgentMessage
  | WidgetSystemMessageU;
```

See [React Components](/widget/custom-components-react) for the action-name routing pattern.

### `WidgetComponentType`

Registration entry for the `components` prop of `WidgetProvider`.

```ts theme={"dark"}
type WidgetComponentType = {
  key: WidgetComponentKey;
  component: React.ElementType;
};
```

### `FileWithProgress`

Queue entry returned by `useUploadFiles`.

```ts theme={"dark"}
type FileWithProgress = {
  status: 'pending' | 'uploading' | 'success' | 'error';
  id: string;
  file: File;
  fileUrl?: string;
  progress: number;
  error?: string;
};
```

## Step 3: Add Storage When The Browser Default Is Not Enough

If browser storage is not the right fit, pass an <Tooltip tip="A storage adapter from @opencx/widget-core that lets you replace browser localStorage with your own persistence layer.">`ExternalStorage`</Tooltip> to `WidgetProvider`.

**Signature**

```ts theme={"dark"}
type ExternalStorage = {
  get: (key: string) => Promise<string | null>;
  set: (key: string, value: string) => Promise<void>;
  remove: (key: string) => Promise<void>;
};
```

**Example**

```ts theme={"dark"}
import type { ExternalStorage } from '@opencx/widget-core';

const storage: ExternalStorage = {
  async get(key) {
    return SecureStore.getItemAsync(key);
  },
  async set(key, value) {
    await SecureStore.setItemAsync(key, value);
  },
  async remove(key) {
    await SecureStore.deleteItemAsync(key);
  },
};

<WidgetProvider options={{ token }} storage={storage}>
  <CustomWidget />
</WidgetProvider>
```

This is especially useful in mobile apps, embedded environments, or custom shells where your app controls persistence itself.

## Step 4: Use Modes When You Need A Guided Flow

Use <Tooltip tip="Widget components that take over the full chat area to drive a structured flow — like onboarding, qualification, or multi-step forms.">`modesComponents`</Tooltip> when the widget should temporarily switch from normal chat to a guided flow such as onboarding, qualification, or structured data collection.

Use an action-result component when you are displaying a **result**. Use a mode component when you are driving a **flow**.

## What To Watch For

<AccordionGroup>
  <Accordion title="Own the fallback UI" icon="wrench">
    Decide what visitors should see when no custom action renderer matches, when a payload is incomplete, or when a session is resolved.
  </Accordion>

  <Accordion title="Keep action names stable" icon="key">
    Your renderer depends on action names and payload shapes staying predictable.
  </Accordion>

  <Accordion title="Test history and identity" icon="clock">
    Make sure your custom UI still handles returning visitors, resolved sessions, and verified identity correctly.
  </Accordion>
</AccordionGroup>

## Good Headless Use Cases

<CardGroup cols={2}>
  <Card title="Fully custom layouts" icon="rectangle-list">
    Build your own message thread, session list, composer, or embedded support layout.
  </Card>

  <Card title="Platform-specific storage" icon="database">
    Use your own storage layer when browser defaults are not the right fit.
  </Card>

  <Card title="Action-aware rendering" icon="bolt">
    Swap in different UI for different action results without relying on the default widget shell.
  </Card>

  <Card title="Guided flows" icon="sliders">
    Combine headless rendering with modes when the widget needs to collect structured input over multiple steps.
  </Card>
</CardGroup>

***

## Related Documentation

<CardGroup cols={2}>
  <Card title="Custom Components" icon="paintbrush" href="/widget/custom-components">
    Decide whether you need action-result UI or a full headless build.
  </Card>

  <Card title="React Components" icon="react" href="/widget/custom-components-react">
    Register custom UI on the default React widget.
  </Card>

  <Card title="Configuration" icon="gear" href="/widget/configuration">
    Pair headless builds with widget behavior, modes, and prompts.
  </Card>

  <Card title="Install Widget" icon="plug" href="/widget/install-widget">
    Start with the right implementation path before building custom UI.
  </Card>
</CardGroup>
