saperly
Guides

Connections

A connection is the handler bound to a phone number — the thing that answers a call — in either hosted or manual mode, with audio always staying in-network.

A connection is the handler bound to a phone number — the thing that answers a call. You create a connection, configure how it should behave, and assign it to a number. It comes in two modes, and in both of them the call audio stays in-network — it never reaches your servers. See Core concepts.

Modes

ModeWho is the brainWhat you bring
hostedAn in-network voice assistant (cascade STT → LLM → TTS)Your OpenAI-compatible LLM, an in-network voice, and optional MCP servers
manualYour own LLMSaperly forwards text turns and executes directives — see Manual mode

In hosted mode you point the in-network assistant at your own OpenAI-compatible LLM, choose an in-network voice, and optionally declare MCP servers the assistant can call. In manual mode you are the brain: Saperly forwards each text turn to your LLM and executes the directives it returns.

Audio never leaves the carrier

Both modes keep speech-to-text, text-to-speech, and voice activity detection in-network. Saperly only ever sees text turns and tool calls — there is no audio socket to your server.

Endpoints

Base URL https://api.saperly.com. Authenticate with Authorization: Bearer sap_sk_live_...; the workspace is resolved from the token.

MethodPathReturnsScope
GET/connectionsConnection[]read
GET/connections/:idConnectionread
POST/connectionsConnection (201)write
PATCH/connections/:idConnectionwrite
DELETE/connections/:id{ status: "deleted" }write

A GET or PATCH/DELETE against an unknown id returns ConnectionNotFound (404).

The connection shape

{
  "id": "conn_...",
  "name": "support line",
  "mode": "hosted",                    // 'hosted' | 'manual'
  "backend": "network",                // hosted brain backend; currently only 'network'
  "smsAutoReply": false,               // hosted: auto-answer inbound SMS with the model
  "instructions": "You are ...",       // the system prompt (put any opening line here)
  "complianceEnabled": true,           // when on, `disclosure` is the forced TCPA opener
  "disclosure": "You are speaking ...", // spoken first, uninterruptibly, when compliance is on
  "llm": {
    "kind": "managed",                 // in-network managed model
    "model": "openai/gpt-4o"
  },
  "tts": { "voiceId": "..." },                // the in-network voice id; null ⇒ managed default
  "language": "en",                    // spoken language (BCP-47); see Languages
  "mcpServers": [
    {
      "url": "https://mcp.example.com",
      "auth": { "type": "bearer", "token": "..." },  // or { "type": "none" }
      "allowedTools": ["lookup_order"]
    }
  ],
  "createdAt": "2026-06-18T..."
}

llm, tts, and mcpServers are each nullable — omit them and the connection uses managed defaults. A read also returns recordingEnabled (per-line call recording, on by default), callControl (end-call / DTMF / transfer enablement; null ⇒ defaults), and — for manual connections — manualSecret and manualWebhookUrl.

Key fields

FieldMeaning
backendThe hosted brain backend. Today the only available value is network — an in-network voice assistant. (The openai_realtime backend is currently disabled: selecting it on create/update is rejected with UnsupportedModel (422).)
smsAutoReplyOn a hosted connection, when true the line auto-answers inbound SMS with its model. Defaults to off.
instructionsThe system prompt that defines the assistant's behavior. Any opening line the agent should say goes here — there is no separate greeting field.
complianceEnabledWhen true (the default), the disclosure is spoken as the line's first, uninterruptible utterance — the TCPA notice. When false, there is no forced opener.
disclosureThe TCPA notice spoken first when complianceEnabled is on. Leave it empty with compliance on and Saperly fills a standard org-named default.
llm.modelThe in-network managed model the hosted assistant runs (e.g. openai/gpt-4o). llm.kind is always managed.
tts.voiceIdThe in-network voice the hosted assistant speaks with. Discover ids from GET /voices. See Voices.
languageThe spoken language as a BCP-47 tag (e.g. en, es, en-US). Works in both modes (hosted: the transcription hint; manual: the Conversation Relay language). See Languages.
mcpServersMCP servers the hosted assistant may call as tools during a conversation. allowedTools scopes which tools are exposed.

Create payload

{
  "name": "support line",        // required, 1–120 chars
  "mode": "hosted",              // optional, defaults to 'hosted'
  "backend": "network",          // optional; currently only 'network' ('openai_realtime' is disabled)
  "smsAutoReply": false,         // optional; hosted only, defaults to false
  "instructions": "...",         // optional
  "complianceEnabled": true,     // optional, defaults to true
  "disclosure": "...",           // optional; the TCPA opener when compliance is on
  "llm": { ... },                // optional
  "tts": { ... },                // optional; the voice — see Voices
  "language": "en",              // optional; spoken language (BCP-47) — see Languages
  "mcpServers": [ ... ]          // optional
}

For manual mode, a manualSecret (an mc_ prefix followed by hex) is minted once when the connection is created. Your agent uses it to connect — see Manual mode. The default manual LLM model is openai/gpt-4o.

Create a hosted connection

This creates an in-network assistant with instructions and a TCPA disclosure. Omit tts and the line uses the managed default voice.

Voices: omit tts and the line uses the managed default voice, or set tts.voiceId to a voice id discovered from GET /voices (see Voices below). tts.provider is optional — it defaults to the in-network provider — so { "tts": { "voiceId": "<id>" } } is enough.

import { configure, connections } from '@trysaperly/sdk'

configure({ apiKey: process.env.SAPERLY_API_KEY! })

const { data: connection } = await connections.create({
  body: {
    name: 'support line',
    mode: 'hosted',
    instructions:
      'You are a friendly support agent for Acme Inc. Be concise. Open with: Thanks for calling Acme — how can I help?',
    complianceEnabled: true,
    disclosure: 'You are speaking with an AI assistant for Acme.',
  },
})

console.log(connection!.id) // → conn_...
import os
import httpx

connection = httpx.post(
    "https://api.saperly.com/connections",
    headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"},
    json={
        "name": "support line",
        "mode": "hosted",
        "instructions": "You are a friendly support agent for Acme Inc. Be concise. Open with: Thanks for calling Acme — how can I help?",
        "complianceEnabled": True,
        "disclosure": "You are speaking with an AI assistant for Acme.",
    },
).json()

print(connection["id"])  # → conn_...
curl -X POST https://api.saperly.com/connections \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support line",
    "mode": "hosted",
    "instructions": "You are a friendly support agent for Acme Inc. Be concise. Open with: Thanks for calling Acme — how can I help?",
    "complianceEnabled": true,
    "disclosure": "You are speaking with an AI assistant for Acme."
  }'

Assign it to a number

A connection does nothing until it is bound to a number. Assign it with POST /numbers/:id/connection:

import { numbers } from '@trysaperly/sdk'

await numbers.assignConnection({
  path: { id: numberId },
  body: { connectionId: connection!.id },
})
httpx.post(
    f"https://api.saperly.com/numbers/{number_id}/connection",
    headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"},
    json={"connectionId": connection["id"]},
)
curl -X POST https://api.saperly.com/numbers/$NUMBER_ID/connection \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "connectionId": "conn_..." }'

Now calls to that number are answered by the connection. To make it the brain of a live call from your own agent, switch the connection to manual mode — Saperly reconciles all the carrier config for you — then read Manual mode and Voice channels.

Voices

A hosted connection speaks with an in-network text-to-speech voice. Discover the available voices with GET /voices, then pass the chosen id as the connection's tts.voiceId.

MethodPathReturnsScope
GET/voicesVoice[]read

Each voice is { id, name, gender, language }. Pass an optional ?language= query (a case-insensitive prefix, e.g. ?language=en for every English variant) to narrow the list. The id is an opaque, stable voice identifier — pass it straight back as tts.voiceId.

tts.provider is optional and defaults to the in-network provider; you normally only need voiceId.

import { configure, voices, connections } from '@trysaperly/sdk'

configure({ apiKey: process.env.SAPERLY_API_KEY! })

// 1. Discover voices (optionally filter by language).
const { data: list } = await voices.list({ query: { language: 'en' } })
const voiceId = list![0]!.id

// 2. Use the chosen voice on a connection — `provider` is optional.
await connections.create({
  body: {
    name: 'support line',
    mode: 'hosted',
    tts: { voiceId },
  },
})
import os
import httpx

auth = {"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}

# 1. Discover voices (optionally filter by language).
voices = httpx.get(
    "https://api.saperly.com/voices",
    headers=auth,
    params={"language": "en"},
).json()
voice_id = voices[0]["id"]

# 2. Use the chosen voice on a connection — provider is optional.
httpx.post(
    "https://api.saperly.com/connections",
    headers=auth,
    json={"name": "support line", "mode": "hosted", "tts": {"voiceId": voice_id}},
)
# 1. Discover voices (optionally filter by ?language=en).
curl "https://api.saperly.com/voices?language=en" \
  -H "Authorization: Bearer $SAPERLY_API_KEY"

# 2. Use the chosen voice on a connection — provider is optional.
curl -X POST https://api.saperly.com/connections \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "support line", "mode": "hosted", "tts": { "voiceId": "<id from /voices>" } }'

Languages

A connection can converse in a language other than English — in both hosted and manual mode. Set the connection's language field to a BCP-47 tag — en, es, fr, or a regional variant like en-US / es-419. What it drives depends on the mode:

  • Hostedlanguage is the in-network transcription hint: it tells the assistant which language the caller speaks. Pair it with a voice that speaks the same language (see Voices), and write your instructions so the model replies in that language.
  • Manuallanguage is the Conversation Relay language: it steers the in-network transcription and the spoken voice. Your brain decides what to say; this sets the language it's transcribed from and spoken in.
FieldTypeNotes
languagestringBCP-47 tag, e.g. en, es, en-US. Optional — send "" (or omit) to let the carrier auto-detect. Any well-formed tag is accepted; an unsupported one falls back to a multilingual engine rather than failing.

Discover the supported languages

GET /languages returns the curated set of supported languages, each { code, name }, ordered most-supported first. code is the BCP-47 tag to pass as a connection's language; every language listed has at least one voice that can speak it.

MethodPathReturnsScope
GET/languagesLanguage[]read
import { configure, languages, voices, connections } from '@trysaperly/sdk'

configure({ apiKey: process.env.SAPERLY_API_KEY! })

// 1. See what's supported: [{ code: 'en', name: 'English' }, { code: 'es', name: 'Spanish' }, …]
const { data: langs } = await languages.list()

// 2. Pick a voice that speaks Spanish (hosted lines pair language + voice).
const { data: list } = await voices.list({ query: { language: 'es' } })

// 3. Create a Spanish hosted line.
await connections.create({
  body: {
    name: 'línea de soporte',
    mode: 'hosted',
    language: 'es',             // transcribe the caller in Spanish…
    tts: { voiceId: list![0]!.id }, // …and speak with a Spanish voice
    instructions: 'Responde siempre en español.',
  },
})
import os
import httpx

auth = {"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"}

# 1. See what's supported: [{"code": "en", "name": "English"}, …]
languages = httpx.get("https://api.saperly.com/languages", headers=auth).json()

# 2. Pick a Spanish voice.
voices = httpx.get(
    "https://api.saperly.com/voices", headers=auth, params={"language": "es"}
).json()

# 3. Create a Spanish hosted line.
httpx.post(
    "https://api.saperly.com/connections",
    headers=auth,
    json={
        "name": "línea de soporte",
        "mode": "hosted",
        "language": "es",
        "tts": {"voiceId": voices[0]["id"]},
        "instructions": "Responde siempre en español.",
    },
)
# 1. List supported languages.
curl "https://api.saperly.com/languages" \
  -H "Authorization: Bearer $SAPERLY_API_KEY"

# 2. Create a Spanish hosted line (pair the language with a Spanish voice from /voices).
curl -X POST https://api.saperly.com/connections \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "linea de soporte", "mode": "hosted", "language": "es", "tts": { "voiceId": "<id from /voices?language=es>" } }'

Language is set on the connection, not per call

Both the voice and the language are properties of the connection, not of an individual call. POST /calls only overrides instructions per call — to change the language or voice, PATCH the connection (or use a different connection via connectionId).

Calling your own tools (MCP)

A hosted connection can call your tools mid-conversation — to look up an order, check availability, book a slot — by pointing its assistant at one or more Model Context Protocol (MCP) servers you host. You declare them with the mcpServers field; the in-network assistant decides when to call a tool and weaves the result into the conversation. Audio never leaves the carrier — the assistant calls your server over plain HTTPS, just like any other backend request.

This is hosted-only. In manual mode you are the brain, so you call your own tools directly — there's no mcpServers field to set.

What your server must be

  • A remote, streamable-HTTP MCP server reachable at a public https:// URL. This is the modern single-endpoint MCP transport (one URL handles the JSON-RPC POSTs). Legacy SSE-only servers are not supported — if your framework offers both, expose the streamable-HTTP endpoint (commonly /mcp).
  • It must implement the standard MCP handshake plus tools/list and tools/call. Any compliant MCP SDK (TypeScript, Python, etc.) gives you this out of the box.
  • Auth (optional): auth: { type: "bearer", token: "..." } sends Authorization: Bearer <token> on every request to your server (the token is stored encrypted and never returned in API reads). Use { "type": "none" } (or omit auth) for an open server.
  • allowedTools (optional): an allow-list of tool names to expose to the assistant. Omit it to expose every tool your server advertises.

Add MCP servers via the API

Pass mcpServers when you create (or PATCH) a hosted connection. Each entry is { url, auth?, allowedTools? }:

import { configure, connections } from '@trysaperly/sdk'

configure({ apiKey: process.env.SAPERLY_API_KEY! })

const { data: connection } = await connections.create({
  body: {
    name: 'support line',
    mode: 'hosted',
    instructions:
      'You are a support agent for Acme. Use the lookup_order tool when a caller asks about an order.',
    mcpServers: [
      {
        url: 'https://mcp.acme.com/mcp',
        auth: { type: 'bearer', token: process.env.ACME_MCP_TOKEN! },
        allowedTools: ['lookup_order', 'check_availability'],
      },
    ],
  },
})

console.log(connection!.id) // → conn_...
import os
import httpx

connection = httpx.post(
    "https://api.saperly.com/connections",
    headers={"Authorization": f"Bearer {os.environ['SAPERLY_API_KEY']}"},
    json={
        "name": "support line",
        "mode": "hosted",
        "instructions": "You are a support agent for Acme. Use the lookup_order tool when a caller asks about an order.",
        "mcpServers": [
            {
                "url": "https://mcp.acme.com/mcp",
                "auth": {"type": "bearer", "token": os.environ["ACME_MCP_TOKEN"]},
                "allowedTools": ["lookup_order", "check_availability"],
            }
        ],
    },
).json()

print(connection["id"])  # → conn_...
curl -X POST https://api.saperly.com/connections \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "support line",
    "mode": "hosted",
    "instructions": "You are a support agent for Acme. Use the lookup_order tool when a caller asks about an order.",
    "mcpServers": [
      {
        "url": "https://mcp.acme.com/mcp",
        "auth": { "type": "bearer", "token": "'"$ACME_MCP_TOKEN"'" },
        "allowedTools": ["lookup_order", "check_availability"]
      }
    ]
  }'

To update the servers on an existing connection, send the full mcpServers array in a PATCH /connections/:id — it replaces the stored list (drop a server by omitting it; clear them all with []).

Add MCP servers via the dashboard

  1. Open Connections and click the hosted connection you want to edit.
  2. In the agent config, find the MCP servers section and click Add server.
  3. Paste the server URL (your streamable-HTTP endpoint, e.g. https://mcp.acme.com/mcp) and, if your server requires auth, a Bearer token.
  4. Save. Saving re-syncs the assistant and registers the tools.

Tip your model off to the tools

The assistant only calls a tool when its instructions give it a reason to. Mention the tools and when to use them in the system prompt — e.g. "Use lookup_order whenever the caller references an order number." Otherwise the model may never reach for them.

When do the tools take effect?

Your servers are registered to the assistant on sync, which runs automatically every time you create or update the connection (via the API, the SDK, or the dashboard Save). New calls to a number bound to that connection can use the tools immediately; a call already in progress keeps the configuration it started with.

Troubleshooting

  • The agent never calls my tool. The model decides when to call a tool — make sure your instructions describe the tool and when to use it, and that the tool isn't filtered out by allowedTools.
  • Nothing happens after I add a server. Confirm the URL is a streamable-HTTP MCP endpoint (not SSE-only) reachable over public HTTPS, and that it answers tools/list. An unreachable or SSE-only server registers but exposes no tools.
  • I changed a token or allowedTools but the old behavior persists. Editing those values for the same URL is not pushed in place. Remove the server and re-add it (a new entry, or delete + save then add + save) to apply the change.
  • A tool errors at call time. The assistant surfaces tool failures back into the conversation. Check your server's logs and that the bearer token is valid — Saperly forwards it as Authorization: Bearer <token> on every request.
  • Numbers — provision and manage the phone numbers connections bind to.
  • Manual mode — run your own LLM as the brain of a live call.
  • Voice — place and control outbound calls.
  • API reference — the full generated contract.

On this page