Skip to content

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

Merged
buildbyjithu merged 5 commits into
mainfrom
devin/1789241109-vapi-adapter
Sep 15, 2026
Merged

buildbyjithu merged 5 commits into
mainfrom
devin/1789241109-vapi-adapter

Conversation

@amanmibra

@amanmibra amanmibra commented Sep 12, 2026 •

Copy link
Copy Markdown
Member

Summary

src/agents/vapi.ts — the TypeScript mirror of deeptrust.agents.vapi in 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 as monitor.controlUrl. So a handler, not a watcher:

const bridge = new Bridge(new DeepTrust(), { apiKey: process.env.VAPI_API_KEY! });

app.post("/vapi/webhook", async (req, res) => {
  await bridge.handle(req.body, { user: caller });   // every event; it decides
  res.json({});
});

handle unwraps { message: {...} }, learns the control URL off any event carrying the call object, appends final transcripts, analyses caller turns only, and POSTs each nudge as addMessageCommand(nudge.render()):

{"type": "add-message",
 "message": {"role": "system", "content": "…"},
 "triggerResponseEnabled": true}

Worth stating, since the code alone does not say why:

  • triggerResponseEnabled: true is 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 a say, so the agent's persona carries the nudge.
  • Control URL resolution is per call, not per call-creation: payload first, else 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 no monitor, so sendNudge resolves false instead of throwing inside the customer's route.
  • Final transcripts only (transcriptType === "final"), so a sentence is not re-analysed once per partial. monitor.listenUrl ignored (raw PCM); tool-calls deliberately unanswered — blocking an action is Session.check, which this release does not implement.
  • Divergence from Python, and why: BridgeOptions.fetch exists as an injection seam, matching MonitorOptions.connect in agents/elevenlabs.ts and HttpOptions.fetch. Python needs none because its tests intercept httpx with respx. Behaviour is identical.
  • "./agents/vapi" added to the exports map alongside ./agents/livekit, vapi to keywords; tsc emits dist/agents/vapi.{js,d.ts} with no config change.

Tests extend test/adapters.test.mjs with 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-report ending the session, a hung-up call taking no nudge, and non-transcript events starting nothing. npm run check passes. README section added.

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

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

Copy link
Copy Markdown

🤖 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

⚙️ 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...)

Comment thread src/agents/vapi.ts Fixed

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

Copy link
Copy Markdown

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 src/agents/vapi.ts
Comment on lines +149 to +158
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Concurrent events share mutable session state

Concurrent handle calls for one call can overlap Session.analyze on the same transcript. Verify VAPI ordering guarantees or serialize work per call.

Devin Review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/agents/vapi.ts
Comment on lines +163 to +166
if (this.deliver) {
for (const nudge of result.nudges) {
await this.sendNudge(callId, nudge);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Control delivery failures remain invisible

handle ignores sendNudge returning false and returns the analysis normally. Confirm whether integrations need failure reporting or delivery retries.

Devin Review

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Comment thread src/agents/vapi.ts
Comment thread src/agents/vapi.ts Outdated
devin-ai-integration Bot and others added 2 commits September 12, 2026 19:30
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
@buildbyjithu
buildbyjithu merged commit 72c670b into main Sep 15, 2026
1 check 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.

4 participants