Skip to main content

Put a Cartesia Line voice agent on Microsoft Teams calls. Terminates the StandIn media bridge wire protocol on one side and the Cartesia Line agent WebSocket on the other. No transcoding: the base64 PCM 16k payload is relayed verbatim both ways, barge-in via clear, native DTMF, live-context custom events, call governors with a Sonic TTS goodbye.

Project description

Microsoft Teams Bridge for Cartesia Line Agents (Python)

CI PyPI version docs MIT License Python 3.10+ PRs Welcome

cartesia-msteams-bridge puts a Cartesia Line voice agent on a real Microsoft Teams call.

Prefer Node.js? The same bridge exists as an npm package: @komaa/cartesia-msteams-bridge (repo, docs site) - same wire protocol, same environment variables, drop-in interchangeable behind the same .env file.

The hosted StandIn media bridge (standin.komaa.com) joins the Teams call and dials into this bridge over an HMAC-authenticated WebSocket; the bridge opens one Line agent stream per call (via Cartesia's WebSocket API, the integration Cartesia provides for bringing your own telephony) and relays between them.

Microsoft Teams call
       |
       v
StandIn media bridge        (hosted; joins the call)
       |   HMAC WebSocket, base64 PCM 16 kHz
       v
this bridge                 (you run it; Python/asyncio)
       |   WebSocket, base64 pcm_16000
       v
Cartesia Line agent         (your agent code on Cartesia's platform)

The hot path is copy-only in the strongest sense: both wires carry base64 PCM 16 kHz mono, so the payload string is relayed verbatim in both directions - no decode, no re-encode, no resampling, no transcoding (frame timestamps are computed from string length + padding, not by decoding).

One wire assumption to verify on your first call. Cartesia's docs give the stream a single format config and their own quick start plays media_output straight back against it, but they stop short of stating "output equals input_format" in writing. The bridge logs the first agent frame's byte length (and warns if it cannot be 16-bit PCM) so a format surprise is visible in the logs on your first live call instead of silently reaching the caller as noise.

Where the brain lives (read this first)

A Line agent is your code deployed on Cartesia's platform: the LLM, the tools, the conversation logic, the hangup decision. That makes this bridge deliberately a transport, and different from its siblings in two ways:

  • No tool registry, no vision hooks. The Line wire has no client-side tool channel back to the bridge, so tools belong in your Line agent code. The sibling bridges (ElevenLabs, Deepgram, OpenAI) host tools in-process because those platforms route function calls back over the socket; Line does not.
  • Your agent still gets the Teams context. The bridge forwards caller identity, participants, active speaker, DTMF and recording state to your agent code via the wire's metadata and custom events - shapes below.

Features

  • Realtime voice, end to end - the caller talks to your Line agent and hears it reply. Turn-taking and interruption are the Line platform's own; the bridge adds nothing to the latency budget beyond a relay hop.
  • Barge-in mapped to Teams - when Line flushes playback (clear), the bridge cancels queued audio on the Teams side with assistant.cancel.
  • Per-call personalization - caller name, tenant and direction ride the start metadata for your agent code, and are appended to CARTESIA_SYSTEM_PROMPT when you override the prompt. CARTESIA_INTRODUCTION gives a deterministic opening line (the natural place for a spoken AI disclosure); CARTESIA_VOICE_ID overrides the voice per call.
  • Native DTMF - keypad digits are forwarded as Line dtmf events, not prompt hacks.
  • Live context events - participants, active-speaker changes (rate-limited) and recording state reach your agent code as custom {type: "call_context"} events, with an initial snapshot at stream start.
  • Per-call access tokens - the long-lived CARTESIA_API_KEY never rides an agent socket: each call mints a short-lived, agent-scoped access token and authenticates the WebSocket with that.
  • Two call governors - a StandIn-side cutoff the bridge speaks a goodbye for, and a bridge-side MAX_CALL_MINUTES hard cap. With CARTESIA_TTS_MODEL + a voice id the goodbye is the exact text via standalone Sonic TTS (agent muted while it plays, real duration honored, frames never dropped, teardown waits for the send buffer to drain); without TTS the bridge emits a goodbye_request custom event your agent code may speak. Both paths are backstopped so a call can never sit open half-dead.
  • Observability - GET /healthz for liveness and GET /metrics (Prometheus text format): calls, a duration histogram, rejects, relay/drop counters, agent errors.
  • Hardened transport - replay-proof HMAC upgrade, single-use handshake guard, connection caps, payload caps, backpressure bounds, pre-start timeout, dead-peer detection, duplicate-call rejection, ack-gated audio, graceful drain that lets an in-progress goodbye finish, TLS 1.2 minimum on native TLS, and a *.cartesia.ai host allowlist so your API key can only be sent to Cartesia.

Install

pip install cartesia-msteams-bridge

Python >= 3.10. One runtime dependency (aiohttp).

Quick start

1. As a CLI (env-configured)

CARTESIA_API_KEY=sk_car_... \
CARTESIA_AGENT_ID=agent_... \
WORKER_SHARED_SECRET=... \
  cartesia-msteams-bridge

A .env file in the working directory is loaded automatically (existing environment wins). The package ships a fully commented .env.example.

2. As a library

import asyncio
from cartesia_msteams_bridge import load_config, start_server

async def main():
    server = await start_server(load_config())  # same env variables as the CLI
    try:
        await asyncio.Event().wait()   # run until cancelled
    finally:
        await server.close()           # drains live calls; lets a goodbye finish

asyncio.run(main())

server.drain() ends every live call gracefully without stopping the listener; server.close() drains and stops. The CLI wires SIGTERM/SIGINT to this for you - in your own app, hook your shutdown path to server.close() so a rolling deploy never hard-drops a call.

3. Connect it to StandIn

StandIn dials in from the internet, so expose port 8080 (tunnel or public host), then register the URL on your identity in the StandIn dashboard:

tailscale funnel --bg --https=8080 8080
# Agent voice URL: wss://<machine>.<tailnet>.ts.net:8080/voice/msteams/stream

Place a Teams call to your bot (or join the sandbox meeting). StandIn joins, connects to the bridge, and your Line agent answers.

What your Line agent receives

Everything below arrives in your agent code through Cartesia's platform (custom events arrive as UserCustomSent).

Start metadata (every call):

{
  "from": "msteams",
  "callId": "19:meeting_...",
  "callerName": "Alice W",
  "tenantId": "72f9...",
  "direction": "inbound"
}

Live context (custom events, advisory; one snapshot at stream start, then updates):

{ "type": "call_context", "note": "Call context snapshot at stream start.", "participantCount": 1, "recordingStatus": "unknown" }
{ "type": "call_context", "note": "There are 3 human participants on this call. Stay quiet unless directly addressed.", "participantCount": 3 }
{ "type": "call_context", "note": "The person now speaking is Sara.", "activeSpeaker": "Sara" }
{ "type": "call_context", "note": "Teams recording is now active.", "recordingStatus": "active" }

Governor goodbye request (custom event, only when no TTS goodbye is configured):

{ "type": "goodbye_request", "text": "I'm sorry, we've reached the time limit for this call. Goodbye!" }

Handle it by speaking the text; the bridge ends the call after the grace window either way.

Known limitation: transfer_call from your agent is logged and ignored - there is no phone number to transfer a Teams call to, and the StandIn wire currently has no Teams-side transfer capability. Disable transfer paths for calls with metadata.from == "msteams".

Configuration

Only the first three are required; the agent itself is configured in your Line deployment.

Variable Default Meaning
CARTESIA_API_KEY required Server-side key: mints per-call access tokens and calls Sonic TTS. Never rides the agent socket.
CARTESIA_AGENT_ID required The deployed Line agent that answers calls.
WORKER_SHARED_SECRET required Shared secret from StandIn pairing; both sides must match exactly.
CARTESIA_VOICE_ID unset Override the agent's TTS voice for calls through this bridge.
CARTESIA_INTRODUCTION unset Deterministic opening line (start agent.introduction).
CARTESIA_SYSTEM_PROMPT unset Prompt override (start agent.system_prompt); caller context is appended when set. Unset = the deployed agent's prompt, untouched.
CARTESIA_TTS_MODEL unset Sonic model for the deterministic governor goodbye (e.g. sonic-2).
CARTESIA_TTS_VOICE_ID unset Voice for the goodbye TTS (falls back to CARTESIA_VOICE_ID).
CARTESIA_TTS_LANGUAGE en Language for the goodbye TTS.
CARTESIA_API_HOST api.cartesia.ai REST + agent WS host. Restricted to *.cartesia.ai (CARTESIA_HOST_ALLOW_ANY=true to override for a proxy you control).
CARTESIA_VERSION 2025-04-16 Cartesia-Version header sent on every request.
MAX_CALL_MINUTES 0 (off) Bridge-side hard cap per call, in minutes (fractional allowed).
GOODBYE_TEXT a default line The goodbye the bridge-side governor speaks.
GOODBYE_GRACE_MS 8000 Grace before session.end when the goodbye duration is unknown. Always hard-bounded.
PORT / BIND 8080 / 0.0.0.0 Listen address.
TLS_CERT_PATH / TLS_KEY_PATH unset PEM cert/key for native TLS (wss://, TLS 1.2+). Without both, front the bridge with a TLS terminator.
HMAC_FRESHNESS_MS 60000 Allowed clock skew for the HMAC timestamp. Must be positive.
MAX_CONNECTIONS 0 (= 64) Max concurrent worker connections.
MAX_CONNECTIONS_PER_IP 0 (= total) Per-IP cap (with TRUST_PROXY_XFF=true behind a proxy you control).
PRE_START_TIMEOUT_MS 0 (= 10000) Drop a connection that authenticates but never sends session.start.
WORKER_IDLE_TIMEOUT_MS 0 (= 90000) Dead-peer window (the worker heartbeats every 30 s).
LOG_LEVEL info debug | info | warn | error.

Configuration fails loud: non-numeric or negative numerics, a non-cartesia.ai host, or HMAC_FRESHNESS_MS=0 stop startup with a clear message. Setting MAX_CALL_MINUTES without a TTS goodbye logs a startup warning.

Audio formats are not configurable: the wire is PCM 16 kHz by contract and the Line stream is pinned to pcm_16000 (the copy-only property).

Disconnects and reconnects

If the worker socket drops mid-call, the bridge tears the call down: the Line stream is closed and the callId is freed. There is no mid-call re-attach: a StandIn retry with the same callId after teardown is a fresh call with a fresh agent stream and no conversation memory; a retry arriving while the old session is still live is rejected with 409 so one call can never pay for two agent streams. If the Cartesia socket drops instead (including your agent code ending the call), the bridge ends the Teams call with session.end(agent-disconnected) - there is no mid-call reconnect to Cartesia either: the connect path retries once at call start, but a steady-state Line blip ends the Teams call. A silent dead peer is detected after 90 s and the billed stream is closed.

One operational note: the per-call access token is minted with the API's maximum lifetime (1 hour) and the wire has no re-auth message. Whether Cartesia enforces expiry on established streams is not documented; set MAX_CALL_MINUTES to 55 or less to end long calls cleanly before that cliff (the bridge warns at startup otherwise).

Privacy

  • The bridge relays audio and forwards call context; it logs no transcripts (the Line wire carries none) and buffers no video (video.frame messages are dropped - there is no vision path on this wire).
  • Caller audio and the call metadata above transit Cartesia's platform per your Cartesia data settings; disclose the AI on the call via CARTESIA_INTRODUCTION.
  • The Teams recording state is forwarded to your agent code (call_context events) so your Line agent can implement its own recording-gated behavior.

Repository layout

src/cartesia_msteams_bridge/
  server.py      aiohttp server + WS upgrade, HMAC validation, connection guards, session registry, drain
  session.py     per-call relay: StandIn WS <-> Line agent WS, context events, governors, goodbye
  cartesia.py    Line agent socket (access tokens, start/ack, media events, keepalive), Sonic TTS
  protocol.py    wire message parsing (JSON, camelCase, discriminated on "type")
  hmac_auth.py   HMAC-SHA256("{timestampMs}.{callId}") hex, constant-time verify
  config.py      env config (fail-loud numeric parsing, host allowlist)
  cli.py         CLI entry point, .env loader, SIGTERM/SIGINT drain
examples/        runnable example project
tests/           pytest suites (fake agent port + real loopback WS; no Cartesia account needed)

Documentation

Contributing

PRs welcome - see CONTRIBUTING.md for local setup and conventions.

License

MIT

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_msteams_bridge-0.1.0.tar.gz (55.2 kB view details)

Uploaded Source

Built Distribution

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

cartesia_msteams_bridge-0.1.0-py3-none-any.whl (43.9 kB view details)

Uploaded Python 3

File details

Details for the file cartesia_msteams_bridge-0.1.0.tar.gz.

File metadata

  • Download URL: cartesia_msteams_bridge-0.1.0.tar.gz
  • Upload date:
  • Size: 55.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for cartesia_msteams_bridge-0.1.0.tar.gz
Algorithm Hash digest
SHA256 ff633fa36e69dcff189ff8f1e050424aa20fee6853d229a6e81573fe785c831e
MD5 8d30ade2a9d4f0082996c1cc17c95d7f
BLAKE2b-256 b2e3491bddffeffb18fec22c24972d6a103376ae4e3cb1d24eeeb3aa6a7e6322

See more details on using hashes here.

Provenance

The following attestation bundles were made for cartesia_msteams_bridge-0.1.0.tar.gz:

Publisher: publish.yml on komaa-com/cartesia-msteams-bridge-py

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_msteams_bridge-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for cartesia_msteams_bridge-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b3fc64791c66ee726eb0111f1ad530464f5963eb3e3760151d796ccf6e07fa71
MD5 4d319cd2ec7112915054cb8a24714372
BLAKE2b-256 416a9e89e0e54bdaf1621da918973c078e2e3cfe63bdd689a75afba486453ffb

See more details on using hashes here.

Provenance

The following attestation bundles were made for cartesia_msteams_bridge-0.1.0-py3-none-any.whl:

Publisher: publish.yml on komaa-com/cartesia-msteams-bridge-py

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