Skip to main content

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.

This guide is based on the Akapulu Web SDK.

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 APIs using @akapulu/server.
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 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=...
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 API key from server-side environment variable, and call Akapulu’s 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:
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):
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.
<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.
.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.
[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 keyWhat it targetsDefault classdata-slot value
containerOuter wrapper for the whole conversation UIakapulu-conversationcontainer
titleTop heading textakapulu-titletitle
connectedLayoutMain connected-state grid wrapperakapulu-connected-layoutconnected-layout
startButtonIdle/error state start-call buttonakapulu-start-buttonstart-button

Loading

Slot keyWhat it targetsDefault classdata-slot value
loadingContainerWrapper for loading state UIakapulu-loading-containerloading-container
loadingSpinnerLoading spinner elementakapulu-loading-spinnerloading-spinner
loadingLabelLoading headline text (e.g. Connecting…)akapulu-loading-labelloading-label
loadingProgressTrackProgress bar background trackakapulu-loading-progress-trackloading-progress-track
loadingProgressFillProgress bar filled portionakapulu-loading-progress-fillloading-progress-fill
loadingStatusTextDetailed status text under progress barakapulu-loading-status-textloading-status-text

Error modal

Slot keyWhat it targetsDefault classdata-slot value
errorModalBackdropFull-screen modal overlay behind error cardakapulu-error-modal-backdroperror-modal-backdrop
errorModalCardError modal content cardakapulu-error-modal-carderror-modal-card

Tool events

Slot keyWhat it targetsDefault classdata-slot value
toolToastFloating toast/card for tool activityakapulu-tool-toasttool-toast

Video + controls

Slot keyWhat it targetsDefault classdata-slot value
videoPaneVideo column containerakapulu-video-panevideo-pane
videoSurfacePrimary remote/bot video surfaceakapulu-video-surfacevideo-surface
botStateBadgeSpeaking/listening/idle badge on videoakapulu-bot-state-badgebot-state-badge
pipLocal picture-in-picture preview tileakapulu-pippip
waitingVideoPlaceholder shown before video is availableakapulu-waiting-videowaiting-video
controlMicIn-call mic button (video mode)akapulu-control-miccontrol-mic
controlCamIn-call camera button (video mode)akapulu-control-camcontrol-cam
controlEndIn-call hang-up button (video mode)akapulu-control-endcontrol-end

Transcript

Slot keyWhat it targetsDefault classdata-slot value
transcriptPaneTranscript column containerakapulu-transcript-panetranscript-pane
transcriptContainerScrollable transcript boxakapulu-transcript-containertranscript-container
transcriptHeaderTranscript header rowakapulu-transcript-headertranscript-header
nodeChipCurrent node chip in transcript headerakapulu-node-chipnode-chip
transcriptRowUserUser transcript row/bubbleakapulu-transcript-row-usertranscript-row-user
transcriptRowBotBot transcript row/bubbleakapulu-transcript-row-bottranscript-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:
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):
FieldTypeDescription
idstringStable transcript row id
speaker"user" | "bot"Who produced this transcript chunk
textstringTranscript text
timestampstringEvent timestamp
isFinalbooleanWhether this transcript row is finalized
NormalizedToolEvent shape (tool) by tool type:

RAG tool event

FieldTypeDescription
messageType"RAG"RAG tool event
functionNamestringRAG function/tool name
summarystring"RAG tool called"
querystring | undefinedQuery text

vision tool event

FieldTypeDescription
messageType"vision"Vision tool event
functionNamestringVision function/tool name
summarystring"Vision tool called"

http tool event

FieldTypeDescription
messageType"http"HTTP tool event
functionNamestringHTTP function/tool name
summarystring"HTTP endpoint called"
argsJsonstring | undefinedPretty JSON string of body
bodyRecord<string, unknown>Outgoing HTTP request payload fields
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.
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

FieldTypeDescription
type"status_changed"Discriminator
status"idle" | "connecting" | "connected" | "disconnecting" | "ended" | "error"Session lifecycle state

bot_speaking_state_changed

FieldTypeDescription
type"bot_speaking_state_changed"Discriminator
speakingState"idle" | "speaking" | "listening"Bot speaking/listening state

node_changed

FieldTypeDescription
type"node_changed"Discriminator
node{ key: string; label: string } | nullCurrent flow node

tool_event

FieldTypeDescription
type"tool_event"Discriminator
toolNormalizedToolEventNormalized tool event payload

transcript_updated

FieldTypeDescription
type"transcript_updated"Discriminator
transcriptTranscriptEntryUpdated transcript row

call_ready

FieldTypeDescription
type"call_ready"Call reached ready state

timeout

FieldTypeDescription
type"timeout"Discriminator
reason"duration_limit_reached" | "participant_join_timeout"Timeout reason from backend
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
      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).
<AkapuluProvider
  config={{
    endpoints: {
      connectPath: "/api/akapulu/connect",
      updatesPath: "/api/akapulu/updates",
    },
  }}
>
  {/* your UI */}
</AkapuluProvider>
  1. 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 conversation session id from connect (used when polling updates; also useful if your app logs or links out to dashboard/API records).
const {
  status,
  start,
  end,
  error,
  callIsReady,
  conversationSessionId,
  completionPercent,
  latestUpdateText,
  currentNode,
  transcripts,
  botSpeakingState,
} = useAkapuluSession();
  1. 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, useParticipantIds, and useVideoTrack.
import { DailyVideo, useDaily, useParticipantIds, useVideoTrack } from "@daily-co/daily-react";

function BotVideoTile() {
  const daily = useDaily();
  const [sessionId] = useParticipantIds({ filter: "remote" });
  const track = useVideoTrack(sessionId);
  if (track.isOff) return null;
  return <DailyVideo sessionId={sessionId} 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" />;
}
  1. Use useAkapuluMediaControls() for in-call mic and camera toggles (wired to that Daily session).
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>
  1. Render <AkapuluBotAudio /> once in the tree so assistant audio plays (small hidden element; required for typical voice/video bots).
<AkapuluBotAudio />
  1. Optionally useAkapuluEvents(callback) to react to the full event stream (transcript_updated, tool_event, node_changed, timeout, etc.) while still rendering your own UI.
useAkapuluEvents((event) => {
  // … handle `event`
});
Rough outline
"use client";

import { AkapuluBotAudio, AkapuluProvider, useAkapuluEvents, useAkapuluMediaControls, useAkapuluSession } from "@akapulu/react";
import { DailyVideo, useDaily, useParticipantIds, 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 `useParticipantIds` or `participants().local`).
  return <DailyVideo sessionId={props.sessionId} type="video" />;
}

function CustomCallUi() {
  // Akapulu 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 list: remote ids + local self.
  const daily = useDaily();
  // Daily ids for everyone except this browser client.
  // In the 1:1 Akapulu call, remoteParticipantIds[0] is the bot video participant.
  const remoteParticipantIds = useParticipantIds({ filter: "remote" });
  // 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.) */}
          {/* Bot video (remote participant) */}
          {remoteParticipantIds[0] ? (
            <VideoTile sessionId={remoteParticipantIds[0]} 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
ExportRole
AkapuluProviderConfig, session store, Daily context for children
useAkapuluSessionSession state + start / end
useAkapuluMediaControlsMic/cam state and toggles
AkapuluBotAudioPlays bot audio track
useAkapuluEventsSubscribe to AkapuluEvent stream
useAkapuluDailyCallAccess 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.
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):
FieldTypeDescription
idstringConversation id
created_atstringCreated timestamp
created_at_textstringHuman-readable created time
duration_textstringHuman-readable duration
avatarobjectAvatar summary (see below)
scenario_idstringScenario id
recordingobjectRecording metadata on this conversation (see below; not the downloadable file)
transcript_rowsarrayOrdered transcript rows (see below)

avatar

FieldTypeDescription
idstringAvatar id
hrefstringAvatar resource URL
profile_image_urlstringProfile image URL

recording (detail payload)

FieldTypeDescription
has_recordingbooleanWhether a recording exists
status_textstringRecording status for display
is_readybooleanWhether the recording is ready to fetch

transcript_rows[]

FieldTypeDescription
rolestringSpeaker / row role
contentstring (optional)Row text
node_idstring (optional)Scenario node id
tool_call_idstring (optional)Tool call id
tool_callsunknown[] (optional)Tool call payloads

Conversation recording retrieval

@akapulu/server also exposes getConversationRecording(conversationSessionId) for fetching the recording response for a conversation from your backend.
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):
kindMeaning
"redirect"Akapulu 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"

FieldTypeDescription
kind"redirect"Discriminator
statusnumberHTTP status (3xx)
locationstringLocation URL

kind: "json"

FieldTypeDescription
kind"json"Discriminator
statusnumberHTTP status
payloadunknownParsed JSON body

kind: "binary"

FieldTypeDescription
kind"binary"Discriminator
statusnumberHTTP status
bodyArrayBufferRaw response bytes
contentTypestringContent-Type
contentDispositionstringContent-Disposition

Examples

Runnable apps that mirror this guide are available in Akapulu/akapulu-examples (fundamentals/). Both are Next.js apps with @akapulu/server route handlers for POST …/connect and GET …/updates?conversation_session_id=…
  • Pre-built UIfundamentals/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.
  • Custom UIfundamentals/custom-ui: @akapulu/react only—same provider pattern, then useAkapuluSession, useAkapuluMediaControls, useAkapuluEvents, AkapuluBotAudio, and @daily-co/daily-react (DailyVideo, participant/track hooks) for your own chrome. Useful when you need full control over markup and state wiring.