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

# Customize Conversation UI

> Build and tailor your own conversation interface.

This guide is based on the [Akapulu Labs Web SDK](/web-sdk/overview).

## Packages

* `@akapulu/react`
* `@akapulu/react-ui`
* `@akapulu/server`

## Quickstart (React)

`AkapuluProvider` takes paths to your **local API routes**:

* `connectPath`: your local connect endpoint
* `updatesPath`: your local updates endpoint

The SDK calls these local routes from the browser. Your server routes then call Akapulu Labs APIs using `@akapulu/server`.

```tsx theme={null}
import { AkapuluProvider } from "@akapulu/react";
import { AkapuluConversation } from "@akapulu/react-ui";

export function App() {
  return (
    <AkapuluProvider
      config={{
        endpoints: {
          connectPath: "/api/akapulu/connect",
          updatesPath: "/api/akapulu/updates",
        },
      }}
    >
      <AkapuluConversation title="Akapulu demo" />
    </AkapuluProvider>
  );
}
```

## Backend connect and updates routes

Your local `connect` route should:

* call `akapulu.connectConversation(...)` with the Akapulu Labs connect schema
  * `scenario_id: string`
  * `avatar_id: string`
  * `runtime_vars?: Record<string, string>`
  * `stt_keywords?: string[]`
  * `record_conversation?: boolean`
* return the connect response fields required by the SDK:
  * `room_url`
  * `token`
  * `conversation_session_id`

Your local `updates` route should:

* read `conversation_session_id` from the query param
* call `akapulu.pollConversationUpdates(conversationSessionId)`
* return the updates payload fields required by the SDK:
  * `call_is_ready`
  * `completion_percent`
  * `latest_update_text`
  * `conversation_session_id`

The SDK always calls your `updatesPath` with `conversation_session_id` in the URL query string.

Install `@akapulu/server` in your backend and create two routes:

* `POST /api/akapulu/connect`
* `GET /api/akapulu/updates?conversation_session_id=...`

```ts theme={null}
import { createAkapuluServerClient } from "@akapulu/server";

const akapulu = createAkapuluServerClient();

// POST /api/akapulu/connect
export async function connectRoute() {
  return await akapulu.connectConversation({
    scenario_id: "YOUR_SCENARIO_ID",
    avatar_id: "YOUR_AVATAR_ID",
    runtime_vars: {},
    record_conversation: false,
  });
}

// GET /api/akapulu/updates?conversation_session_id=...
export async function updatesRoute(conversationSessionId: string) {
  return await akapulu.pollConversationUpdates(conversationSessionId);
}
```

Then point `AkapuluProvider` to those local paths:

* `connectPath: "/api/akapulu/connect"`
* `updatesPath: "/api/akapulu/updates"`

### Browser vs server

* **In the browser:** `AkapuluProvider` (via `config.endpoints.connectPath` and `config.endpoints.updatesPath`) requests your connect and updates backend routes.

* **On your server:** those routes use `@akapulu/server`, read the Akapulu Labs API key from server-side environment variable, and call the Akapulu Labs HTTP API. That way your api key stays on the server and does not get exposed in the browser

`AkapuluProvider` expects your local **connect** and **updates** endpoints to return the JSON schema described under **Backend connect and updates routes** above.

## Passing custom payload to your local connect route

If you want, `AkapuluProvider` can send a custom JSON payload to your local `connectPath` route via `config.connectBody`.

* If `connectBody` is provided, the SDK sends it as the POST JSON body to your local connect route.
* If `connectBody` is omitted, the SDK sends `POST /connectPath` with no body.

Frontend example:

```tsx theme={null}
import { AkapuluProvider } from "@akapulu/react";
import { AkapuluConversation } from "@akapulu/react-ui";

export function App() {
  return (
    <AkapuluProvider
      config={{
        endpoints: {
          connectPath: "/api/akapulu/connect",
          updatesPath: "/api/akapulu/updates",
        },
        connectBody: {
          tenant_id: "acme-health",
          patient_id: "patient_001",
          should_record: true,
        },
      }}
    >
      <AkapuluConversation title="Akapulu demo" />
    </AkapuluProvider>
  );
}
```

Backend example (your local connect route):

```ts theme={null}
import { createAkapuluServerClient } from "@akapulu/server";

const akapulu = createAkapuluServerClient();

type LocalConnectBody = {
  tenant_id?: string;
  patient_id?: string;
  should_record?: boolean;
};

export async function connectRoute(body: LocalConnectBody) {
  return await akapulu.connectConversation({
    scenario_id: "YOUR_SCENARIO_ID",
    avatar_id: "YOUR_AVATAR_ID",
    runtime_vars: {
      patient_id: body.patient_id || "patient_001",
      tenant_id: body.tenant_id || "default_tenant",
    },
    record_conversation: body.should_record === true,
  });
}
```

## Styling `AkapuluConversation`

`@akapulu/react-ui` supports slot-level customization via:

* `className` for the root
* `classes` for slot class names
* `styles` for slot inline style overrides

### Example 1: Slot key overrides (`classes` + `styles`)

Use slot keys when you want to target specific UI parts directly from React props.

```tsx theme={null}
<AkapuluConversation
  title="Akapulu demo"
  classes={{
    transcriptContainer: "myTranscriptContainer",
    controlEnd: "myLeaveButton",
  }}
  styles={{
    videoPane: { borderRadius: 20 },
    connectedLayout: { gap: "1.5rem" },
  }}
/>
```

### Example 2: Default class overrides (global CSS)

Use built-in default classes when you want to apply theme-like global styles from CSS.

```css theme={null}
.akapulu-transcript-container {
  border: 1px solid #334155;
  border-radius: 14px;
}

.akapulu-control-end {
  background: rgba(185, 28, 28, 0.9);
}
```

### Example 3: `data-slot` overrides (stable CSS selectors)

Use `data-slot` selectors when you want explicit, inspectable selectors in DevTools.

```css theme={null}
[data-slot="transcript-header"] {
  backdrop-filter: blur(2px);
}

[data-slot="pip"] {
  width: 28%;
  border-color: #60a5fa;
}
```

### Slot map

Each slot has both a default class and a `data-slot` marker so users can inspect and target it in DevTools without guessing.

#### Layout

| Slot key          | What it targets                             | Default class              | `data-slot` value  |
| ----------------- | ------------------------------------------- | -------------------------- | ------------------ |
| `container`       | Outer wrapper for the whole conversation UI | `akapulu-conversation`     | `container`        |
| `title`           | Top heading text                            | `akapulu-title`            | `title`            |
| `connectedLayout` | Main connected-state grid wrapper           | `akapulu-connected-layout` | `connected-layout` |
| `startButton`     | Idle/error state start-call button          | `akapulu-start-button`     | `start-button`     |

#### Loading

| Slot key               | What it targets                            | Default class                    | `data-slot` value        |
| ---------------------- | ------------------------------------------ | -------------------------------- | ------------------------ |
| `loadingContainer`     | Wrapper for loading state UI               | `akapulu-loading-container`      | `loading-container`      |
| `loadingSpinner`       | Loading spinner element                    | `akapulu-loading-spinner`        | `loading-spinner`        |
| `loadingLabel`         | Loading headline text (e.g. Connecting...) | `akapulu-loading-label`          | `loading-label`          |
| `loadingProgressTrack` | Progress bar background track              | `akapulu-loading-progress-track` | `loading-progress-track` |
| `loadingProgressFill`  | Progress bar filled portion                | `akapulu-loading-progress-fill`  | `loading-progress-fill`  |
| `loadingStatusText`    | Detailed status text under progress bar    | `akapulu-loading-status-text`    | `loading-status-text`    |

#### Error modal

| Slot key             | What it targets                             | Default class                  | `data-slot` value      |
| -------------------- | ------------------------------------------- | ------------------------------ | ---------------------- |
| `errorModalBackdrop` | Full-screen modal overlay behind error card | `akapulu-error-modal-backdrop` | `error-modal-backdrop` |
| `errorModalCard`     | Error modal content card                    | `akapulu-error-modal-card`     | `error-modal-card`     |

#### Tool events

| Slot key    | What it targets                       | Default class        | `data-slot` value |
| ----------- | ------------------------------------- | -------------------- | ----------------- |
| `toolToast` | Floating toast/card for tool activity | `akapulu-tool-toast` | `tool-toast`      |

#### Video + controls

| Slot key        | What it targets                             | Default class             | `data-slot` value |
| --------------- | ------------------------------------------- | ------------------------- | ----------------- |
| `videoPane`     | Video column container                      | `akapulu-video-pane`      | `video-pane`      |
| `videoSurface`  | Primary remote/bot video surface            | `akapulu-video-surface`   | `video-surface`   |
| `botStateBadge` | Speaking/listening/idle badge on video      | `akapulu-bot-state-badge` | `bot-state-badge` |
| `pip`           | Local picture-in-picture preview tile       | `akapulu-pip`             | `pip`             |
| `waitingVideo`  | Placeholder shown before video is available | `akapulu-waiting-video`   | `waiting-video`   |
| `controlMic`    | In-call mic button (video mode)             | `akapulu-control-mic`     | `control-mic`     |
| `controlCam`    | In-call camera button (video mode)          | `akapulu-control-cam`     | `control-cam`     |
| `controlEnd`    | In-call hang-up button (video mode)         | `akapulu-control-end`     | `control-end`     |

#### Transcript

| Slot key              | What it targets                        | Default class                  | `data-slot` value      |
| --------------------- | -------------------------------------- | ------------------------------ | ---------------------- |
| `transcriptPane`      | Transcript column container            | `akapulu-transcript-pane`      | `transcript-pane`      |
| `transcriptContainer` | Scrollable transcript box              | `akapulu-transcript-container` | `transcript-container` |
| `transcriptHeader`    | Transcript header row                  | `akapulu-transcript-header`    | `transcript-header`    |
| `nodeChip`            | Current node chip in transcript header | `akapulu-node-chip`            | `node-chip`            |
| `transcriptRowUser`   | User transcript row/bubble             | `akapulu-transcript-row-user`  | `transcript-row-user`  |
| `transcriptRowBot`    | Bot transcript row/bubble              | `akapulu-transcript-row-bot`   | `transcript-row-bot`   |

## Behavior customization (handlers + custom elements)

Beyond styles, `@akapulu/react-ui` lets you customize behavior and rendering for transcript/tool events directly on `AkapuluConversation`.

### Built-in handler props on `AkapuluConversation`

* `transcriptFilter(entry)` to hide transcript rows
* `renderTranscriptEntry(entry)` to render transcript rows with your own JSX
* `onToolEvent(tool)` to run side effects when a tool event arrives
* `renderToolEvent(tool)` to replace the default tool toast element
* `toolEventTimeoutMs` to control how long the tool toast stays visible

Callback signatures:

```ts theme={null}
transcriptFilter?: (entry: TranscriptEntry) => boolean;
renderTranscriptEntry?: (entry: TranscriptEntry) => ReactNode;
onToolEvent?: (tool: NormalizedToolEvent) => void;
renderToolEvent?: (tool: NormalizedToolEvent) => ReactNode;
toolEventTimeoutMs?: number | null;
```

`toolEventTimeoutMs` behavior:

* default: `4000`
* pass a number to customize the auto-hide timeout
* pass `null` to disable auto-hide

`TranscriptEntry` shape (`entry`):

| Field       | Type              | Description                              |
| ----------- | ----------------- | ---------------------------------------- |
| `id`        | `string`          | Stable transcript row id                 |
| `speaker`   | `"user" \| "bot"` | Who produced this transcript chunk       |
| `text`      | `string`          | Transcript text                          |
| `timestamp` | `string`          | Event timestamp                          |
| `isFinal`   | `boolean`         | Whether this transcript row is finalized |

`NormalizedToolEvent` shape (`tool`) by tool type:

#### `RAG` tool event

| Field          | Type                  | Description            |
| -------------- | --------------------- | ---------------------- |
| `messageType`  | `"RAG"`               | RAG tool event         |
| `functionName` | `string`              | RAG function/tool name |
| `summary`      | `string`              | `"RAG tool called"`    |
| `query`        | `string \| undefined` | Query text             |

#### `vision` tool event

| Field          | Type       | Description               |
| -------------- | ---------- | ------------------------- |
| `messageType`  | `"vision"` | Vision tool event         |
| `functionName` | `string`   | Vision function/tool name |
| `summary`      | `string`   | `"Vision tool called"`    |

#### `http` tool event

| Field          | Type                      | Description                          |
| -------------- | ------------------------- | ------------------------------------ |
| `messageType`  | `"http"`                  | HTTP tool event                      |
| `functionName` | `string`                  | HTTP function/tool name              |
| `summary`      | `string`                  | `"HTTP endpoint called"`             |
| `argsJson`     | `string \| undefined`     | Pretty JSON string of `body`         |
| `body`         | `Record<string, unknown>` | Outgoing HTTP request payload fields |

```tsx theme={null}
import { AkapuluProvider } from "@akapulu/react";
import { AkapuluConversation } from "@akapulu/react-ui";

export function App() {
  return (
    <AkapuluProvider config={config}>
      <AkapuluConversation
        title="Akapulu demo"
        transcriptFilter={(entry) => {
          // Example: skip rows you do not want in the transcript list
          return entry.text.trim() !== "skip me";
        }}
        renderTranscriptEntry={(entry) => (
          <div>
            <strong>{entry.speaker === "user" ? "You" : "Bot"}:</strong> {entry.text}
          </div>
        )}
        onToolEvent={(tool) => {
          // Example: analytics side effect
          console.log("tool_event", tool.messageType, tool.functionName);
        }}
        renderToolEvent={(tool) => (
          <div style={{ border: "1px solid #334155", borderRadius: 8, padding: 12 }}>
            <strong>{tool.summary}</strong>
            <div style={{ marginTop: 6, opacity: 0.85 }}>{tool.functionName}</div>
          </div>
        )}
      />
    </AkapuluProvider>
  );
}
```

### Handling all conversation events while keeping prebuilt UI

For full event handling (node changes, bot speaking state, transcript updates, tool calls, and timeout), add a small sibling listener component that uses `useAkapuluEvents`.

```tsx theme={null}
import { AkapuluProvider, useAkapuluEvents } from "@akapulu/react";
import { AkapuluConversation } from "@akapulu/react-ui";

function ConversationEventListener() {
  useAkapuluEvents((event) => {
    // … handle `event` (discriminated by `event.type`; see schema below)
  });
  return null;
}

export function App() {
  return (
    <AkapuluProvider
      config={{
        endpoints: {
          connectPath: "/api/akapulu/connect",
          updatesPath: "/api/akapulu/updates",
        },
      }}
    >
      <AkapuluConversation title="Akapulu demo" />
      <ConversationEventListener />
    </AkapuluProvider>
  );
}
```

Event schema by `event.type`:

#### `status_changed`

| Field    | Type                                                                             | Description             |
| -------- | -------------------------------------------------------------------------------- | ----------------------- |
| `type`   | `"status_changed"`                                                               | Discriminator           |
| `status` | `"idle" \| "connecting" \| "connected" \| "disconnecting" \| "ended" \| "error"` | Session lifecycle state |

#### `bot_speaking_state_changed`

| Field           | Type                                  | Description                  |
| --------------- | ------------------------------------- | ---------------------------- |
| `type`          | `"bot_speaking_state_changed"`        | Discriminator                |
| `speakingState` | `"idle" \| "speaking" \| "listening"` | Bot speaking/listening state |

#### `node_changed`

| Field  | Type                                     | Description       |
| ------ | ---------------------------------------- | ----------------- |
| `type` | `"node_changed"`                         | Discriminator     |
| `node` | `{ key: string; label: string } \| null` | Current flow node |

#### `tool_event`

| Field  | Type                  | Description                   |
| ------ | --------------------- | ----------------------------- |
| `type` | `"tool_event"`        | Discriminator                 |
| `tool` | `NormalizedToolEvent` | Normalized tool event payload |

#### `transcript_updated`

| Field        | Type                   | Description            |
| ------------ | ---------------------- | ---------------------- |
| `type`       | `"transcript_updated"` | Discriminator          |
| `transcript` | `TranscriptEntry`      | Updated transcript row |

#### `call_ready`

| Field  | Type           | Description              |
| ------ | -------------- | ------------------------ |
| `type` | `"call_ready"` | Call reached ready state |

#### `timeout`

| Field    | Type                                                     | Description                 |
| -------- | -------------------------------------------------------- | --------------------------- |
| `type`   | `"timeout"`                                              | Discriminator               |
| `reason` | `"duration_limit_reached" \| "participant_join_timeout"` | Timeout reason from backend |

```tsx theme={null}
import { AkapuluProvider, useAkapuluEvents } from "@akapulu/react";
import { AkapuluConversation } from "@akapulu/react-ui";

function ConversationEventBridge() {
  useAkapuluEvents((event) => {
    if (event.type === "transcript_updated") {
      // event.transcript
      return;
    }
    if (event.type === "bot_speaking_state_changed") {
      // event.speakingState: "idle" | "speaking" | "listening"
      return;
    }
    if (event.type === "node_changed") {
      // event.node
      return;
    }
    if (event.type === "tool_event") {
      // event.tool
      return;
    }
    if (event.type === "timeout") {
      // event.reason:
      // - "duration_limit_reached" => call ended because session duration cap was reached
      // - "participant_join_timeout" => call ended because no participant joined in time
      return;
    }
  });

  return null;
}

export function App() {
  return (
    <AkapuluProvider config={config}>
      <ConversationEventBridge />
      <AkapuluConversation title="Akapulu demo" />
    </AkapuluProvider>
  );
}
```

## Using `@akapulu/react` without `@akapulu/react-ui`

The **same** connect/updates route pattern applies: `AkapuluProvider` points at your local `connectPath` and `updatesPath`, your server uses `@akapulu/server`. What changes is **UI**: you build layout yourself and pull state from hooks instead of mounting `AkapuluConversation`.

**How it fits together**

1. Wrap your tree in `AkapuluProvider` (as in the Quickstart).

```tsx theme={null}
<AkapuluProvider
  config={{
    endpoints: {
      connectPath: "/api/akapulu/connect",
      updatesPath: "/api/akapulu/updates",
    },
  }}
>
  {/* your UI */}
</AkapuluProvider>
```

2. Call `useAkapuluSession()` anywhere under that provider. It exposes everything in the session store plus `start` / `end`:

   **Lifecycle & connection**

   * `status` — where the client is in the join/leave flow: `"idle"` → `"connecting"` → `"connected"` → `"disconnecting"` / `"ended"`, or `"error"` if something failed.
   * `start` / `end` — async actions that begin the conversation (connect + Daily join) or hang up and reset session state.
   * `error` — when `status` is `"error"`, structured details (`code` optional, `message` required) for your error UI or logging.

   **Call readiness & updates** (from your **updates** route)

   * `callIsReady` — whether the backend considers the call ready (often used while `status === "connecting"` so you are not stuck on a spinner forever).
   * `completionPercent` — numeric progress through the scenario (0–100).
   * `latestUpdateText` — short human-readable status line for loading/progress copy.

   **Scenario / flow**

   * `currentNode` — the active scenario node (`{ key, label }`) or `null` if none.

   **Transcript & bot**

   * `transcripts` — ordered list of rows (`id`, `text`, `speaker`, `timestamp`, `isFinal`) for your own transcript UI.
   * `botSpeakingState` — `"idle"`, `"speaking"`, or `"listening"` for indicators or turn-taking UI.

   **Correlation**

   * `conversationSessionId` — Akapulu Labs conversation session id from connect (used when polling updates; also useful if your app logs or links out to dashboard/API records).

```tsx theme={null}
const {
  status,
  start,
  end,
  error,
  callIsReady,
  conversationSessionId,
  completionPercent,
  latestUpdateText,
  currentNode,
  transcripts,
  botSpeakingState,
} = useAkapuluSession();
```

3. The provider joins the **Daily** room for realtime media. Install `@daily-co/daily-react` (and peer `@daily-co/daily-js`) and use its primitives to draw video—for example `DailyVideo`, `useDaily`, and `useVideoTrack`. For assistant video selection, use `useAkapuluParticipantRoles` from `@akapulu/react`.

```tsx theme={null}
import { useAkapuluParticipantRoles } from "@akapulu/react";
import { DailyVideo, useDaily, useVideoTrack } from "@daily-co/daily-react";

function BotVideoTile() {
  const { akapuluBotParticipantId } = useAkapuluParticipantRoles();
  const track = useVideoTrack(akapuluBotParticipantId);
  if (!akapuluBotParticipantId || track.isOff) return null;
  return <DailyVideo sessionId={akapuluBotParticipantId} type="video" />;
}

function SelfVideoTile() {
  const daily = useDaily();
  const sessionId = daily?.participants().local.session_id ?? "";
  const track = useVideoTrack(sessionId);
  if (!sessionId || track.isOff) return null;
  return <DailyVideo sessionId={sessionId} type="video" />;
}
```

4. Use `useAkapuluMediaControls()` for in-call mic and camera toggles (wired to that Daily session).

```tsx theme={null}
const { toggleMic, toggleCam, isMicMuted, isCamOff } = useAkapuluMediaControls();

<button type="button" onClick={() => void toggleMic()}>
  Mic {isMicMuted ? "unmute" : "mute"}
</button>
<button type="button" onClick={() => void toggleCam()}>
  Cam {isCamOff ? "on" : "off"}
</button>
```

5. Render `<AkapuluBotAudio />` once in the tree so assistant audio plays (small hidden element; required for typical voice/video bots).

```tsx theme={null}
<AkapuluBotAudio />
```

6. Optionally `useAkapuluEvents(callback)` to react to the full event stream (`transcript_updated`, `tool_event`, `node_changed`, `timeout`, etc.) while still rendering your own UI.

```tsx theme={null}
useAkapuluEvents((event) => {
  // … handle `event`
});
```

**Rough outline**

```tsx theme={null}
"use client";

import {
  AkapuluBotAudio,
  AkapuluProvider,
  useAkapuluEvents,
  useAkapuluMediaControls,
  useAkapuluParticipantRoles,
  useAkapuluSession,
} from "@akapulu/react";
import { DailyVideo, useDaily, useVideoTrack } from "@daily-co/daily-react";

// Single participant video tile; Daily hooks are available because `AkapuluProvider` wraps Daily context.
function VideoTile(props: { sessionId: string; isLocal: boolean }) {
  // Track state for this participant’s video in the current Daily call.
  const videoTrack = useVideoTrack(props.sessionId);

  if (videoTrack.isOff) {
    // TODO: render your own “camera off” placeholder here.
    return null;
  }

  // `sessionId` must be this participant’s Daily `session_id` (from `useAkapuluParticipantRoles` or `participants().local`).
  return <DailyVideo sessionId={props.sessionId} type="video" />;
}

function CustomCallUi() {
  // Akapulu Labs session: status, lifecycle controls, transcript + progress state.
  const {
    status,
    start,
    end,
    error,
    transcripts,
    completionPercent,
    latestUpdateText,
    botSpeakingState,
    currentNode,
  } = useAkapuluSession();

  // Media controls for the underlying Daily call.
  const { toggleMic, toggleCam, isMicMuted, isCamOff } = useAkapuluMediaControls();

  // Daily participant roles + local self.
  const daily = useDaily();
  // Assistant video participant id selected from Daily roles.
  const { akapuluBotParticipantId } = useAkapuluParticipantRoles();
  // Local browser participant (the user/self-view tile).
  const localParticipant = daily?.participants().local;
  const localId = localParticipant?.session_id ?? "";

  // Subscribe to high-level conversation events (status, tools, transcript, node, timeouts).
  useAkapuluEvents((event) => {
    if (event.type === "status_changed") {
      // handle session lifecycle changes (idle/connecting/connected/ended/error)
      return;
    }
    if (event.type === "tool_event") {
      // handle RAG / HTTP / vision tool activity
      return;
    }
    if (event.type === "transcript_updated") {
      // handle incremental transcript updates
      return;
    }
    if (event.type === "node_changed") {
      // handle scenario node changes
      return;
    }
    if (event.type === "bot_speaking_state_changed") {
      // handle bot speaking/listening/idle state
      return;
    }
    if (event.type === "timeout") {
      // handle duration / participant_join timeouts
      return;
    }
  });

  // Render behavior by session status:
  // - error -> dedicated error surface
  // - idle / ended -> pre-call surface (+ start button)
  // - connecting -> loading/progress surface
  // - connected -> full in-call surface (video, controls, transcript)
  switch (status) {
    case "error":
      return (
        <>
          {/* TODO: dedicated error UI*/}
          <AkapuluBotAudio />
        </>
      );

    case "idle":
    case "ended":
      return (
        <>
          {/* TODO: pre-call UI (title, consent, etc.) */}
          <button type="button" onClick={() => void start()}>
            Start call
          </button>
          <AkapuluBotAudio />
        </>
      );

    case "connecting":
      return (
        <>
          <p>Connecting...</p>
          {/* TODO: replace with your spinner component */}
          <div>Spinner</div>
          <progress value={completionPercent} max={100} />
          <p>{latestUpdateText}</p>
          <AkapuluBotAudio />
        </>
      );

    case "connected":
      return (
        <>
          {/* TODO: in-call header (botSpeakingState, currentNode, etc.) */}
          {/* Assistant video */}
          {akapuluBotParticipantId ? (
            <VideoTile sessionId={akapuluBotParticipantId} isLocal={false} />
          ) : (
            <>{/* TODO: waiting-for-bot-video placeholder */}</>
          )}

          {/* User/self-view video (local participant from this browser). */}
          {localParticipant ? (
            <VideoTile sessionId={localParticipant.session_id} isLocal />
          ) : (
            <>{/* TODO: waiting-for-user-video placeholder */}</>
          )}

          {/* Call controls */}
          <>
            <button onClick={toggleMic}>
              {isMicMuted ? "Unmute" : "Mute"}
            </button>
            <button onClick={toggleCam}>
              {isCamOff ? "Camera on" : "Camera off"}
            </button>
            <button onClick={() => void end()}>
              Leave
            </button>
          </>

          {/* Transcript surface */}
          <>
            {transcripts.map((entry) => {
              // TODO: return your transcript row component (use key={entry.id}).
              // TODO: use entry.speaker / entry.text / entry.isFinal for row content + styling.
              return null;
            })}
          </>
          <AkapuluBotAudio />
        </>
      );
  }
}

export function App() {
  return (
    <AkapuluProvider
      config={{
        endpoints: {
          connectPath: "/api/akapulu/connect",
          updatesPath: "/api/akapulu/updates",
        },
        // Optional connectBody — see "Passing custom payload" above.
      }}
    >
      <CustomCallUi />
    </AkapuluProvider>
  );
}
```

**Main exports from `@akapulu/react`**

| Export                    | Role                                                        |
| ------------------------- | ----------------------------------------------------------- |
| `AkapuluProvider`         | Config, session store, Daily context for children           |
| `useAkapuluSession`       | Session state + `start` / `end`                             |
| `useAkapuluMediaControls` | Mic/cam state and toggles                                   |
| `AkapuluBotAudio`         | Plays bot audio track                                       |
| `useAkapuluEvents`        | Subscribe to `AkapuluEvent` stream                          |
| `useAkapuluDailyCall`     | Access the Daily call object when you need lower-level APIs |

**Ending a call:** When **your user** ends the call in your app, call `end()` from `useAkapuluSession()`. That leaves the Daily room from the browser and runs the SDK teardown so the session is no longer live. If the **assistant** disconnects from Daily first, `AkapuluProvider` calls `end()` for you so `status` does not stay `connected` with no assistant in the room.

**Types and shared logic**

Use `AkapuluEvent` from `@akapulu/react`. For `TranscriptEntry`, `NormalizedToolEvent`, and related handler shapes on `AkapuluConversation`, align with the prop types exported from `@akapulu/react-ui`.

## Conversation detail and recording

Separate from the live call **connect** and **updates** loop, `@akapulu/server` can fetch post-call (or in-progress) **conversation detail** and **recording** responses from your backend—for example dedicated HTTP handlers your UI calls after `conversation_session_id` is known.

### Conversation detail retrieval

`@akapulu/server` also exposes `getConversationDetail(conversationSessionId)` for fetching a completed or in-progress conversation detail payload from your backend.

```ts theme={null}
import { createAkapuluServerClient } from "@akapulu/server";

const akapulu = createAkapuluServerClient();

// GET /api/akapulu/conversation-details?conversation_session_id=...
export async function conversationDetailsRoute(conversationSessionId: string) {
  return await akapulu.getConversationDetail(conversationSessionId);
}
```

JSON body shape (matches `ConversationDetailResponse` from `@akapulu/server`):

| Field             | Type     | Description                                                                    |
| ----------------- | -------- | ------------------------------------------------------------------------------ |
| `id`              | `string` | Conversation id                                                                |
| `created_at`      | `string` | Created timestamp                                                              |
| `created_at_text` | `string` | Human-readable created time                                                    |
| `duration_text`   | `string` | Human-readable duration                                                        |
| `avatar`          | object   | Avatar summary (see below)                                                     |
| `scenario_id`     | `string` | Scenario id                                                                    |
| `recording`       | object   | Recording metadata on this conversation (see below; not the downloadable file) |
| `transcript_rows` | array    | Ordered transcript rows (see below)                                            |

#### `avatar`

| Field               | Type     | Description         |
| ------------------- | -------- | ------------------- |
| `id`                | `string` | Avatar id           |
| `href`              | `string` | Avatar resource URL |
| `profile_image_url` | `string` | Profile image URL   |

#### `recording` (detail payload)

| Field           | Type      | Description                             |
| --------------- | --------- | --------------------------------------- |
| `has_recording` | `boolean` | Whether a recording exists              |
| `status_text`   | `string`  | Recording status for display            |
| `is_ready`      | `boolean` | Whether the recording is ready to fetch |

#### `transcript_rows[]`

| Field          | Type                   | Description        |
| -------------- | ---------------------- | ------------------ |
| `role`         | `string`               | Speaker / row role |
| `content`      | `string` (optional)    | Row text           |
| `node_id`      | `string` (optional)    | Scenario node id   |
| `tool_call_id` | `string` (optional)    | Tool call id       |
| `tool_calls`   | `unknown[]` (optional) | Tool call payloads |

### Conversation recording retrieval

`@akapulu/server` also exposes `getConversationRecording(conversationSessionId)` for fetching the recording response for a conversation from your backend.

```ts theme={null}
import { createAkapuluServerClient } from "@akapulu/server";

const akapulu = createAkapuluServerClient();

// GET /api/akapulu/view-recording?conversation_session_id=...
export async function recordingRoute(conversationSessionId: string) {
  return await akapulu.getConversationRecording(conversationSessionId);
}
```

Return value shape (discriminated union, `ConversationRecordingResponse` from `@akapulu/server`):

| `kind`       | Meaning                                                       |
| ------------ | ------------------------------------------------------------- |
| `"redirect"` | Akapulu Labs responded with a redirect; follow `location`.    |
| `"json"`     | Response body was JSON; see `payload`.                        |
| `"binary"`   | Raw bytes (for example a media file); see `body` and headers. |

#### `kind: "redirect"`

| Field      | Type         | Description       |
| ---------- | ------------ | ----------------- |
| `kind`     | `"redirect"` | Discriminator     |
| `status`   | `number`     | HTTP status (3xx) |
| `location` | `string`     | `Location` URL    |

#### `kind: "json"`

| Field     | Type      | Description      |
| --------- | --------- | ---------------- |
| `kind`    | `"json"`  | Discriminator    |
| `status`  | `number`  | HTTP status      |
| `payload` | `unknown` | Parsed JSON body |

#### `kind: "binary"`

| Field                | Type          | Description           |
| -------------------- | ------------- | --------------------- |
| `kind`               | `"binary"`    | Discriminator         |
| `status`             | `number`      | HTTP status           |
| `body`               | `ArrayBuffer` | Raw response bytes    |
| `contentType`        | `string`      | `Content-Type`        |
| `contentDisposition` | `string`      | `Content-Disposition` |

## Examples

Runnable apps that mirror this guide are available as standalone Vite + Express repos. Each has an Express `backend/` with **`@akapulu/server`** route handlers for `POST /api/connect` and `GET /api/updates?conversation_session_id=…`, plus a React `frontend/`.

* [Prebuilt UI](/examples/web-sdk/prebuilt-ui) — [`Akapulu/prebuilt-ui`](https://github.com/Akapulu/prebuilt-ui): **`@akapulu/react`** (`AkapuluProvider` with local `connectPath` / `updatesPath`) plus **`@akapulu/react-ui`** (`AkapuluConversation` for the full default layout: video, transcript, controls). Useful when you want to ship quickly and customize via props or CSS rather than rebuilding layout.

* [Prebuilt UI (styled)](/examples/web-sdk/prebuilt-ui-styled) — [`Akapulu/prebuilt-ui-styled`](https://github.com/Akapulu/prebuilt-ui-styled): the same prebuilt path with a dark theme, a custom tool toast, and a post-call review screen (recording + transcript).

* [Customized UI](/examples/web-sdk/customized-ui) — [`Akapulu/customized-ui`](https://github.com/Akapulu/customized-ui): **`@akapulu/react` only**—same provider pattern, then **`useAkapuluSession`**, **`useAkapuluMediaControls`**, **`useAkapuluParticipantRoles`**, **`useAkapuluEvents`**, **`AkapuluBotAudio`**, and **`@daily-co/daily-react`** (`DailyVideo`, track hooks) for your own chrome. Useful when you need full control over markup and state wiring.
