Skip to main content

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.

Use this page when the default widget UI is not the right fit and you want to own the rendering layer yourself.
Need the broader concept first? Start with Custom Components.
The headless path skips the default UI, but the widget options behave the same. Use the Playground to validate option shapes live before wiring them into your headless setup.

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
  • rendering your own message UI
  • switching on props.data.action?.name
  • owning fallback states and storage behavior

Step 1: Build The Message Component

Use so your renderer has access to the action name and payload.
AIAgentMessage.tsx
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.
App.tsx
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 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
PropTypeRequiredDescription
optionsWidgetConfigyesSame shape as the default <Widget> options — see Configuration.
childrenReact.ReactNodeyesYour custom widget UI.
componentsWidgetComponentType[]noCustom renderers keyed by slot (see Types Reference).
storageExternalStoragenoOverride the default browser storage adapter.
loadingComponentReact.ReactNodenoUI shown while the widget engine initializes. Distinct from the loading custom component, which renders during bot replies.
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
PropTypeRequiredDescription
childrenReact.ReactNodeyesAny subtree that needs to read or toggle isOpen.
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.
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>;
};
FieldTypeDescription
messagesState.messagesWidgetMessageU[]Ordered message list for the active session.
messagesState.isSendingMessagebooleanTrue while the user’s message is in flight.
messagesState.isSendingMessageToAIbooleanTrue while awaiting the AI pass that follows a send.
messagesState.lastAIResMightSolveUserIssuebooleanHint that the last AI reply likely resolved the question — surface CSAT off this.
messagesState.isInitialFetchLoadingbooleanTrue during the first history fetch.
sendMessage(input)(input) => Promise<void>Send a user message. content required; the rest optional.
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.
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;
};
FieldTypeDescription
sessionState.sessionSessionDto | nullThe active session, if any.
sessionState.isCreatingSessionbooleanTrue while creating a new session.
sessionState.isResolvingSessionbooleanTrue while closing the active session.
sessionsState.dataSessionDto[]All fetched sessions, newest-first.
sessionsState.cursorstring | undefinedPagination cursor for loadMoreSessions.
sessionsState.isLastPagebooleanTrue when no more pages remain.
sessionsState.didStartInitialFetchbooleanTrue once the initial fetch has been kicked off.
sessionsState.isInitialFetchLoadingbooleanTrue 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.
openSessionsSessionDto[]Convenience slice: sessions with isOpened: true.
closedSessionsSessionDto[]Convenience slice: sessions with isOpened: false.
canCreateNewSessionbooleanWhether a new session can be started right now.

useCsat()

Drive the CSAT survey flow — detect when the server requested feedback and submit a score.
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;
};
FieldTypeDescription
submitCsat(body)({ score, feedback? }) => Promise<...>Submit a CSAT score with optional free-text feedback.
csatRequestedMessageWidgetSystemMessage__CsatRequested | undefinedThe system message that triggered the CSAT prompt.
isCsatRequestedbooleanTrue when the server has asked for CSAT but it hasn’t been submitted.
csatSubmittedMessageWidgetSystemMessage__CsatSubmitted | undefinedThe system message recording the submitted score.
isCsatSubmittedbooleanTrue once the score has been submitted for this session.
submittedScorenumber | null | undefinedThe submitted score, if any.
submittedFeedbackstring | null | undefinedThe 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.
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.
function useWidget(): {
  widgetCtx: WidgetCtx;
  config: WidgetConfig;
  components?: WidgetComponentType[];
  componentStore: ComponentRegistry;
  version: string;
  contentIframeRef?: React.MutableRefObject<HTMLIFrameElement | null>;
};
FieldTypeDescription
widgetCtxWidgetCtxInternal widget context. Prefer the scoped hooks above.
configWidgetConfigThe live config passed to WidgetProvider.
componentsWidgetComponentType[] | undefinedCustom components registered on the provider.
componentStoreComponentRegistryRegistry used internally to resolve renderers by key.
versionstringWidget engine version — useful for debugging.
contentIframeRefRefObject<HTMLIFrameElement | null>Ref to the content iframe when the widget renders inside one.

useConfig()

Shortcut to the live WidgetConfig. Equivalent to useWidget().config.
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.
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>;
};
FieldTypeDescription
contactState.contact{ token; externalId? } | nullCurrent contact, or null if none.
contactState.extraCollectedDataRecord<string, string> | undefinedAny extra data collected alongside the contact.
contactState.isCreatingUnverifiedContactbooleanTrue while creating an unverified contact.
contactState.isErrorCreatingUnverifiedContactbooleanTrue 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.
function useDocumentDir(): { dir: 'ltr' | 'rtl' };

useWidgetRouter()

Navigate between the built-in screens (sessions list ↔ chat) from your custom UI.
function useWidgetRouter(): {
  routerState: { screen: ScreenU };
  toSessionsScreen: () => void;
  toChatScreen: (sessionId?: string) => void;
};
FieldTypeDescription
routerState.screenScreenUThe current screen identifier.
toSessionsScreen()() => voidSwitch to the sessions list.
toChatScreen(sessionId?)(sessionId?) => voidSwitch 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>.
function useWidgetTrigger(): {
  isOpen: boolean;
  setIsOpen: Dispatch<SetStateAction<boolean>>;
};
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.
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;
};
FieldTypeDescription
modes{ id; name; slug? }[]All modes configured for this widget.
modesComponentsModeComponent[] | undefinedMode components registered on the provider.
activeModeIdstring | null | undefinedId of the mode currently driving the flow.
activeMode{ id; name; slug? } | undefinedThe resolved active mode record.
Component(props: ModeComponentProps) => ReactElement | undefinedComponent to render for the active mode. Render null when undefined.

useUploadFiles()

Manage attachment uploads — queue, progress, cancel, retrieve URLs — to pair with useMessages().sendMessage.
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;
};
FieldTypeDescription
allFilesFileWithProgress[]Every queued upload, in any status.
appendFiles(files)(File[]) => voidQueue new files for upload.
handleCancelUpload(id)(string) => voidCancel an in-flight upload by id.
successFilesFileWithProgress[]Convenience slice: only successfully uploaded files.
emptyTheFiles()() => voidClear the queue (e.g. after attaching to a message).
getFileById(id)(string) => FileWithProgress | undefinedLook up a single entry.
getUploadProgress(id)(string) => numberUpload progress (0–100) for a file.
getUploadStatus(id)(string) => FileWithProgress['status'] | undefinedCurrent status for a file.
hasErrorsbooleanTrue if any file in the queue errored.
isUploadingbooleanTrue 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.
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.
type WidgetComponentProps<TData = unknown> =
  | WidgetAiMessage<TData>
  | WidgetAgentMessage
  | WidgetSystemMessageU;
See React Components for the action-name routing pattern.

WidgetComponentType

Registration entry for the components prop of WidgetProvider.
type WidgetComponentType = {
  key: WidgetComponentKey;
  component: React.ElementType;
};

FileWithProgress

Queue entry returned by useUploadFiles.
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 to WidgetProvider. Signature
type ExternalStorage = {
  get: (key: string) => Promise<string | null>;
  set: (key: string, value: string) => Promise<void>;
  remove: (key: string) => Promise<void>;
};
Example
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 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

Decide what visitors should see when no custom action renderer matches, when a payload is incomplete, or when a session is resolved.
Your renderer depends on action names and payload shapes staying predictable.
Make sure your custom UI still handles returning visitors, resolved sessions, and verified identity correctly.

Good Headless Use Cases

Fully custom layouts

Build your own message thread, session list, composer, or embedded support layout.

Platform-specific storage

Use your own storage layer when browser defaults are not the right fit.

Action-aware rendering

Swap in different UI for different action results without relying on the default widget shell.

Guided flows

Combine headless rendering with modes when the widget needs to collect structured input over multiple steps.

Custom Components

Decide whether you need action-result UI or a full headless build.

React Components

Register custom UI on the default React widget.

Configuration

Pair headless builds with widget behavior, modes, and prompts.

Install Widget

Start with the right implementation path before building custom UI.