feat(vapi): webhook bridge and control-URL nudges - #4
Conversation
…call Co-Authored-By: Aman Ibrahim <aman@deeptrust.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
Original prompt from Aman Ibrahim
|
There was a problem hiding this comment.
Devin Review found 4 potential issues.
2 bugs not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)
| @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 |
There was a problem hiding this comment.
| 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 |
| url = _monitor_control_url(call) | ||
| if url: | ||
| self._control[call_id] = url |
| message = _message(payload) | ||
| call = message.get("call") | ||
| call = call if isinstance(call, dict) else {} | ||
| call_id = str(call.get("id") or "") |
Co-Authored-By: Aman Ibrahim <aman@deeptrust.ai>
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 asmonitor.controlUrl. So this is a handler, not a watcher:handleunwraps{"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: trueis 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 asay, so the agent's persona carries the nudge instead of speaking our words verbatim.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 nomonitor, so a nudge from its last turn returnsFalserather than raising inside the customer's webhook route.transcriptevent per partial as the sentence is still being recognised; analysing those re-analyses the same sentence, the class of bug the LiveKit adapter'slastdict guards against.monitor.listenUrlignored (raw PCM), andtool-callsdeliberately unanswered — blocking an action isSession.check, which is separate work.vapiextra:httpxis already core.vapiadded 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-reportending the session, a hung-up call taking no nudge, and non-transcript events (includingtool-calls) starting nothing. README section added alongside LiveKit and ElevenLabs.Ships with
VAPI end to end across four repos:
platform="vapi"and the delivery target this matchesLink 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