> ## 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.

> ## Agent Instructions
> Akapulu Labs builds live AI avatars on real-time calls. Two shapes: (1) a hosted link — public page Akapulu Labs hosts; open URL, Start Call; page never sees the API key; (2) your voice pipeline (speech-to-speech or any stack that produces assistant audio) plus Akapulu Labs avatar video in a Daily room you create. Calls use conversation minutes (akapulu.com/pricing). For agent onboarding (skills, MCP, account, which path to take), follow https://docs.akapulu.com/agent-setup.md.
> Hosted links: create in the dashboard or Bearer CRUD on /api/hosted-links/ (GET/POST/DELETE collection; GET/PUT/DELETE by id). Create body is exactly label, avatar_id, system_prompt, show_ai_label. Response includes url and token.
> Plugin path: Akapulu Labs uses Daily. On a new room, create_akapulu_call from pipecat-akapulu runs on their server. It reads DAILY_API_KEY and AKAPULU_API_KEY, creates a private Daily room, mints the three meeting tokens (browser, voice bot, Akapulu Labs worker), and POST /api/live-sessions/. It returns the room URL, the bot token, the browser token, and session_id. The room lasts two hours unless they pass room_exp_secs. A bot that already has a Daily room mints the worker token and POSTs avatar_id, show_ai_label, room_url, and avatar_token itself. That POST starts the Akapulu Labs worker and returns session_id. The code is on Your bot. AkapuluAvatar (pipecat-akapulu) runs inside the voice bot after assistant audio and sends that audio to the Akapulu Labs worker. The worker joins the call as a Daily participant and publishes the audio and the video, so voice-bot audio/video out stay off. The voice bot unsubscribes from the Avatar microphone when that participant joins. The browser stays subscribed to it. Public example akapulu-examples uses Deepgram Flux (`flux-general-multi`), OpenAI `gpt-5.4-mini`, and Cartesia Sonic 3.6.
> Every avatar frame has an invisible mark; verify with POST /api/video-marks/ then POST /api/video-marks/<id>/read/. API errors include error_code — branch on that field, not the English string.

# Your bot

> Create the Daily room, start a live session, and send speech to the Akapulu Labs worker.

You create the room. The live-session call starts the Akapulu Labs worker. Your voice bot's job is to send the assistant audio to that worker. The worker joins the call as a Daily participant and publishes the audio and the video. The voice bot does not publish audio or video.

## 1. Create the room

`create_akapulu_call`, from [`pipecat-akapulu`](https://github.com/Akapulu/pipecat-akapulu), runs on your server, before the voice bot starts. It reads `DAILY_API_KEY` and `AKAPULU_API_KEY`. It creates a private Daily room for three participants: the user browser, your voice bot, and the Akapulu Labs worker. It mints the three meeting tokens and `POST /api/live-sessions/`. It returns the room URL, the bot token, the browser token, and `session_id`. The worker token is sent to Akapulu Labs and is not returned.

The room lasts two hours unless you pass `room_exp_secs`. Daily removes everyone when the room expires. The browser joins as `You`. The worker joins as `Avatar`. Daily Prebuilt shows the browser and the avatar.

```python theme={null}
from pipecat_akapulu import create_akapulu_call

room_url, bot_token, browser_token, session_id = await create_akapulu_call(
    avatar_id="<avatar uuid>",
    show_ai_label=True,
    session=session,
)
```

`session` is an `aiohttp.ClientSession`. The voice bot joins Daily with `bot_token`. The browser joins with `browser_token`. Pass `session_id` to `AkapuluAvatar`.

A bot that already has a Daily room skips `create_akapulu_call`. Mint the Akapulu Labs worker's meeting token and `POST /api/live-sessions/` yourself.

## 2. Start the live session

This is the call `create_akapulu_call` makes. Use it yourself when the Daily room already exists. Put the Akapulu Labs worker's meeting token in `avatar_token`. Your voice bot joins Daily with its token. The browser joins Daily with its token.

```bash theme={null}
curl -X POST "https://akapulu.com/api/live-sessions/" \
  -H "Authorization: Bearer $AKAPULU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "avatar_id": "<avatar uuid>",
    "show_ai_label": true,
    "room_url": "https://<your-domain>.daily.co/<room>",
    "avatar_token": "<avatar meeting token>"
  }'
```

`show_ai_label` is a boolean. On success the response is:

```json theme={null}
{
  "session_id": "11111111-2222-3333-4444-555555555555"
}
```

The call returns when the session is stored and the Akapulu Labs worker has been asked to start. It does not wait for the first video frame.

## Loading status

The `AkapuluAvatar` Pipecat plugin connects to the Akapulu Labs worker in the background. When the pipeline starts, it forwards `StartFrame` right away so your voice bot keeps moving. It does not wait for the first face frame.

While the Akapulu Labs worker boots, the plugin pushes **RTVI server messages** into your voice-bot pipeline. Each message body has `type` `status` and these fields:

* `call_is_ready`: whether the Akapulu Labs worker has published the first frame
* `completion_percent`: how far startup has gotten (0 to 100)
* `latest_update_text`: the latest human-readable startup line

Those are not Daily audio or video tracks. With RTVI on the call (for example `@pipecat-ai/client-js` in the browser), they arrive on the client as RTVI `ServerMessage` events over that same Daily connection. Use them for a loading spinner, percent, and copy. Keep the loading UI up until `call_is_ready` is true.

## 3. Put AkapuluAvatar in the pipeline

Install [`pipecat-akapulu`](https://github.com/Akapulu/pipecat-akapulu). Put the `AkapuluAvatar` Pipecat plugin after your TTS (or whatever produces assistant audio). `AkapuluAvatar` sends that audio to the Akapulu Labs worker. The worker joins the call as a Daily participant and publishes the audio and the video. The voice bot does not publish audio or video, so `audio_out_enabled` and `video_out_enabled` stay `False`.

```python theme={null}
from pipecat.pipeline.pipeline import Pipeline
from pipecat.transports.daily.transport import DailyParams, DailyTransport
from pipecat_akapulu import AkapuluAvatar

transport = DailyTransport(
    room_url,
    bot_token,
    bot_name,
    DailyParams(
        audio_in_enabled=True,
        audio_out_enabled=False,
        video_out_enabled=False,
    ),
)

stt = YourVoiceBotSTT()
llm = YourVoiceBotLLM()
tts = YourVoiceBotTTS()
avatar = AkapuluAvatar(session_id=session_id)

pipeline = Pipeline(
    [
        transport.input(),
        stt,
        llm,
        tts,
        avatar,
        transport.output(),
    ]
)
```

`YourVoiceBotSTT`, `YourVoiceBotLLM`, and `YourVoiceBotTTS` are stand-ins for your own STT, LLM, and TTS. Leave `audio_out_enabled` and `video_out_enabled` as `False` because the voice bot's job is to send the assistant audio to the Akapulu Labs worker. The worker joins the call as a Daily participant and publishes the audio and the video. The voice bot does not publish audio or video.

The worker starts the face only after a short stretch of assistant audio has arrived. Speech-to-speech models often reach that first audio quickly, then trickle, so the buffer fills slowly. A streaming TTS keeps the audio coming after the first chunk, which fills the buffer sooner. Cartesia has been the best mix we have tried: a low time to first audio, and audio that keeps arriving quickly enough to fill that buffer.

Pipecat subscribes your voice bot to every remote microphone. When the participant named `Avatar` joins, turn that microphone off. The voice bot still hears the browser. The browser stays subscribed to the Avatar microphone. That track is the voice the visitor hears.

```python theme={null}
@transport.event_handler("on_participant_joined")
async def on_participant_joined(_transport, participant):
    info = participant.get("info")
    if not isinstance(info, dict):
        raise RuntimeError("Daily participant has no info")
    name = info.get("userName")
    if not isinstance(name, str) or not name:
        raise RuntimeError("Daily participant has no userName")
    if name != "Avatar":
        return
    participant_id = participant.get("id")
    if not isinstance(participant_id, str) or not participant_id:
        raise RuntimeError("Daily participant has no id")
    error = await transport.update_subscriptions(
        participant_settings={
            participant_id: {"media": {"microphone": "unsubscribed"}},
        },
    )
    if error:
        raise RuntimeError(f"Daily avatar microphone unsubscribe failed: {error}")
```

Call this once, when Avatar joins. Do not set subscriptions again on a participant update.

The public example is [akapulu-examples](https://github.com/Akapulu/akapulu-examples): a local server, a page, and a voice bot that speaks first.

## Errors

When `POST /api/live-sessions/` fails, the response JSON includes `error` and `error_code`. Branch on `error_code`. The codes are in the [API overview](https://docs.akapulu.com/api-reference/introduction.md#error-responses).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.