Metadata-Version: 2.4
Name: cartesia-msteams-bridge
Version: 0.1.0
Summary: 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-URL: Homepage, https://github.com/komaa-com/cartesia-msteams-bridge-py
Project-URL: Documentation, https://github.com/komaa-com/cartesia-msteams-bridge-py#readme
Project-URL: StandIn docs, https://docs.komaa.com/cartesia/installation
Project-URL: Repository, https://github.com/komaa-com/cartesia-msteams-bridge-py
Project-URL: Issues, https://github.com/komaa-com/cartesia-msteams-bridge-py/issues
Author-email: Alaaeldin Elhenawy <alaamh@outlook.com>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,bridge,cartesia,conversational-ai,line,microsoft-teams,sonic,speech-to-speech,telephony,voice,voice-agent,websocket
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Telephony
Classifier: Topic :: Multimedia :: Sound/Audio
Requires-Python: >=3.10
Requires-Dist: aiohttp<4,>=3.9
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# Microsoft Teams Bridge for Cartesia Line Agents (Python)

[![CI](https://github.com/komaa-com/cartesia-msteams-bridge-py/actions/workflows/ci.yml/badge.svg)](https://github.com/komaa-com/cartesia-msteams-bridge-py/actions/workflows/ci.yml)
[![PyPI version](https://img.shields.io/pypi/v/cartesia-msteams-bridge.svg)](https://pypi.org/project/cartesia-msteams-bridge/)
[![docs](https://img.shields.io/badge/docs-komaa--com.github.io-2563eb.svg)](https://komaa-com.github.io/cartesia-msteams-bridge-py/)
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB.svg)](https://www.python.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](./CONTRIBUTING.md)

**`cartesia-msteams-bridge`** puts a [Cartesia Line](https://docs.cartesia.ai/line) voice agent on a real **Microsoft Teams call**.

> **Prefer Node.js?** The same bridge exists as an npm package: [`@komaa/cartesia-msteams-bridge`](https://www.npmjs.com/package/@komaa/cartesia-msteams-bridge) ([repo](https://github.com/komaa-com/cartesia-msteams-bridge), [docs site](https://komaa-com.github.io/cartesia-msteams-bridge/)) - same wire protocol, same environment variables, drop-in interchangeable behind the same `.env` file.

The hosted **StandIn media bridge** ([standin.komaa.com](https://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](https://docs.cartesia.ai/line/integrations/websocket-api), the integration Cartesia provides for bringing your own telephony) and relays between them.

```text
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

```bash
pip install cartesia-msteams-bridge
```

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

## Quick start

### 1. As a CLI (env-configured)

```bash
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`](./.env.example).

### 2. As a library

```python
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](https://standin.komaa.com/dashboard):

```bash
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](https://standin.komaa.com/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):

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

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

```json
{ "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):

```json
{ "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

- **Docs site:** [komaa-com.github.io/cartesia-msteams-bridge-py](https://komaa-com.github.io/cartesia-msteams-bridge-py/) - getting started, the runnable example, [what your Line agent receives](https://komaa-com.github.io/cartesia-msteams-bridge-py/your-line-agent/), configuration and library API reference, wire protocol, troubleshooting.
- **Example project:** [`examples/basic-bridge/`](./examples/basic-bridge/) - a runnable embedding.
- **StandIn (the hosted service):** [standin.komaa.com](https://standin.komaa.com) · [docs.komaa.com](https://docs.komaa.com/cartesia/installation).
- **Cartesia Line:** [docs.cartesia.ai/line](https://docs.cartesia.ai/line) - deploying agents, the WebSocket API this bridge speaks.
- **Siblings:** the same bridge exists for [ElevenLabs](https://github.com/komaa-com/elevenlabs-msteams-bridge-py), [LiveKit](https://github.com/komaa-com/livekit-msteams-bridge-py), [OpenAI Realtime](https://github.com/komaa-com/openai-msteams-bridge), and [Deepgram](https://github.com/komaa-com/deepgram-msteams-bridge-py) - same wire protocol, same hardening, pick the agent platform that fits.

## Contributing

PRs welcome - see [CONTRIBUTING.md](./CONTRIBUTING.md) for local setup and conventions.

## License

[MIT](./LICENSE)
