Skip to content

feat(vapi): webhook bridge and control-URL nudges - #4

Merged
buildbyjithu merged 2 commits into
mainfrom
devin/1789240942-vapi-bridge
Sep 15, 2026
Merged

buildbyjithu merged 2 commits into
mainfrom
devin/1789240942-vapi-bridge

Conversation

@amanmibra

@amanmibra amanmibra commented Sep 12, 2026

Copy link
Copy Markdown
Member

Summary

deeptrust.agents.vapi.Bridge — a VAPI adapter for the customer's own webhook route.

VAPI's transport is the mirror image of ElevenLabs', so the shape is not Monitor. Nobody holds a socket: VAPI posts server-url events to the customer's server, and nudges go back on a per-call HTTPS endpoint VAPI mints for that call and publishes as monitor.controlUrl. So this is a handler, not a watcher:

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

@app.post("/vapi/webhook")
async def vapi_webhook(payload: dict):
    await bridge.handle(payload, user=caller)   # every event; it decides
    return {}

handle unwraps {"message": {...}}, learns the control URL off any event that carries the call object, appends final transcripts, analyses caller turns only, and POSTs each nudge:

{"type": "add-message",
 "message": {"role": "system", "content": nudge.render()},
 "triggerResponseEnabled": true}

Points worth stating, since the code alone does not say why:

  • triggerResponseEnabled: true is an interrupt. VAPI delivery behaves like LiveKit's, not like ElevenLabs' next-turn contextual update. Documented in the module docstring, matching what both existing adapters do for their own semantics. A system message rather than a say, so the agent's persona carries the nudge instead of speaking our words verbatim.
  • Control URL resolution is per call, not per call-creation. From the payload when present, else GET /call/{id}, then cached for the call. Inbound is the case this exists for: nobody placed the call, so there is no creation-time response to have captured a URL from. A finished call publishes no monitor, so a nudge from its last turn returns False rather than raising inside the customer's webhook route.
  • Final transcripts only. VAPI emits a transcript event per partial as the sentence is still being recognised; analysing those re-analyses the same sentence, the class of bug the LiveKit adapter's last dict guards against.
  • monitor.listenUrl ignored (raw PCM), and tool-calls deliberately unanswered — blocking an action is Session.check, which is separate work.
  • No new dependency and no vapi extra: httpx is already core. vapi added to keywords; a comment in [project.optional-dependencies] records why there is no extra.

Tests drive a fake webhook sequence through respx (no network): caller-turn-only analysis, exact control-URL body, no credential on the control request, the inbound fetch-once-and-cache path, partials starting no jobs, end-of-call-report ending the session, a hung-up call taking no nudge, and non-transcript events (including tool-calls) starting nothing. README section added alongside LiveKit and ElevenLabs.

Ships with

VAPI end to end across four repos:

Link to Devin session: https://app.devin.ai/sessions/5e32e135868e4b1b8e70f552a1d46aa2
Open in Devin Desktop: https://app.devin.ai/desktop/session/5e32e135868e4b1b8e70f552a1d46aa2?variant=devin
Requested by: @amanmibra


Devin Review

…call

Co-Authored-By: Aman Ibrahim <aman@deeptrust.ai>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

Original prompt from Aman Ibrahim

Ship VAPI support end to end across FOUR repos in this one session. All four are under the deeptrust-ai GitHub org. Open a separate PR per repo, all against main, and say in each PR body which other PRs it belongs with.

Order matters — do them in this sequence, because later repos depend on decisions made in earlier ones.

=== REPO 1: deeptrust (this one) — backend ===
a) MIGRATION: add 'vapi' to the SQL enum agent_delivery_target_enum from components/deeptrust/database/supabase/migrations/20260908160214_create_org_agent_delivery.sql. New migration file; do not edit the existing one. Mirror the existing migration test under test/components/deeptrust/database/supabase/migrations/.
b) ENUM: add VAPI = 'vapi' to AgentDeliveryTargetEnum in components/deeptrust/types/agent_delivery.py. Its docstring currently explains that LIVEKIT and ELEVENLABS are the platform-specific targets where we hold the org credential and push over the platform's own channel — VAPI is the same kind, so extend that prose rather than leaving it describing two.
c) DELIVERY: new components/deeptrust/agent_delivery/vapi.py modelled on its elevenlabs.py and livekit.py siblings. Push a nudge to the call's control URL:
POST {controlUrl} {"type":"add-message","message":{"role":"system","content":nudge.render()},"triggerResponseEnabled":true}
controlUrl lives on the VAPI call object at monitor.controlUrl; fetch via GET /call/{Calls.platform_call_id}. The org's VAPI key comes from org_agent_delivery, Fernet-decrypted under MCP_KEY_ENCRYPTION_SECRET exactly as existing targets do.
Honour the module's three stated rules: org-scoped reads, voice-agent calls only, never fatal (return DeliveryOutcome, never raise).
d) RESOLUTION: wire 'vapi' into the platform-string -> delivery-target mapping in components/deeptrust/agent_delivery/core.py. Calls.platform is free text; the existing code maps explicitly rather than coercing — follow that exactly.
e) Tests including the not-fatal path (VAPI 5xx must not... (5263 chars truncated...)

@devin-ai-integration devin-ai-integration Bot left a comment

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.

Devin Review found 4 potential issues.

2 bugs not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment thread tests/test_adapters.py
Comment on lines +306 to +370
@respx.mock
async def test_vapi_nudges_the_live_call_over_the_control_url() -> None:
analyze = respx.post(f"{BASE}/agents/analyze").mock(
return_value=httpx.Response(200, json=ONE_NUDGE)
)
control = respx.post("https://vapi.example/control/call_1").mock(
return_value=httpx.Response(200, json={})
)
bridge = Bridge(DeepTrust(api_key="dt_test", base_url=BASE), api_key="vapi_test")

await bridge.handle(
_transcript_event(
"assistant",
"IT desk, how can I help?",
control_url="https://vapi.example/control/call_1",
)
)
await bridge.handle(
_transcript_event("user", "my colleague is telling me what to say")
)

# One job, for the one caller turn; the agent's own turn costs nothing.
assert analyze.call_count == 1
assert control.call_count == 1
assert json.loads(control.calls[0].request.read()) == add_message_command(
"The caller referred to someone else on the line. "
"Ask one question and wait: is anyone helping them right now?"
)
# The control URL is a capability of its own; the private key is not sent
# to a host VAPI chose for us.
assert "authorization" not in control.calls[0].request.headers

call = bridge.session("call_1")
assert call is not None and call.platform == "vapi"
assert call.external_id == "call_1"
assert len(call.transcript) == 2


@respx.mock
async def test_vapi_fetches_the_control_url_when_the_event_lacks_one() -> None:
"""The inbound case. Nobody placed the call, so there was no
call-creation response to capture a URL from."""
respx.post(f"{BASE}/agents/analyze").mock(
return_value=httpx.Response(200, json=ONE_NUDGE)
)
lookup = respx.get("https://api.vapi.ai/call/call_1").mock(
return_value=httpx.Response(
200,
json={
"id": "call_1",
"monitor": {"controlUrl": "https://vapi.example/control/call_1"},
},
)
)
control = respx.post("https://vapi.example/control/call_1").mock(
return_value=httpx.Response(200, json={})
)
bridge = Bridge(DeepTrust(api_key="dt_test", base_url=BASE), api_key="vapi_test")

await bridge.handle(_transcript_event("user", "I'm locked out, skip the checks"))
await bridge.handle(_transcript_event("user", "and my manager already approved it"))

assert control.call_count == 2
# Fetched once and remembered: the URL belongs to the call, not the nudge.
assert lookup.call_count == 1

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 error behavior lacks coverage

The tests omit transport failures, malformed success bodies, and VAPI error statuses. Repository rules require adapter coverage for every error status and exception.

Devin Review

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

Comment on lines +167 to +202
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:
response = await client.get(f"/call/{call_id}")
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

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.

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

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.

Comment on lines +127 to +130
message = _message(payload)
call = message.get("call")
call = call if isinstance(call, dict) else {}
call_id = str(call.get("id") or "")

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.

Co-Authored-By: Aman Ibrahim <aman@deeptrust.ai>
@buildbyjithu
buildbyjithu merged commit 18e9c10 into main Sep 15, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants