Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,61 @@ await DeepTrust().watch(conversation_id) # platform="elevenlabs"
was already watching. It raises `ServiceError` with status 404 when the
platform is not connected for your organisation.

## VAPI

No extra install: the adapter talks to VAPI over HTTP, and `httpx` is already
here.

```python
from deeptrust.agents import DeepTrust
from deeptrust.agents.vapi import Bridge

bridge = Bridge(DeepTrust(), api_key=os.environ["VAPI_API_KEY"])

@app.post("/vapi/webhook") # your route, on your server
async def vapi_webhook(payload: dict):
await bridge.handle(payload, user=caller)
return {}
```

VAPI is the mirror image of ElevenLabs. Nobody can hold a socket: VAPI posts
its server-url events to *your* server, so the adapter is a handler you call
from your own webhook route rather than a watcher with a loop of its own. Hand
it every event and let it decide — the ones that are not turns cost nothing,
and they carry the call object the control URL is learned from. One bridge
serves every call your server receives.

Nudges go back on the per-call HTTPS endpoint VAPI publishes as
`monitor.controlUrl`, as an `add-message` with `triggerResponseEnabled: true`.
That is an interrupt, so VAPI behaves like LiveKit rather than ElevenLabs: the
agent responds to the nudge immediately, cutting into what it was saying. It is
sent as a system message, not a `say`, so your agent's own persona carries it
instead of speaking our words verbatim.

The control URL comes off the webhook payload when the event carries it, and
from `GET /call/{id}` when it does not — which is why the bridge wants a VAPI
private key. Inbound calls are the case this exists for: nobody placed the
call, so there was no creation-time response to capture a URL from. Once
resolved it is remembered for the rest of the call. A call that has already
hung up publishes no control URL, and a nudge from its last turn is dropped
rather than raising inside your webhook route.

A control URL is only used if it is HTTPS on `vapi.ai`. Your webhook route is
reachable from the internet and a nudge names what was found in the call, so a
forged `monitor.controlUrl` would otherwise be a way to make this SDK post that
text to someone else's host. Anything off that domain is treated as no URL, and
the bridge asks VAPI for the real one.

Only final transcripts are read. VAPI emits a `transcript` event per partial
while the sentence is still being recognised, and analysing those would
re-analyse the same sentence several times over. `monitor.listenUrl` next door
is raw PCM audio and is ignored. `end-of-call-report` ends the DeepTrust
session.

`tool-calls` is the one event whose response controls what the agent does next,
and this adapter does not answer it. Blocking an action is `Session.check`,
which is not implemented in this version.

## Your own stack

Neither adapter is required. If your agent is somewhere else, the two verbs are
Expand Down
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ readme = "README.md"
requires-python = ">=3.11"
license = "Apache-2.0"
authors = [{ name = "DeepTrust", email = "engineering@deeptrust.ai" }]
keywords = ["voice", "agents", "livekit", "elevenlabs", "observability", "security"]
keywords = [
"voice",
"agents",
"livekit",
"elevenlabs",
"vapi",
"observability",
"security",
]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
Expand All @@ -27,6 +35,8 @@ Issues = "https://github.com/deeptrust-ai/deeptrust-python/issues"
# integrating their own stack should not be made to install a platform SDK.
livekit = ["livekit-agents>=1.7"]
elevenlabs = ["websockets>=13"]
# No `vapi` extra: the adapter talks to VAPI over plain HTTP, and httpx is
# already the core dependency.

[build-system]
requires = ["hatchling"]
Expand Down
7 changes: 5 additions & 2 deletions src/deeptrust/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
whether a single action may run and does block; it is not implemented in this
version.

Adapters for LiveKit and ElevenLabs are in `deeptrust.agents.livekit` and
`deeptrust.agents.elevenlabs`, and wire both ends up for you.
Adapters for LiveKit, ElevenLabs and VAPI are in `deeptrust.agents.livekit`,
`deeptrust.agents.elevenlabs` and `deeptrust.agents.vapi`, and wire both ends
up for you. Which end they hold differs by platform: LiveKit runs in your
process, ElevenLabs gives a socket to hold, and VAPI posts webhooks to your
server and takes nudges back on a per-call control URL.

`DeepTrust.watch` is for the hosted path: an organisation that connected its
ElevenLabs workspace in the DeepTrust dashboard can hand a live conversation
Expand Down
292 changes: 292 additions & 0 deletions src/deeptrust/agents/vapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
"""VAPI adapter.

from deeptrust.agents import DeepTrust
from deeptrust.agents.vapi import Bridge

bridge = Bridge(DeepTrust(), api_key=os.environ["VAPI_API_KEY"])

@app.post("/vapi/webhook") # your route, your server
async def vapi_webhook(payload: dict):
await bridge.handle(payload, user=caller)
return {}

VAPI's transport is the mirror image of ElevenLabs'. There is no socket anyone
can hold open: VAPI posts its server-url events to *your* server, and what goes
back the other way goes to a per-call HTTPS endpoint VAPI mints for that call
and publishes on the call object as `monitor.controlUrl`. So the adapter is a
handler you call from inside your own webhook route rather than a watcher with
a loop of its own, and it needs no code inside your agent either way.

A nudge is delivered as `add-message` with `triggerResponseEnabled: true`,
which is an interrupt: VAPI hands the system message to the model and has it
respond immediately, cutting into what the agent is saying. That makes VAPI
behave like the LiveKit adapter rather than the ElevenLabs one, whose
contextual update is documented as non-interrupting and only shapes the turn
after the current one. A system message rather than a `say` because a `say`
would put our words in the agent's mouth verbatim, while a system message lets
the agent's own persona carry them.

The control URL is read from the webhook payload when the event carries it,
and fetched with `GET /call/{id}` when it does not, then cached for the rest of
the call. Inbound calls are the case this exists for: nobody placed the call,
so there is no creation-time response to have captured a URL from, and an
adapter that assumed one would work for outbound calls only.

`monitor.listenUrl` sits next to it and is deliberately ignored: it is a raw
PCM audio stream, not a channel anything can be sent on.

A control URL is only accepted if it is HTTPS on VAPI's own domain. The webhook
body is attacker-reachable in the general case -- it arrives over the public
internet at your route -- and a nudge names what DeepTrust found in the call, so
a forged `monitor.controlUrl` would be a way to have this SDK post that text to
a host of someone else's choosing. Anything off `vapi.ai` reads as no control
URL rather than as an error.

Only final transcripts are read. VAPI emits a `transcript` event per partial as
the sentence is still being recognised, and analysing those re-analyses the
same sentence several times -- the same class of bug the LiveKit adapter's
`last` dict guards against, arriving here by a different route.

VAPI's `tool-calls` webhook is the one event whose response controls what the
agent does next, and this adapter does not answer it. Blocking a tool call is
`Session.check`, which is separate work; this is transcript in, nudge out.

No extra dependency: httpx is already the client's own.
"""

from __future__ import annotations

from collections.abc import Callable
from typing import Any
from urllib.parse import quote, urlsplit

import httpx

from ..errors import ConfigError
from ..types import Analysis, Nudge, User
from . import DeepTrust
from ._session import Session

API_BASE_URL = "https://api.vapi.ai"

#: The only domain a control URL may point at.
CONTROL_URL_DOMAIN = "vapi.ai"


def add_message_command(text: str) -> dict[str, Any]:
"""The control-URL body that delivers a nudge as an interrupt.

`triggerResponseEnabled` is what makes it one. Without it VAPI appends the
message and waits for the agent to reach its next turn on its own, which
is a different product: the caller is being worked on now.
"""
return {
"type": "add-message",
"message": {"role": "system", "content": text},
"triggerResponseEnabled": True,
}


class Bridge:
"""Turns VAPI server-url events into DeepTrust calls, and nudges into
messages on the live call.

One bridge serves every call your server receives; state is kept per VAPI
call id, and dropped when the call reports it ended.
"""

def __init__(
self,
dt: DeepTrust,
*,
api_key: str,
deliver: bool = True,
on_analysis: Callable[[Analysis], None] | None = None,
base_url: str = API_BASE_URL,
) -> None:
"""`api_key` is a VAPI private key: it reads the call object to find
the control URL when an event does not carry one."""
if not api_key:
raise ConfigError(
"Bridge needs a VAPI private API key. It reads the call to "
"find monitor.controlUrl, which is where a nudge is sent."
)
self._dt = dt
self._key = api_key
self._deliver = deliver
self._on_analysis = on_analysis
self._base_url = base_url
self._sessions: dict[str, Session] = {}
# Per call, because VAPI mints the URL per call. Cached because most
# events carry it and the fetch is only for the ones that do not.
self._control: dict[str, str] = {}

async def handle(
self,
payload: dict[str, Any],
*,
user: User | None = None,
) -> Analysis | None:
"""Process one server-url event. Returns the analysis it caused, if any.

Call it for every event and let it decide: events that are not turns
cost nothing, and the ones that are not transcripts still carry the
call object the control URL is learned from.

Returns None for an event that started no job, which is most of them.
"""
message = _message(payload)
call = message.get("call")
call = call if isinstance(call, dict) else {}
call_id = str(call.get("id") or "")
Comment on lines +138 to +141

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Webhook events lack authentication

Bridge.handle accepts unauthenticated payloads as VAPI events. Attackers can inject turns, trigger analyses, send nudges, or end known calls.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

if not call_id:
return None

url = _monitor_control_url(call)
if url:
self._control[call_id] = url
Comment on lines +145 to +147

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟥 Webhook control URLs enable SSRF

An untrusted webhook can set monitor.controlUrl to any URL. send_nudge posts nudge content there, exposing data and reaching internal services.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


kind = message.get("type")
if kind == "end-of-call-report":
await self._finish(call_id)
return None
if kind != "transcript":
return None

role, text = _read_turn(message)
if not text:
return None

session = self._session(call_id, user)
session.append(role, text)

# Caller turns only. Feeding the agent's own replies back in doubles
# the work and lets its answers reclassify the call.
if role != "user":
return None

result = await session.analyze()
if result is None:
return None
if self._on_analysis:
self._on_analysis(result)
if self._deliver:
for nudge in result.nudges:
await self.send_nudge(call_id, nudge)
return result

async def send_nudge(self, call_id: str, nudge: Nudge) -> bool:
"""Send one nudge into a live call. Returns whether VAPI took it.

A call that has already ended publishes no control URL, so a nudge
produced from its last turn returns False rather than raising: the
call it was for is over, and the finding is already recorded.
"""
url = await self.control_url(call_id)
if not url:
return False

async with httpx.AsyncClient() as client:
# No credential on this request. The control URL carries its own
# authority and VAPI does not accept the private key here.
response = await client.post(url, json=add_message_command(nudge.render()))
return response.is_success

async def control_url(self, call_id: str) -> str | None:
"""The call's `monitor.controlUrl`, from cache or from VAPI."""
cached = self._control.get(call_id)
if cached:
return cached

async with httpx.AsyncClient(
base_url=self._base_url,
headers={"Authorization": f"Bearer {self._key}"},
) as client:
# Quoted: the id comes off a webhook body, and a raw `/` or `?` in
# it would address a different endpoint of the API than the call
# lookup.
response = await client.get(f"/call/{quote(call_id, safe='')}")
if not response.is_success:
return None

body = response.json()
url = _monitor_control_url(body if isinstance(body, dict) else {})
if url:
self._control[call_id] = url
return url
Comment on lines +178 to +216

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 VAPI failure contracts diverge

HTTP errors return False or None, while network failures and malformed success bodies raise. The public failure boundary needs definition and tests.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


def session(self, call_id: str) -> Session | None:
"""The DeepTrust session for a call, so its transcript stays reachable."""
return self._sessions.get(call_id)

def _session(self, call_id: str, user: User | None) -> Session:
session = self._sessions.get(call_id)
if session is None:
session = self._dt.session(external_id=call_id, user=user, platform="vapi")
self._sessions[call_id] = session
return session

async def _finish(self, call_id: str) -> None:
self._control.pop(call_id, None)
session = self._sessions.pop(call_id, None)
if session is not None:
await session.end()


def _message(payload: dict[str, Any]) -> dict[str, Any]:
"""The event itself, out of the request body.

VAPI wraps a server-url event in `{"message": {...}}`. A bare event is
accepted too, so a payload already unwrapped by the caller's own framework
still works.
"""
message = payload.get("message")
return message if isinstance(message, dict) else payload


def _read_turn(message: dict[str, Any]) -> tuple[str, str]:
"""A turn from a transcript event, or ("", "") if it is not one yet.

Partials are not turns. VAPI sends one event per revision of the sentence
being recognised, all with the same `transcriptType: "partial"`, and only
the final one is the sentence the caller actually said.
"""
if message.get("transcriptType") != "final":
return "", ""
role = "user" if str(message.get("role") or "") == "user" else "agent"
return role, str(message.get("transcript") or "").strip()


def _monitor_control_url(call: dict[str, Any]) -> str | None:
"""`monitor.controlUrl` off a call object, or None.

Defensive about the shape rather than trusting it: this runs on the
webhook path, and a missing or renamed field has to read as "no control
URL yet" -- which a fetch may still answer -- instead of as a TypeError
inside the customer's webhook route.
"""
monitor = call.get("monitor")
if not isinstance(monitor, dict):
return None
url = monitor.get("controlUrl")
if not isinstance(url, str) or not url:
return None
return url if _is_vapi_control_url(url) else None


def _is_vapi_control_url(url: str) -> bool:
"""Whether a URL is one VAPI could have minted: HTTPS, on `vapi.ai`.

The check is on the host rather than the full URL because VAPI mints these
per region and per call -- the path and the subdomain both vary -- while
the domain is the part that says the destination is VAPI and not somewhere
a forged webhook pointed us.
"""
try:
parsed = urlsplit(url)
except ValueError:
return False
if parsed.scheme != "https":
return False
host = (parsed.hostname or "").lower()
return host == CONTROL_URL_DOMAIN or host.endswith(f".{CONTROL_URL_DOMAIN}")
Loading
Loading