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
80 changes: 69 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ Your agent runs wherever it already runs. This client sends the transcript as
it happens, gets back what the analysis found, and delivers the nudge to the
agent while the caller is still on the line.

## Two ways in

**Connect, no code.** If your agents run on ElevenLabs, connect the workspace
once in the DeepTrust dashboard (Settings, Voice Agents) with an API key that
has the ElevenLabs Agents Write permission, and pick the agents to watch.
DeepTrust finds their calls, listens along, nudges the agent mid-call, and
records the transcript. Nothing to install and nothing in this package to run.
Phone calls are picked up at call setup; other channels within a few seconds.

**This client, in your process.** For your own stack, for LiveKit, or when you
want the socket held by you rather than by DeepTrust. Append turns, call
`analyze`, deliver the nudge. The rest of this README is about this path.

The two meet in the same place: every call, either way, lands in the Calls list
with the `voice_agent` source.

```bash
pip install deeptrust-ai
```
Expand Down Expand Up @@ -46,12 +62,19 @@ a nudge, which has both what was seen and what to do about it. An agent given
only the first has to pick a response itself, and the one it usually picks is
handing the call to a person.

## Two methods
## Three methods

`analyze` reviews the transcript and returns findings. It does not block the
agent, so a result arrives after the turn that caused it has been spoken, and a
nudge affects what the agent says next.

`end` tells DeepTrust the call is over, so post-call processing starts now
rather than after the server's inactivity timeout. Calling it twice is harmless.

```python
await call.end()
```

`check` decides whether a single action may run, and does block. It is meant to
be called from a tool handler before the action executes. Not implemented in
this version.
Expand Down Expand Up @@ -106,15 +129,33 @@ await monitor.watch(conversation_id, user=caller)
```

This needs no code inside your agent. ElevenLabs exposes a per-conversation
monitor socket, so DeepTrust connects from its own side with a workspace key,
reads the transcript, and sends findings back as contextual updates on the same
socket.
monitor socket, so this connects to it from your process with your workspace
key, reads the transcript, and sends findings back as contextual updates on the
same socket.

Two differences from LiveKit, which the client reports rather than hides.
Contextual updates are documented as non-interrupting, so a finding shapes the
next turn. And the socket carries events, not audio, which suits a client that
reads what was said and does not analyse the audio itself.

### Or let DeepTrust hold the socket

If the workspace is connected in the dashboard, you do not need `Monitor` or an
ElevenLabs key here at all. DeepTrust finds live calls on its own. When your
backend already knows a conversation id, for instance from the
`conversation_initiation_metadata` client event, hand it over and the call is
watched from its first turn instead of from the next check:

```python
from deeptrust.agents import DeepTrust

await DeepTrust().watch(conversation_id) # platform="elevenlabs"
```

`watch` returns `True` when it started the monitor and `False` when DeepTrust
was already watching. It raises `ServiceError` with status 404 when the
platform is not connected for your organisation.

## Your own stack

Neither adapter is required. If your agent is somewhere else, the two verbs are
Expand All @@ -123,16 +164,27 @@ your agent takes instructions.

## Keys

Keys are created per organisation in the DeepTrust dashboard. Analysis and
enforcement are separate scopes, so a team piloting analysis is not holding a
key that can block their production calls. When a key lacks a scope, the client
says which scope is missing and which the key holds.
Keys are created per organisation in the DeepTrust dashboard, under Settings
and then API Keys (the tab is offered to voice-agent organisations). A key
belongs to the organisation rather than to the person who made it, so it keeps
working when they leave, and it reaches the agent endpoints and nothing else.

The same key authenticates the hosted path's call-start webhook, so a workspace
connected through Settings, Voice Agents needs no second credential.

The API does not divide keys by scope today. When it does, the client already
reports which scope was missing and which the key holds, rather than a bare
403.

```bash
export DEEPTRUST_API_KEY=...
export DEEPTRUST_BASE_URL=... # optional, for a non-production workspace
```

The key is sent as `X-DeepTrust-Api-Key`. The default base URL is
`https://app.deeptrust.ai/api/v1`; a `DEEPTRUST_BASE_URL` from 0.0.1 that ends
in `/api` needs `/v1` appended.

## Development

```bash
Expand Down Expand Up @@ -162,8 +214,14 @@ Point a client at it with `DEEPTRUST_BASE_URL`.

## Status

`0.0.1`, first release. `analyze` and both adapters work. `check` is defined
and raises `NotImplementedError`. The shapes in `deeptrust.types` are the part
most likely to move.
`0.0.2`. `analyze`, `end`, `watch` and both adapters work against the hosted
API. `check` is defined and raises `NotImplementedError`. The shapes in
`deeptrust.types` are the part most likely to move.

Changes since 0.0.1: the key travels in `X-DeepTrust-Api-Key` (the bearer form
is still sent, and goes away in 0.1); the default base URL gained `/v1`; the
ElevenLabs adapter sends contextual updates in the monitor socket's command
envelope, which 0.0.1 got wrong, so its nudges never arrived; `Session.end`
and `DeepTrust.watch` are new.

Apache 2.0.
32 changes: 29 additions & 3 deletions dev/server.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""A local stand-in for the DeepTrust API, for developing against this client.

Implements POST /agents/analyze with the same request and response shapes the
hosted API uses, so the client, both adapters and the examples can be run end
to end with no key and no network.
Implements POST /agents/analyze, POST /agents/sessions/{id}/end and
POST /agents/conversations/{id}/watch with the same request and response
shapes the hosted API uses, so the client, both adapters and the examples can
be run end to end with no key and no network.

What it is NOT is the analysis. The hosted API runs a reasoning model against
an organisation's runbook, SOPs and controls. This matches a handful of
Expand Down Expand Up @@ -151,6 +152,31 @@ def analyze(req: AnalyzeReq) -> dict[str, Any]:
}


_ENDED: set[str] = set()
_WATCHED: set[str] = set()


class WatchReq(BaseModel):
platform: str = "elevenlabs"
agent_id: str | None = None


@app.post("/agents/sessions/{session_id}/end")
def end_session(session_id: str) -> dict[str, Any]:
already = session_id in _ENDED
_ENDED.add(session_id)
return {"session_id": session_id, "ended": True, "already_ended": already}


@app.post("/agents/conversations/{conversation_id}/watch", status_code=202)
def watch_conversation(conversation_id: str, req: WatchReq) -> dict[str, Any]:
"""The hosted handoff. Here it only remembers the id; the real API starts
a monitor from its own side, which this server has no socket for."""
started = conversation_id not in _WATCHED
_WATCHED.add(conversation_id)
return {"conversation_id": conversation_id, "watching": True, "started": started}


@app.get("/health")
def health() -> dict[str, Any]:
return {"ok": True, "server": "local dev", "signals": len(PATTERNS)}
16 changes: 16 additions & 0 deletions examples/elevenlabs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ uv run python main.py serve
For outbound calls, skip the webhook and pass the conversation id that the
outbound API returns when the call is placed.

## Or let DeepTrust hold the socket

Everything above runs the monitor in your process with your ElevenLabs key.
The other way is to connect the workspace once in the DeepTrust dashboard
(Settings, Voice Agents) and pick the agents to watch; DeepTrust then finds the
calls and holds the socket itself, and none of this example needs to run. When
your backend already knows a conversation id, hand it over so the call is
watched from its first turn rather than from the next check:

```bash
uv run python main.py handoff conv_2601m20xqctperzspz3zzz95pmrs
```

That is `DeepTrust().watch(conversation_id)`, one request with the DeepTrust
key alone.

## What it looks like

The caller asserts an approval that exists somewhere other than a ticket:
Expand Down
25 changes: 25 additions & 0 deletions examples/elevenlabs/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
uv run python main.py serve
Point the ElevenLabs conversation initiation webhook at
http://<host>/calls and every inbound call is watched automatically.

uv run python main.py handoff <conversation_id>
The hosted path. If the workspace is connected in the DeepTrust
dashboard, this needs no ElevenLabs key: DeepTrust holds the monitor
socket itself, and this only tells it which conversation to watch.
"""

import asyncio
Expand Down Expand Up @@ -200,6 +205,24 @@ async def talk(turns: list[str]) -> None:
break


async def handoff(conversation_id: str) -> None:
"""Hand the conversation to DeepTrust's own monitor.

The workspace must be connected in the dashboard (Settings, Voice Agents).
DeepTrust would find the call on its next check anyway; the handoff only
makes it immediate.
"""
dt = DeepTrust()
try:
started = await dt.watch(conversation_id)
finally:
await dt.aclose()
print(
f"{'watching' if started else 'already watching'} {conversation_id} (hosted)",
flush=True,
)


def serve() -> None:
"""The webhook receiver, for watching every call without being told."""
import uvicorn
Expand Down Expand Up @@ -233,6 +256,8 @@ async def call_started(body: dict) -> dict:
asyncio.run(watch_one(sys.argv[2]))
elif command == "serve":
serve()
elif command == "handoff" and len(sys.argv) > 2:
asyncio.run(handoff(sys.argv[2]))
else:
print(__doc__)
raise SystemExit(1)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "deeptrust-ai"
version = "0.0.1"
version = "0.0.2"
description = "QA and runtime nudges for voice agents, while the call is still happening."
readme = "README.md"
requires-python = ">=3.11"
Expand Down
47 changes: 35 additions & 12 deletions src/deeptrust/_http.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"""HTTP transport for the DeepTrust API.

Holds the base URL, the bearer token and the retry policy, and turns error
Holds the base URL, the API key and the retry policy, and turns error
responses into the exception types in `errors`. Nothing in this module knows
what an analysis or a verdict is.

The key travels in `X-DeepTrust-Api-Key`, which is the header the agent
endpoints read. It is also sent as a bearer token for one release, so a client
pinned to an older server keeps working; the bearer form goes away in 0.1.
"""

from __future__ import annotations
Expand Down Expand Up @@ -64,7 +68,7 @@ def __init__(
# it twice.
self.max_retries = max_retries

async def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
async def post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]:
last: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
Expand All @@ -91,24 +95,18 @@ def _unwrap(self, r: httpx.Response) -> dict[str, Any]:
body: dict[str, Any] = r.json()
return body

detail = ""
payload: dict[str, Any] = {}
try:
payload = r.json()
detail = str(payload.get("detail") or payload.get("message") or "")
except Exception:
detail = r.text[:300]
detail, fields = _read_error(r)

# 401 and 403 both mean the key was refused, and the reason decides
# what the caller can do about it. A missing scope is fixed on the key,
# a missing entitlement is not fixable by the caller at all, and
# anything else means the key itself is wrong.
if r.status_code in (401, 403):
code = str(payload.get("code") or "")
code = str(fields.get("code") or "")
if code == "missing_scope":
raise ScopeError(
needed=str(payload.get("needed") or "this operation"),
held=payload.get("scopes") or [],
needed=str(fields.get("needed") or "this operation"),
held=fields.get("scopes") or [],
)
if code == "not_entitled":
raise EntitlementError(
Expand All @@ -126,3 +124,28 @@ def _unwrap(self, r: httpx.Response) -> dict[str, Any]:

async def aclose(self) -> None:
await self._client.aclose()


def _read_error(r: httpx.Response) -> tuple[str, dict[str, Any]]:
"""The message and the structured fields of an error response.

FastAPI puts whatever the server raised under `detail`. That is a string
for a plain refusal and a dict for a coded one, so a `code` may sit either
at the top level or inside `detail`; both are read. A body that is not JSON
is quoted as the message, truncated.
"""
try:
payload = r.json()
except Exception:
return r.text[:300], {}
if not isinstance(payload, dict):
return str(payload)[:300], {}

detail = payload.get("detail")
fields: dict[str, Any] = dict(payload)
if isinstance(detail, dict):
fields.update(detail)
message = detail.get("message") or detail.get("detail") or detail.get("error")
else:
message = detail or payload.get("message") or payload.get("error")
return str(message or ""), fields
2 changes: 1 addition & 1 deletion src/deeptrust/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.1"
__version__ = "0.0.2"
36 changes: 34 additions & 2 deletions src/deeptrust/agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@
...

`Session.analyze` reviews the transcript and returns findings, and does not
block the agent. `Session.check` decides whether a single action may run and
does block; it is not implemented in this version.
block the agent. `Session.end` closes the call. `Session.check` decides
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.

`DeepTrust.watch` is for the hosted path: an organisation that connected its
ElevenLabs workspace in the DeepTrust dashboard can hand a live conversation
id to DeepTrust, which then holds the monitor socket itself.
"""

from __future__ import annotations
Expand Down Expand Up @@ -91,5 +96,32 @@ def session(
metadata=metadata or {},
)

async def watch(
self,
conversation_id: str,
*,
platform: str = "elevenlabs",
agent_id: str | None = None,
) -> bool:
"""Hand a live platform conversation to DeepTrust to monitor.

For the hosted path. The organisation must have connected `platform` in
the DeepTrust dashboard (Settings, Voice Agents); DeepTrust then opens
the monitor socket from its own side, so no platform key is needed
here. Call it as soon as the conversation id is known, for instance
from the `conversation_initiation_metadata` client event, and the
call is watched from its first turn instead of from the next poll.

Returns True when this request started the monitor and False when
DeepTrust was already watching the conversation. Raises `ServiceError`
with status 404 when the platform is not connected for the
organisation.
"""
d = await self._http.post(
f"/agents/conversations/{conversation_id}/watch",
{"platform": platform, "agent_id": agent_id},
)
return bool(d.get("started"))

async def aclose(self) -> None:
await self._http.aclose()
Loading
Loading