Skip to main content

Cartesia Voice Agents SDK

Project description

Cartesia Line SDK

Ask DeepWiki

Build intelligent, low-latency voice agents with Line.

Line brings voice to your text agents with Cartesia's state-of-the-art speech models. We handle audio orchestration, deployment, and observability so you can focus on your agent's reasoning.

Features

  • Real-time interruption support — Handles audio interruptions and turn-taking out-of-the-box
  • Tool calling — Connect to databases, APIs, and external services
  • Multi-agent handoffs — Route conversations between specialized agents
  • Web search — Built-in tool for real-time information lookup
  • 100+ LLM providers — Works with any LLM via LiteLLM
  • Instant deployment — Build, deploy, and start talking in minutes

Quick Start

1. Clone and run an example:

git clone https://github.com/cartesia-ai/line.git
cd line/examples/basic_chat
GEMINI_API_KEY=your-key uv run python main.py

2. Or create from scratch:

mkdir my-agent && cd my-agent
uv init && uv add cartesia-line

Create main.py:

import os
from line.llm_agent import LlmAgent, LlmConfig, end_call
from line.voice_agent_app import VoiceAgentApp

async def get_agent(env, call_request):
    return LlmAgent(
        model="gemini/gemini-2.5-flash-preview-09-2025",
        api_key=os.getenv("GEMINI_API_KEY"),
        tools=[end_call],
        config=LlmConfig(
            system_prompt="You are a helpful voice assistant.",
            introduction="Hello! How can I help you today?",
        ),
    )

app = VoiceAgentApp(get_agent=get_agent)

if __name__ == "__main__":
    app.run()

Run it:

GEMINI_API_KEY=your-key uv run python main.py

3. (Optional) Install the CLI to test locally:

curl -fsSL https://cartesia.sh | sh

Then chat with your agent:

PORT=8000 uv run python main.py
cartesia chat 8000

See the CLI documentation for deployment and management commands.


Customize Your Agent's Prompt

System Prompt & Introduction

Configure your agent's personality and behavior via LlmConfig:

config = LlmConfig(
    system_prompt="You are a customer service agent for Acme Corp. Be friendly and concise.",
    introduction="Hi! Thanks for calling Acme. How can I help?",
)
  • system_prompt — Defines the agent's personality, rules, and context
  • introduction — First message spoken when the call starts (set to "" to wait for user)

Dynamic Prompts from API

Use LlmConfig.from_call_request() to configure prompts dynamically from your API:

async def get_agent(env: AgentEnv, call_request: CallRequest):
    # Prompts come from call_request.agent.system_prompt and call_request.agent.introduction
    # Falls back to your defaults if not provided
    return LlmAgent(
        model="gemini/gemini-2.5-flash-preview-09-2025",
        tools=[end_call],
        config=LlmConfig.from_call_request(
            call_request,
            fallback_system_prompt="You are a helpful assistant.",
            fallback_introduction="Hello! How can I help?",
        ),
    )

Add Tools to Your Agent

Built-in Tools

Ready-to-use tools for common actions:

from line.llm_agent import LlmAgent, LlmConfig, end_call, knowledge_base, send_dtmf, transfer_call, voicemail, web_search

agent = LlmAgent(
    model="gemini/gemini-2.5-flash-preview-09-2025",
    tools=[end_call, send_dtmf, transfer_call, voicemail, web_search, knowledge_base],
    config=LlmConfig(...),
)
Tool What it does
end_call Ends the call
send_dtmf Presses phone buttons (0-9, *, #)
transfer_call Transfers to a phone number (E.164). LLM-supplied by default, or pin a fixed destination with transfer_call(target_phone_number="+1...")
voicemail Ends the call when you reach a voicemail: optionally leaves a message first, then hangs up with end_reason="voicemail_detected". Configure with voicemail(message="…"). See Voicemail detection.
web_search Searches the web (native LLM search or DuckDuckGo fallback)
knowledge_base Looks up information from the agent's knowledge base via natural-language query. Call knowledge_base(filters={...}, top_k=10) to pre-filter retrievals or override top_k
http_server_tool Creates an HTTP tool from JSON schemas (see below)

Voicemail Detection (outbound calls)

On an outbound call you usually want the agent to hear the callee's opening line before speaking, so set introduction="" — the agent stays silent on CallStarted and only responds after the first UserTurnEnded. Give it the voicemail tool and it will hang up (after an optional message) when the LLM recognizes a machine greeting, recording end_reason="voicemail_detected":

from line.llm_agent import LlmAgent, LlmConfig, end_call, voicemail

agent = LlmAgent(
    model="anthropic/claude-haiku-4-5-20251001",
    api_key=os.getenv("ANTHROPIC_API_KEY"),
    tools=[voicemail(message="Hi, please call us back when you can."), end_call],
    config=LlmConfig(system_prompt=SYSTEM_PROMPT, introduction=""),  # outbound: wait for the callee
)

Behavior modes (all from the one tool):

voicemail                                  # silently end the call on a voicemail
voicemail(message="Sorry we missed you.")  # speak the message (uninterruptible), then end
voicemail(interruptible=True)              # allow the message/hangup to be interrupted
voicemail(description="…")                 # override the LLM-facing "when to call this" text

Automatic removal once the conversation starts. Voicemail is only worth checking at the very start of a call, so the agent drops the voicemail tool after its active_turns (default 2) — once the conversation is "deemed started" the LLM can no longer trigger a voicemail hangup mid-call. The default of 2 covers a greeting that arrives over a couple of turns (e.g. split by VAD); set it higher for heavily fragmented greetings, or None to keep the tool for the whole call:

agent = LlmAgent(
    model="anthropic/claude-haiku-4-5-20251001",
    api_key=os.getenv("ANTHROPIC_API_KEY"),
    # active_turns lives on the tool. Default is 2; None keeps it for the whole call.
    tools=[voicemail(message="Hi, please call us back.", active_turns=2), end_call],
    config=LlmConfig(system_prompt=SYSTEM_PROMPT, introduction=""),
)

active_turns is a general feature of the built-in class tools (end_call, transfer_call, voicemail, knowledge_base): any of them can be set to drop after N user turns. It defaults to None (kept for the whole call) for every tool except voicemail, which defaults to 2.

HTTP Tools — Connect to HTTP APIs Without Code

http_server_tool creates a tool that makes HTTP requests when the LLM calls it. Define the request shape with JSON schemas — no custom tool function needed:

from line.llm_agent import http_server_tool

create_ticket = http_server_tool(
    name="create_ticket",
    description="Creates a support ticket for the caller.",
    url="https://api.example.com/v1/{tenant_id}/tickets",
    method="POST",
    request_body_schema={
        "type": "object",
        "required": ["subject", "priority"],
        "properties": {
            "subject": {"type": "string", "description": "Short summary of the issue."},
            "priority": {"type": "string", "enum": ["low", "medium", "high"]},
            # constant_value: hidden from the LLM, baked into every request
            "source": {"type": "string", "constant_value": "voice_agent"},
        },
    },
    # ${ENV_VAR} resolved from os.environ at build time
    auth={"Authorization": "Bearer ${SUPPORT_API_KEY}"},
)

agent = LlmAgent(tools=[create_ticket, end_call], ...)

The LLM sees subject, priority, and tenant_id (from the URL template). It never sees source — that's injected automatically. The ${SUPPORT_API_KEY} is resolved from your environment when the tool is created.

Query parameter tools work the same way for GET requests:

search_orders = http_server_tool(
    name="search_orders",
    description="Search orders by status.",
    url="https://api.example.com/orders",
    method="GET",
    query_params_schema={
        "type": "object",
        "required": ["status"],
        "properties": {
            "status": {"type": "string", "enum": ["pending", "shipped", "delivered"]},
            "api_key": {"type": "string", "constant_value": "pk_live_abc123"},
        },
    },
)

Response format — the LLM always receives structured JSON:

{"ok": true,  "status": 201, "body": "{\"ticket_id\": \"TKT-001\"}"}
{"ok": false, "status": 500, "error": "Internal server error"}
{"ok": false, "status": null, "error": "Request timed out after 5.0s."}

Loopback Tools — Fetch Data & Call APIs

Results go back to the LLM for a natural language response:

from typing import Annotated
from line.llm_agent import loopback_tool

@loopback_tool
async def get_order_status(ctx, order_id: Annotated[str, "The order ID"]) -> str:
    """Look up order status."""
    order = await db.get_order(order_id)
    return f"Order {order_id}: {order.status}"

agent = LlmAgent(tools=[get_order_status, end_call], ...)

User: "What's the status of order 12345?" Agent: Calls tool → LLM responds: "Your order was delivered on January 5th!"

Passthrough Tools — Deterministic Actions

Output goes directly to the user, bypassing the LLM:

from typing import Annotated
from line.events import AgentSendText, AgentTransferCall
from line.llm_agent import passthrough_tool

@passthrough_tool
async def transfer_to_support(ctx, reason: Annotated[str, "Why they need support"]):
    """Transfer to support team."""
    yield AgentSendText(text="Transferring you to support now.")
    yield AgentTransferCall(target_phone_number="+18005551234")

agent = LlmAgent(tools=[transfer_to_support, end_call], ...)

Handoff Tools — Multi-Agent Workflows

Transfer control to a specialized agent:

from line.llm_agent import LlmAgent, LlmConfig, agent_as_handoff, end_call

spanish_agent = LlmAgent(
    model="anthropic/claude-sonnet-4-5",
    tools=[end_call],
    config=LlmConfig(
        system_prompt="You speak only in Spanish.",
        introduction="¡Hola! ¿Cómo puedo ayudarte?",
    ),
)

main_agent = LlmAgent(
    model="gemini/gemini-2.5-flash-preview-09-2025",
    tools=[
        end_call,
        agent_as_handoff(
            spanish_agent,
            handoff_message="Transferring you to our Spanish-speaking agent...",
            name="transfer_to_spanish",
            description="Transfer when user wants to speak Spanish.",
        ),
    ],
    config=LlmConfig(system_prompt="Transfer to Spanish if requested."),
)

Tool Types Summary

Type How to create Result goes to Use for
Loopback @loopback_tool Back to LLM API calls, data lookup
Passthrough @passthrough_tool Directly to user Deterministic actions
Handoff agent_as_handoff() or @handoff_tool Another agent Multi-agent workflows

Long-Running Tools

By default, tool calls are terminated when the agent is interrupted (though any reasoning and tool call response values already produced are preserved for use in the next generation).

For tools that take a long time to complete, set is_background=True. The tool will continue running in the background until completion regardless of interruptions, then loop back to the LLM:

from typing import Annotated
from line.llm_agent import loopback_tool

@loopback_tool(is_background=True)
async def search_database(ctx, query: Annotated[str, "Search query"]) -> str:
    """Search that may take a while."""
    results = await slow_database_search(query)
    return results

Context Management

Control what the LLM sees in its conversation history using agent.history.add_entry and agent.history.update.

Inject Context with agent.history.add_entry

Insert text into the LLM's conversation history. This is useful for injecting context for controlling exactly what the LLM sees from tool calls, or integrating information from external APIs.

from line.llm_agent import LlmAgent, LlmConfig, loopback_tool

agent = LlmAgent(
    model="gemini/gemini-2.5-flash-preview-09-2025",
    api_key=os.getenv("GEMINI_API_KEY"),
    config=LlmConfig(system_prompt="You are a helpful assistant."),
)

# Inject context before the conversation starts
agent.history.add_entry("The customer's name is Alice and she has a premium account.")

# Or inject context from within a tool call
@loopback_tool
async def lookup_customer(ctx, customer_id: str) -> str:
    """Look up customer details."""
    customer = await db.get_customer(customer_id)
    # Inject rich context that persists across turns
    agent.history.add_entry(f"Customer profile: {customer.summary}")
    return f"Found customer {customer.name}"

Each entry defaults to a user message (role="user"). Pass role="system" to inject a system note instead. By default entries are appended at the end of history; pass the before= or after= anchor keyword (a HistoryEvent already in history) to insert relative to a specific event.

Rewrite History with agent.history.update

agent.history.update(events, *, start=None, end=None) replaces a segment of history with a new list of HistoryEvent items. The optional start and end anchors (events already present in history) determine which segment is replaced:

  • Neither anchorevents are prefixed before the existing history.
  • start only — replaces from start through the end of history.
  • end only — replaces from the beginning of history through end (inclusive).
  • Both anchors — replaces the segment [start..end] inclusive.
from line import CustomHistoryEntry

# Prefix the history with a reminder (neither anchor)
agent.history.update([CustomHistoryEntry(content="Remember: be concise and friendly.")])

# Replace everything from `marker` onward (start only)
agent.history.update(
    [CustomHistoryEntry(content="Conversation summarized.")],
    start=marker,
)

# Replace the inclusive segment between two known events (both anchors)
agent.history.update(
    [CustomHistoryEntry(content="(redacted)")],
    start=first_event,
    end=last_event,
)

update raises ValueError if an anchor is not found in the current history, or if end appears before start.


Customize Your Agent's Implementation

Wrap with Custom Logic

Implement the Agent protocol to add guardrails, logging, or preprocessing:

from line.agent import TurnEnv
from line.events import InputEvent, OutputEvent, UserTurnEnded, AgentSendText
from line.llm_agent import LlmAgent, LlmConfig, end_call

class GuardedAgent:
    def __init__(self, inner_agent):
        self.inner = inner_agent
        self.blocked_words = ["competitor", "confidential"]

    async def process(self, env: TurnEnv, event: InputEvent):
        # Pre-process: check user input for blocked words
        if isinstance(event, UserTurnEnded):
            user_text = " ".join(
                item.content for item in event.content if hasattr(item, "content")
            )
            if any(word in user_text.lower() for word in self.blocked_words):
                yield AgentSendText(text="I can't discuss that topic.")
                return

        # Delegate to inner agent
        async for output in self.inner.process(env, event):
            yield output

async def get_agent(env, call_request):
    inner = LlmAgent(
        model="gemini/gemini-2.5-flash-preview-09-2025",
        tools=[end_call],
        config=LlmConfig(system_prompt="You are a helpful assistant."),
    )
    return GuardedAgent(inner)

LLM Provider Support

Line leverages LiteLLM to support 100+ LLM providers. Pass any LiteLLM-compatible model string to LlmAgent:

Provider Model format
OpenAI gpt-5-nano, gpt-5.2
Anthropic anthropic/claude-haiku-4-5-20251001, anthropic/claude-sonnet-4-5
Google gemini/gemini-2.5-flash-preview-09-2025

Agent Examples

Example Description
Basic Chat Simple conversational agent
Form Filler Collect structured data
Phone Transfer IVR navigation & transfers
Multi-Agent Hand off between agents
Echo Tool Custom handoff tool

Integrations

Integration Description
Exa Web Research Real-time web search
Browserbase Fill web forms via voice

Documentation

Getting Help

Acknowledgments

Line leverages the fantastic work by the maintainers of LiteLLM. Their open-source library provides the unified LLM interface that makes it possible to support 100+ providers out of the box.

LiteLLM is licensed under the MIT License.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

cartesia_line-0.2.14a0.tar.gz (172.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

cartesia_line-0.2.14a0-py3-none-any.whl (114.7 kB view details)

Uploaded Python 3

File details

Details for the file cartesia_line-0.2.14a0.tar.gz.

File metadata

  • Download URL: cartesia_line-0.2.14a0.tar.gz
  • Upload date:
  • Size: 172.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for cartesia_line-0.2.14a0.tar.gz
Algorithm Hash digest
SHA256 10a8a19e913a3d67cc7453a70bc9f78d9e693418b54a5c684b3ba5eeb52c42fa
MD5 5dfd78769665ded6bc740e9109a056b6
BLAKE2b-256 eec16727d4047bb308570274cc24fe0bc6a0f3ba1fb0848e6b4bcac6ace6fe2e

See more details on using hashes here.

Provenance

The following attestation bundles were made for cartesia_line-0.2.14a0.tar.gz:

Publisher: publish-to-pypi.yaml on cartesia-ai/line

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file cartesia_line-0.2.14a0-py3-none-any.whl.

File metadata

File hashes

Hashes for cartesia_line-0.2.14a0-py3-none-any.whl
Algorithm Hash digest
SHA256 8ff807dc06a7a917a191775a97b92d888f9e09e17f914911d11c3f5bf24175cc
MD5 97919fefbf75c6774426cf455524ea61
BLAKE2b-256 c2f36f0e0eb2ee74fa374b5fb3407d846ef181a2e419b341437c4b5d6660b1a6

See more details on using hashes here.

Provenance

The following attestation bundles were made for cartesia_line-0.2.14a0-py3-none-any.whl:

Publisher: publish-to-pypi.yaml on cartesia-ai/line

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page