ElevenLabs Agents

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Written on 2026-10-01 against the ElevenAgents documentation (under /docs/eleven-agents/; the older /docs/agents-platform/ addresses redirect there), the elevenlabs Python package 2.70.0, @elevenlabs/react and @elevenlabs/client, the ElevenLabs Swift and Android SDKs, FastAPI 0.142.2 on Python 3.12, and Spring AI 2.0.1 on Spring Boot 4.1.x with Java 21 (see the version baseline). Nothing here was run against an ElevenLabs account: the platform needs a paid account and live audio, so the configuration steps and the request shapes were taken from the official documentation linked after each block, and the custom-LLM endpoints were written to the OpenAI-compatible contract that documentation describes. Dashboard labels, plan limits and model lists change; the pricing page is the source for cost.

ElevenLabs Agents is a hosted voice-agent platform. You configure an agent (a system prompt, a voice, an LLM, tools and a knowledge base) in a dashboard or through the API, and the platform runs the whole speech pipeline: speech recognition, turn-taking, the LLM, speech synthesis and the telephony or web transport. That is the shortest route from nothing to a talking DocsAssistant, and it answers the question the decision map on the landing page poses for the "little of the stack" end of the spectrum. The price of that speed is that the pipeline is the platform’s, not yours, and audio leaves your network.

flowchart LR U["Caller: browser widget, phone or SIP"] <--> A["ElevenLabs Agent (STT, turn-taking, TTS)"] A -- "OpenAI-compatible request (SSE)" --> L["DocsAssistant custom LLM endpoint (FastAPI or Spring AI)"] L --> CORE["DocsAssistant core: ask(conversationId, userId, text)"] CORE --> R[(Retriever, memory, ACL filter)] A -- "webhook or MCP tool call" --> T["Tool endpoint or MCP server"] T --> R A -. "optional" .-> KB[(ElevenLabs knowledge base)]

Configuring an agent

An agent has these parts, set in the dashboard or created through the API and CLI:

  • System prompt and first message. For a voice agent the prompt asks for short plain-text sentences without markdown, and the first message states that the caller is speaking to an AI assistant.

  • Voice and language. A stock or cloned voice, with the same consent rules as on Text to Speech; additional languages can switch voice automatically.

  • LLM. One of the natively integrated models, your own OpenAI key, or a custom LLM (next section).

  • Turn-taking and interruption settings. Timeouts and eagerness of the platform’s own turn detection; the concepts are on Turn-Taking and Interruptions.

  • Tools and knowledge base. Covered below.

  • Authentication. A public agent can be started by anyone who knows its ID; a private agent requires a signed URL or conversation token minted by your backend.

Agents are stored as configuration that can be pulled and pushed with the ElevenLabs CLI, so keep it in version control and review changes like code.

The custom LLM: DocsAssistant as the brain

The cleanest integration, and the one that keeps all retrieval, memory and access control in the DocsAssistant core, is to select Custom LLM in the agent and give the platform the URL of an endpoint that behaves like OpenAI’s Chat Completions API (/v1/chat/completions) or Responses API (/v1/responses). Both must answer with Server-Sent Events (Content-Type: text/event-stream); for Chat Completions every chunk is data: {json} followed by a blank line and the stream ends with data: [DONE]. The platform sends the conversation so far as messages, and your endpoint returns the next assistant message as a stream of deltas, which the platform turns into speech sentence by sentence.

The endpoint therefore adapts an OpenAI-shaped request to ask(conversationId, userId, text). The last user message is the transcript of what the caller said. The earlier messages are the platform’s copy of the history. The core does not need them as long as it keeps its own memory under a stable thread ID. Extra identity and conversation fields arrive in elevenlabs_extra_body, set from the SDK or dynamic variables when the conversation starts. Pass the conversation ID there, for example from the system__conversation_id system variable, together with the user ID. When no conversation ID arrives, the endpoint must not invent a new thread per turn, because the core would then see every follow-up question without context. The examples fall back to folding the platform’s history into the question, which keeps follow-ups answerable at the cost of a longer prompt.

custom_llm.py: DocsAssistant behind an OpenAI-compatible endpoint (FastAPI)
import json
import time
import uuid

from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

from docsassistant.core import ask      # the channel-agnostic core: async iterator of text tokens
from docsassistant.config import settings

app = FastAPI()


class Message(BaseModel):
    role: str
    content: str | list | None = None


class ChatRequest(BaseModel):
    model: str
    messages: list[Message]
    stream: bool = True
    user_id: str | None = None
    elevenlabs_extra_body: dict | None = None


def chunk(completion_id: str, model: str, delta: dict, finish: str | None = None) -> str:
    body = {
        "id": completion_id,
        "object": "chat.completion.chunk",
        "created": int(time.time()),
        "model": model,
        "choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
    }
    return f"data: {json.dumps(body)}\n\n"


@app.post("/v1/chat/completions")
async def completions(request: ChatRequest, authorization: str = Header(default="")):
    if authorization != f"Bearer {settings.elevenlabs_llm_secret}":   # the secret stored in the agent's LLM settings
        raise HTTPException(status_code=401)

    turns = [(m.role, m.content) for m in request.messages
             if m.role in ("user", "assistant") and isinstance(m.content, str)]
    text = next((content for role, content in reversed(turns) if role == "user"), "")
    extra = request.elevenlabs_extra_body or {}
    user_id = extra.get("user_id", request.user_id or "anonymous")
    thread_id = extra.get("conversation_id")
    if thread_id is None:
        # No stable thread: a new one per turn would lose the conversation, so carry the platform's history instead.
        thread_id = f"elevenlabs-{uuid.uuid4()}"
        history = "\n".join(f"{role}: {content}" for role, content in turns[:-1][-10:])
        if history:
            text = f"Conversation so far:\n{history}\n\nCaller: {text}"
    completion_id = f"chatcmpl-{uuid.uuid4().hex}"

    async def events():
        yield chunk(completion_id, request.model, {"role": "assistant"})
        async for token in ask(thread_id, user_id, text):
            yield chunk(completion_id, request.model, {"content": token})
        yield chunk(completion_id, request.model, {}, finish="stop")
        yield "data: [DONE]\n\n"

    return StreamingResponse(events(), media_type="text/event-stream")

Source: ElevenLabs: Integrate your own model (request and response contract, the SSE format, and elevenlabs_extra_body). ask is the same operation used by the text channels (Serving RAG as an API); here it must be a token stream with no markdown, so the voice persona and a "write for the ear" instruction belong in the core’s prompt for this channel. Authenticate the endpoint with a bearer secret that you store in the agent settings, and put it behind HTTPS; it is called from ElevenLabs' network, so it must be reachable from the internet.

The Spring AI version does the same with ChatClient.stream():

CustomLlmController.java: DocsAssistant behind an OpenAI-compatible endpoint (Spring AI)
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Flux;

@RestController
class CustomLlmController {

    private final ChatClient docsAssistant;
    private final String secret;

    CustomLlmController(ChatClient docsAssistant, @Value("${elevenlabs.llm-secret}") String secret) {
        this.docsAssistant = docsAssistant;
        this.secret = secret;
    }

    record Message(String role, Object content) { }

    record ChatRequest(String model, List<Message> messages, Map<String, Object> elevenlabs_extra_body) { }

    // Each Map element is written as one "data: {json}" event; the String is written as "data: [DONE]".
    @PostMapping(value = "/v1/chat/completions", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    Flux<Object> completions(@RequestBody ChatRequest request,
                             @RequestHeader(value = "Authorization", defaultValue = "") String auth) {
        if (!("Bearer " + this.secret).equals(auth)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
        }
        List<Message> turns = request.messages().stream()
                .filter(m -> ("user".equals(m.role()) || "assistant".equals(m.role())) && m.content() instanceof String)
                .toList();
        String text = turns.reversed().stream()
                .filter(m -> "user".equals(m.role()))
                .map(m -> (String) m.content()).findFirst().orElse("");
        Map<String, Object> extra = request.elevenlabs_extra_body() == null
                ? Map.of() : request.elevenlabs_extra_body();
        Object conversationId = extra.get("conversation_id");
        String thread;
        if (conversationId != null) {
            thread = conversationId.toString();
        } else {
            // No stable thread: a new one per turn would lose the conversation, so carry the platform's history instead.
            thread = "elevenlabs-" + UUID.randomUUID();
            List<Message> earlier = turns.subList(Math.max(0, turns.size() - 11), Math.max(0, turns.size() - 1));
            if (!earlier.isEmpty()) {
                String history = earlier.stream().map(m -> m.role() + ": " + m.content())
                        .collect(Collectors.joining("\n"));
                text = "Conversation so far:\n" + history + "\n\nCaller: " + text;
            }
        }
        String id = "chatcmpl-" + UUID.randomUUID().toString().replace("-", "");

        Flux<String> tokens = this.docsAssistant.prompt()
                .user(text)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, thread))
                .stream().content();
        return Flux.concat(
                Flux.just(chunk(id, request.model(), Map.of("role", "assistant"), null)),
                tokens.map(t -> chunk(id, request.model(), Map.of("content", t), null)),
                Flux.just(chunk(id, request.model(), Map.of(), "stop"), "[DONE]"));
    }

    private static Map<String, Object> chunk(String id, String model, Map<String, Object> delta, String finish) {
        Map<String, Object> choice = new HashMap<>();
        choice.put("index", 0);
        choice.put("delta", delta);
        choice.put("finish_reason", finish);
        return Map.of("id", id, "object", "chat.completion.chunk",
                "created", System.currentTimeMillis() / 1000, "model", model, "choices", List.of(choice));
    }
}

Source: ElevenLabs: Integrate your own model for the contract and Spring AI: ChatClient API for stream().content() and the conversation ID advisor parameter. Returning a Flux<Object> lets Spring write the maps as JSON events and the final string as the literal [DONE] event; check the output with curl -N against a local run, because the exact event framing is what the platform parses. The ChatClient is the memory-backed DocsAssistant client built in the Spring AI and RAG sub-sections; see Audio Transcription & Speech for the same client behind a voice endpoint. If retrieval takes noticeable time, the documentation suggests returning an initial chunk ending in an ellipsis and a space, which lets the voice flow into later text without an awkward silence.

The custom endpoint is also where the RAG latency shows: it adds directly to in the latency budget. Keep the retriever fast, stream from the first token, and host the endpoint near the ElevenLabs region you use.

Knowledge base or your own RAG?

ElevenLabs offers a knowledge base: documents (files, URLs or text, up to 20 MB per file for uploads) attached to an agent and used either in full in the prompt or through the platform’s own retrieval (RAG), which the documentation says adds roughly a quarter of a second to the response. The choice for the DocsAssistant:

Concern ElevenLabs knowledge base Custom LLM with the DocsAssistant core

Setup effort

Upload or point at URLs; no code.

Build and host the endpoint.

Freshness

You re-sync documents; the site’s nightly build is a natural trigger.

The core already reads the live index.

Access control per user

Document-level attachment to an agent; no per-caller filters.

The core applies the same ACL filter as the text channel.

Citations, grounding checks, guardrails

Platform behaviour only.

The full pipeline from the RAG sub-section.

Observability

Platform conversation logs.

Your traces, plus the platform logs.

Use the knowledge base for a public documentation assistant with no per-user rules and a deadline; use the custom LLM as soon as access control, grounding checks or one shared brain across channels matter.

Tools

An agent can act through tools, listed on the ElevenLabs tools page:

  • Client tools run in the web or mobile app (for example, "open the documentation page for this topic").

  • Webhook tools (the server-side tools of the platform) call your HTTP API; use them for a search_docs endpoint when you prefer to keep a platform-native LLM and still ground answers in the DocsAssistant retrieval.

  • MCP tools connect a remote MCP server, so the server from Exposing RAG through MCP can be attached directly; authenticate it as described in MCP authorization.

  • System tools are built in (end call, language detection, transfer to a human, skip turn, voicemail detection); a custom LLM receives them in the standard tools parameter and decides when to call them.

  • Code tools run sandboxed JavaScript on ElevenLabs' infrastructure.

A tool call adds a pause, and the tool settings allow call sounds and control whether the caller may interrupt while it runs, which is the filler-while-searching technique from the turn-taking page.

Workflows

Agent workflows are a visual graph of nodes (sub-agents with their own prompt, tools and voice) connected by edges with conditions, stored in the agent’s conversation_config.workflow so they can be exported and kept in version control. For the DocsAssistant a small workflow is enough: a greeting node, the question-answering node backed by the custom LLM, and a hand-off node that transfers to a human or ends the call when the caller asks for something outside documentation. Details: ElevenLabs: Workflows.

A web widget over WebRTC

The ElevenLabs web SDKs (@elevenlabs/client for plain JavaScript and @elevenlabs/react for React) start a conversation from the browser. Voice conversations use WebRTC by default and text-only conversations use WebSocket; you can force either with connectionType. A public agent needs only its ID. A private agent needs a short-lived credential from your backend, which keeps the ElevenLabs API key off the client.

token.py: conversation token for a private agent (FastAPI)
import httpx
from fastapi import Depends, FastAPI

from docsassistant.security import current_user, User
from docsassistant.config import settings

app = FastAPI()


@app.get("/api/elevenlabs/token")
async def conversation_token(user: User = Depends(current_user)) -> dict:
    async with httpx.AsyncClient(timeout=10) as http:
        response = await http.get(
            "https://api.elevenlabs.io/v1/convai/conversation/token",
            params={"agent_id": settings.elevenlabs_agent_id},
            headers={"xi-api-key": settings.elevenlabs_api_key},
        )
    response.raise_for_status()
    return response.json()      # {"token": "..."}

Source: ElevenLabs: JavaScript SDK (private agents and the conversation token request) and ElevenLabs: React SDK. The authentication dependency is the same one used on the text endpoints, so the access-control rules apply to who may start a voice session. Pass the user and thread identifiers into the conversation start so the custom LLM can read them.

VoiceWidget.tsx: a React voice button
import { ConversationProvider, useConversationControls, useConversationStatus } from "@elevenlabs/react";

function Button() {
  const { startSession, endSession } = useConversationControls();
  const { status } = useConversationStatus();

  async function start() {
    await navigator.mediaDevices.getUserMedia({ audio: true });      // ask for the microphone from a user gesture
    const response = await fetch("/api/elevenlabs/token", { credentials: "include" });
    const { token } = await response.json();
    await startSession({ conversationToken: token });
  }

  return status === "connected"
    ? <button onClick={() => endSession()}>End call</button>
    : <button onClick={start}>Ask DocsAssistant</button>;
}

export function VoiceWidget() {
  return (
    <ConversationProvider>
      <Button />
    </ConversationProvider>
  );
}

Source: ElevenLabs: React SDK (the provider, the controls and status hooks, and startSession). Check the package page for the current hook names, since the React SDK has been reorganised across releases. Browser microphone, echo and TURN questions for any WebRTC widget are on Voice in the Browser.

The WebSocket API

When you need to drive the agent from a server (for a custom client, a telephony bridge or tests) the platform exposes a WebSocket at wss://api.elevenlabs.io/v1/convai/conversation?agent_id=<agent id>, or a signed URL for private agents. After the connection opens the client sends a conversation_initiation_client_data message, then streams user audio in user_audio_chunk messages, and receives audio, transcripts and tool events; a contextual_update message lets the client inject non-spoken context mid-conversation. The message set and audio formats are in ElevenLabs: Agents WebSocket.

Phone calls: native Twilio and SIP trunking

  • Native Twilio integration. Import a purchased Twilio number (inbound and outbound) or a verified caller ID (outbound only) into ElevenLabs, assign an agent, and the platform handles the telephony leg. Setup: Native Twilio integration. This is the quickest "phone number that answers questions"; compare it with ConversationRelay and Media Streams, where you own the speech work, on Telephony with Twilio.

  • SIP trunking. Connect an existing PBX or carrier trunk directly to ElevenLabs with a SIP URI (UDP, TCP or TLS signalling, with certificate and cipher requirements) and import the number. This is the route for a company PBX such as Asterisk that wants a hosted agent; see SIP trunking and Telephony with Asterisk and SIP.

Mobile SDKs

  • Swift. The elevenlabs-swift-sdk Swift package (Swift SDK) starts a Conversation with an agent ID (public agents) or a conversation token (private agents), exposes published properties for SwiftUI, and needs the microphone usage description in Info.plist. Never ship the ElevenLabs API key in the app. Capture, session and permission details for iOS media are on Camera, Media and Photos.

  • Kotlin and Android. The io.elevenlabs:elevenlabs-android library (Kotlin SDK) takes a ConversationConfig with agentId or conversationToken and calls ConversationClient.startSession. Runtime permission and audio-session handling follow Device Features: Camera, Location, Sensors and Media.

The token endpoint above serves browser and mobile clients alike.

Choosing ElevenLabs Agents

Choose it when you want the fastest working voice agent, a high-quality voice, native phone numbers and SIP, and you accept audio and transcripts being processed by the vendor. Add the custom LLM when the DocsAssistant brain must stay yours. Choose a realtime API (Realtime APIs) when the lowest latency matters more than voice choice, or a framework (Pipecat, LiveKit Agents) when you need to run models yourself.