I wrote a guide for Null Studio on switching voice AI providers without dropping calls. Its core advice is simple: before you migrate, sort everything into two piles. What you own moves with you. What you rent gets rebuilt.

It is good advice for a business owner. Then I asked the engineer version of the question about my own product. VoiceDash is a white-label portal that sits on top of Retell and Vapi, so if a client ever wants to move their agents from one to the other, the portal is the thing that has to survive the move. So which pile is my schema in?

I opened the Prisma schema and the routes that write to it and sorted every field. Here is what I found.

The enum is the easy part

The Agent model has a platform field:

enum Platform { RETELL VAPI ELEVENLABS } 

Three values. That looks like a portable design until you grep for what each one actually does. ELEVENLABS appears in exactly two places in src: a function that picks a badge colour and a function that returns a display label. There is no webhook route for it, no sync, no import. It is a promise in the type system that the code does not keep.

That is not a disaster. It is a placeholder I added early, and nobody can connect an ElevenLabs agent through the UI, so it never bites. But it is a good warning about reading a schema as evidence of portability. An enum value costs one line. Supporting a vendor costs a webhook parser, a backfill, a config mapper and a test call.

What the portal owns: the calls

The part that is genuinely mine is the call history. Both vendors' webhooks write into the same Conversation table:

model Conversation { agentId String platformCallId String startedAt DateTime duration Float @default(0) endReason String? transcript Json @default("[]") recordingUrl String? summary String? evaluation String? // ... @@unique([agentId, platformCallId]) } 

The transcript is normalised on the way in. The Retell route keeps only agent and user turns from transcript_object. The Vapi route takes artifact.messages, drops the tool_calls entries and maps assistant to agent. Either way, the portal stores the same shape: a list of { role, content }.

The summary and evaluation are mine too, in the sense that they come from VoiceDash's own analyze route rather than a vendor's post-call analysis feature. The analyze route only needs the stored transcript, so it does not care which vendor produced the call. So if a client moves an agent from Retell to Vapi, their old calls stay readable, searchable and summarised in the same screens.

One catch I only saw while writing this: today only the Retell webhook triggers that analyze call. The Vapi route stores the call and stops. The design is portable; the wiring is not finished. A migrated client would keep their old summaries and quietly stop getting new ones.

That was the mostly reassuring half.

What only looks owned: two columns

Two columns in that table are in the right place but hold the wrong thing.

endReason stores the vendor's vocabulary. Retell's route writes call.disconnection_reason || call.call_status. Vapi's writes call.endedReason. Both are free strings from two different lists. The column is shared, but the meaning is not, so a client who switches mid-month gets a chart of end reasons where the same outcome has two names depending on the date. If I ever want "calls that ended because the caller hung up" across a migration, I need a mapping table I have not written. I covered the general version of this problem in merging two voice APIs into one table; this is the same lesson showing up in a column I thought was finished.

recordingUrl stores a link, not a recording. Both routes save the vendor's URL (recording_url or scrubbed_recording_url from Retell, artifact.recordingUrl from Vapi). The audio itself lives with the vendor. Leave the vendor and those links eventually point at nothing. The row survives, the player in the transcript panel does not. My guide tells businesses to export recordings before they give notice; my own schema would make that export a separate job, not something the portal already has. I went deeper on this in storing Retell recordings for client portals.

What the vendor owns: almost the whole agent

This was the uncomfortable part.

The agent configuration screen in VoiceDash saves this to my database when you click Publish:

const agentConfig = { language, model, voice, systemPrompt, firstMessage }; 

Five fields. Everything else on that screen (speech settings, transcription, call settings, post-call analysis fields, functions, MCPs) lives in Retell. And the five fields only land in my database after someone publishes once. Before that, for a Retell agent, the screen reads the live config straight from Retell to fill the form. For a Vapi agent there is no equivalent read, so the form starts empty.

Then the sync route pushes those fields out to the vendor. For Retell:

if (config.systemPrompt) body.general_prompt = config.systemPrompt; if (config.firstMessage) body.begin_message = config.firstMessage; if (config.voice) body.voice_id = config.voice; 

For Vapi it builds a different body (model.messages, firstMessage, voice.voiceId). Three fields go out, not five. language and model are saved locally and never sent.

So if I put the guide's two piles on my own schema:

Moves with the client Rebuilt on the new vendor
Call history, normalised transcripts Functions and tool definitions
Summaries and evaluations Post-call analysis fields
System prompt and first message (once published) Speech, transcription and call settings
Tags, notes Voice (the ID means nothing to another vendor)
Knowledge base
Recordings, unless exported

The prompt is the most valuable thing on that list and it is in the left column, which is good. But the tools are where the agent actually does work (booking, lookups, transfers) and they are entirely in the right column. My portal is a good record of what the agent said. It is not a copy of what the agent is.

The bug the audit found on the way

While reading the sync route I noticed it never checks the response:

await fetch(`https://api.retellai.com/update-agent/${agent.platformAgentId}`, { method: "PATCH", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, body: JSON.stringify(body), }); // ... return NextResponse.json({ success: true }); 

fetch only throws on a network failure. If the vendor answers with a 400 or a 401, the route still returns { success: true }, and the Publish button in the UI does not read the sync response anyway. So the local copy and the live agent can drift apart with no message to anyone. The prompt in my database says one thing, the agent on the phone line says another, and the portal looks fine.

That matters for portability in a specific way. The whole value of keeping a local copy of the prompt is that it is the source of truth you migrate from. A local copy that can silently diverge from production is not a source of truth. It is a draft that might match.

The fix is small: check res.ok, return the vendor's error, and show it next to the Publish button. I have not shipped it yet; it is first on the list.

What I would change

None of this needs a rewrite. In order of value:

  1. Make sync honest. Check the response and surface failures. Cheapest change, and it turns the local prompt back into something you can trust.
  2. Store the tool definitions locally. Functions are the part of an agent that is hardest to rebuild from memory. Even a stored JSON snapshot taken on every publish, in the vendor's own format, is a far better starting point than reading it off a dashboard on the day you leave.
  3. Normalise endReason into my own small set (caller hung up, agent ended, transferred, voicemail, error) and keep the vendor string in metadata. Reports then survive a switch.
  4. Copy recordings into storage the workspace owns, with the retention rules that apply to that client, instead of storing a link that expires with the account.
  5. Trigger analysis from the Vapi webhook too, so summaries keep coming after a switch.
  6. Delete ELEVENLABS from the enum until there is code behind it, or write the code.

The general lesson

"Keep business knowledge outside the platform" is the line in my guide that sounds most obvious. Doing the audit taught me what it means in practice: for every field that defines your agent, ask which database is the source of truth, and whether anything checks that the two agree.

If you are building on top of a voice platform, try the two-pile exercise on your own schema. It took me about an hour and found one silent-failure bug, one half-wired feature, one dead enum value and a fairly clear answer to "could a client leave?" The answer is: with their history, yes. With their agent, partly.

The original, less code-heavy version of the migration guide is on nullstud.io, with the step-by-step order for moving phone lines without dropping calls.

Have you done this kind of audit on something you built on a vendor API? I am curious which field surprised you most.