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.
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.
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():
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_docsendpoint 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
toolsparameter 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.
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.
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-sdkSwift package (Swift SDK) starts aConversationwith an agent ID (public agents) or a conversation token (private agents), exposes published properties for SwiftUI, and needs the microphone usage description inInfo.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-androidlibrary (Kotlin SDK) takes aConversationConfigwithagentIdorconversationTokenand callsConversationClient.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.