feat(vapi): webhook bridge and control-URL nudges - #2
Conversation
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:
⚙️ 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)
| const dtCall = this.callSession(callId, options.user); | ||
| dtCall.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 null; | ||
| } | ||
|
|
||
| const result = await dtCall.analyze(); |
There was a problem hiding this comment.
Not changing this, deliberately.
The unit of state is one call, and VAPI delivers a call's events to one webhook URL in order; the customer's route awaits handle before responding. Two final transcripts for the same call overlapping means the customer's own server ran them concurrently — and append is a synchronous push, so the worst case is one analysis seeing a transcript one turn longer than the other, not corrupted state. Different calls never share a Session.
Serialising per call would also change the product: a queue makes a nudge arrive after the turn it was for, and a nudge is only worth delivering while the caller is still on that turn.
The Python adapter is the reference for this file and has the same shape, so a lock here would be a silent behavioural divergence between the two SDKs rather than a fix.
| if (this.deliver) { | ||
| for (const nudge of result.nudges) { | ||
| await this.sendNudge(callId, nudge); | ||
| } |
There was a problem hiding this comment.
Intended, and I'd keep it.
handle runs inside the customer's webhook route, and the most common reason a nudge has nowhere to go is that the call already hung up — VAPI drops monitor from a finished call. Throwing or failing the response there would turn a normal end-of-call race into a 500 on their server for no gain: the finding is already recorded on the DeepTrust session, and the call the nudge was for is over.
Failure is observable for anyone who wants it: sendNudge is public and returns the boolean, and onAnalysis fires before delivery. Retries are the wrong shape for this specific payload — an add-message with triggerResponseEnabled interrupts the agent, so a retry that lands two turns later interrupts a different conversation.
Same behaviour in the Python reference (send_nudge returns False), which is the contract the two SDKs are held to.
Co-Authored-By: Aman Ibrahim <aman@deeptrust.ai>
Co-Authored-By: Aman Ibrahim <aman@deeptrust.ai>
The bridge took any POST. A customer's webhook is a public URL, so anyone who learned it could post a transcript that was never said and have it become a real call, a real analysis and a real finding in their organization. A forged end-of-call-report could also end a real call's session early. VAPI already sends server.secret back in X-Vapi-Secret on every request. A `secret` option now turns that into a check: pass the request headers to handle, and a request without it raises WebhookVerificationError before a turn is appended or a session is created. The compare is constant time, hand-rolled rather than crypto.timingSafeEqual so the module stays runtime-agnostic across Node, Bun, Deno and the edge. Headers are read case-insensitively from a Headers instance or a plain object, because every server hands them over differently. Verification is off when no secret is configured, so an existing integration keeps working. The README says plainly what that costs, and the example receiver now reads VAPI_WEBHOOK_SECRET, answers 401, and prints verify=OFF at boot when it is unset. Proven against the running example: a forged POST with no header is 401, a wrong secret is 401, the right secret is 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verify the VAPI webhook, so a forged transcript is refused
Summary
src/agents/vapi.ts— the TypeScript mirror ofdeeptrust.agents.vapiin the Python SDK, which is the reference for the shape.VAPI's transport is the mirror image of ElevenLabs', so this is not a
Monitor. Nobody holds a socket: VAPI posts server-url events to the customer's server, and nudges go back on the per-call HTTPS endpoint VAPI publishes asmonitor.controlUrl. So a handler, not a watcher:handleunwraps{ message: {...} }, learns the control URL off any event carrying the call object, appends final transcripts, analyses caller turns only, and POSTs each nudge asaddMessageCommand(nudge.render()):{"type": "add-message", "message": {"role": "system", "content": "…"}, "triggerResponseEnabled": true}Worth stating, since the code alone does not say why:
triggerResponseEnabled: trueis an interrupt, so VAPI behaves like the LiveKit adapter, not like ElevenLabs' next-turn contextual update — documented in the module docstring the way both existing adapters document their own semantics. A system message rather than asay, so the agent's persona carries the nudge.GET /call/{id}, then cached. Inbound is the case this exists for — nobody placed the call, so nothing captured a URL at creation. A hung-up call publishes nomonitor, sosendNudgeresolvesfalseinstead of throwing inside the customer's route.transcriptType === "final"), so a sentence is not re-analysed once per partial.monitor.listenUrlignored (raw PCM);tool-callsdeliberately unanswered — blocking an action isSession.check, which this release does not implement.BridgeOptions.fetchexists as an injection seam, matchingMonitorOptions.connectinagents/elevenlabs.tsandHttpOptions.fetch. Python needs none because its tests intercepthttpxwithrespx. Behaviour is identical."./agents/vapi"added to the exports map alongside./agents/livekit,vapito keywords;tscemitsdist/agents/vapi.{js,d.ts}with no config change.Tests extend
test/adapters.test.mjswith a fake webhook sequence and a fake VAPI (FakeVapi, no network): caller-turn-only analysis, exact control-URL body, no credential on the control request, fetch-once-and-cache on the inbound path, partials starting no jobs,end-of-call-reportending the session, a hung-up call taking no nudge, and non-transcript events starting nothing.npm run checkpasses. README section added.Ships with
VAPI end to end across four repos:
platform="vapi"and the delivery targetLink 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