Non-obvious behaviors and silent defaults for Vapi simulations, test suites, and evaluations.
The simulation tester assistant is built from:
- Voice, model, and transcriber from
personality.assistant - Persona prompt from
personality.assistant.model.messages[0].content(only the first message) - Task instructions from
scenario.instructions - An endCall tool is always appended if not already present
Extra system messages beyond messages[0] are not included in the tester's prompt. The personality is primarily defined by that first system message.
When the same rubric needs to run against multiple personality variants in a sim suite, give EACH (rubric, personality) pair its own scenario file with a uniquely descriptive name — even if the rubric content is identical across them.
Why: the dashboard's run-history view displays scenarios by name, NOT by which personality drove the test. If 4 sims share a scenario named Acme Logistics Live Human Pickup Handling, all 4 result entries show identically in the suite-run sidebar — you can't tell which test was the "quick" pickup vs the "self-id" pickup vs the "question" pickup vs the "ambiguous-short" pickup without drilling into each item to see the personality. This makes failure investigation painful: every flickering test looks like the same test.
Recommendation: name each scenario as <base>-<personality-variant>-handling, with a descriptive name: field that calls out the personality being tested.
# resources/<env>/simulations/scenarios/acme-live-human-pickup-quick-handling.yml
name: Acme Logistics Live Human Pickup — Quick (bare hello)
evaluations: [...]# resources/<env>/simulations/scenarios/acme-live-human-pickup-self-id-handling.yml
name: Acme Logistics Live Human Pickup — Self-ID (driver introduces themselves)
evaluations: [...] # identical rubric content as above; only name differs# resources/<env>/simulations/scenarios/acme-live-human-pickup-question-handling.yml
name: Acme Logistics Live Human Pickup — Question (skeptical "who's calling?")
evaluations: [...] # sameEach test (sim) file then references its variant-specific scenario:
# resources/<env>/simulations/tests/acme-live-human-pickup-quick.yml
name: Acme Logistics Live Human Pickup - Quick
personalityId: live-human-pickup-quick-bot
scenarioId: acme-live-human-pickup-quick-handlingCost: scenario file duplication — each variant is a copy of the same rubric content with a different name: field. Cheap. The duplication is mechanical (you can clone the source scenario file 4-6 times with a one-line name: change each).
Pays for itself the first time you debug a failed run and need to identify which test variant flickered. Particularly important for LLM-as-judge sim suites where the per-evaluator results are subtle and the scenario name is your primary navigation handle.
Anti-pattern: putting one shared scenario behind N personality variants in the same suite. The dashboard sidebar shows N rows with identical scenario names, only distinguishable by clicking into each item to see the personality. Sim iteration time inflates because every failure investigation starts with "wait, which one was this?"
Cross-reference: this convention surfaced as friction during a customer voicemail-triage sim iteration. Original suites shipped with one shared scenario per group (4 live-pickup tests sharing one scenario, 6 voicemail-edge-cases sharing one scenario); split into per-personality scenarios mid-iteration. Worth shipping new suites in the per-personality form from day one.
Simulation evaluations support these comparators:
| Comparator | Supported types |
|---|---|
= |
string, number, boolean |
!= |
string, number, boolean |
> |
number only |
< |
number only |
>= |
number only |
<= |
number only |
Critical: Evaluation schemas must use primitive types only (string, number, integer, boolean). Objects and arrays are rejected at validation and fail at extraction time.
Note: Test suite scorers (live tests) use a different system — an LLM judge with rubric grading, not comparator-based matching.
Evaluations that require audio (target: messages-with-audio) are skipped in chat-mode simulations with passed: false and isSkipped: true.
Critical: If all required evaluations are audio-dependent and the simulation runs in chat mode, they're all skipped — and the run is treated as passed (0 required results = pass). This means your test suite can silently pass with no actual evaluation.
Recommendation: Include at least one text-based (target: messages) evaluation in every simulation that runs in chat mode.
- At run creation (API): Missing scenario or personality IDs fail the API request immediately with a validation error.
- At execution: Missing references fail the run. Retries depend on platform configuration.
- Inline runs: Require snapshots in metadata or fail with a "no inline config" error.
Simulations can target squads directly using target.type: "squad" with the squad ID. Use the unified POST /eval/simulation/run endpoint and pass the suite via simulations[]:
curl -X POST "https://api.vapi.ai/eval/simulation/run" \
-H "Authorization: Bearer $VAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"simulations": [{"type": "simulationSuite", "simulationSuiteId": "your-suite-id"}],
"target": {"type": "squad", "squadId": "your-squad-id"},
"transport": {"provider": "vapi.websocket"},
"iterations": 3
}'The legacy
POST /eval/simulation/suite/{suiteId}/runroute was replaced by this unified runner — see the API reference at the bottom of this doc for the full payload shape.
Voice mode (vapi.websocket) exercises the full STT/TTS pipeline — use for latency testing and realistic end-to-end validation. Chat mode (vapi.webchat) is faster and cheaper — use for rapid iteration on outcome evaluations.
Squad simulations test the full stack: all tools attached to squad members (KB lookups, logging, email tools) actually fire during the simulation. If a tool endpoint is down, the simulation produces different results. Factor this into test design.
Run the same simulation suite against two squad variants and compare results:
- Create both squad variants as separate resources (e.g., Squad A vs Squad B)
- Run the same suite against each with identical iterations
- Compare pass/fail rates on structured output evaluations
- For latency comparison, use voice mode and analyze
end-of-call-reportwebhook data from each run
Limitation: Simulations evaluate outcomes via structured output comparators, not platform-level metrics. There is no built-in p95 latency or dead-air measurement. For quantitative latency data, pipe end-of-call-report webhooks to your own analytics and compute metrics from artifact.performanceMetrics.
Scenario evaluations reference structured outputs either by structuredOutputId (existing resource) or inline via structuredOutput. The key constraint: evaluation schemas must use primitive types (string, number, integer, boolean).
If your post-call analytics structured output uses type: object with nested schemas, you cannot use it directly as a scenario evaluation. Instead, create separate primitive-typed structured outputs for each evaluation criterion (e.g., eval-call-outcome as type: string, eval-goal-achieved as type: boolean). The full analytics output can still run via the squad's artifactPlan.structuredOutputIds — it just can't be used with comparators.
Simulation resource files use placeholder UUIDs (a0000000) locally. After the first push, the gitops engine creates platform resources and maps local filenames to platform UUIDs in .vapi-state.<env>.json. The local filename is a stable handle and is not rewritten to match the platform name — once a UUID is tracked in state, pull and bootstrap preserve the existing filename and only update file content. (Older engine versions renamed files to match the platform name and emitted name_mismatch warnings; that behavior was removed — see improvements.md #22.)
When a sim suite uses LLM-as-judge evaluators (descriptive structured-output rubrics graded by a judge LLM reading the call transcript), several platform-internal transcript shapes routinely confuse the judge into reporting failures on calls where the audio is clean. These artifacts are invisible to a human listener but preserved in the canonical messagesOpenAIFormatted transcript that the judge reads.
If your rubric grades anything related to handoffs or "did the assistant emit text," you must explicitly tell the judge about these artifacts in the rubric description, or accept ambient false negatives.
What actually happens: OpenAI chat-completions models periodically emit a non-empty content field on the SAME assistant turn that carries tool_calls: [...]. This happens even when the system prompt explicitly forbids it ("Output is a single tool call or empty content. No prose, ever.") — the behavior is RLHF-driven and no prompt engineering reliably bulletproofs it.
Vapi's TTS pipeline correctly suppresses speaking this content (so the audio is clean), but the dual-emitted text is preserved verbatim in messagesOpenAIFormatted. An LLM judge reading the transcript sees both the content AND the tool_calls and naturally interprets the content as text the assistant emitted before/alongside the tool call.
Recommendation: In any rubric description that grades "did the assistant emit text before the tool call," explicitly clarify:
Content emitted in the SAME turn as the
tool_callsfield (OpenAI dual-emission, where one turn has both non-emptycontentandtool_calls: [...]) is NOT spoken by the TTS pipeline. It is a transcript-only artifact and does NOT count as text-before-tool-call. The relevant question is: did the assistant emit any STANDALONE text turn (a turn with non-empty content and NOtool_callsfield) BEFORE the tool_call turn?
The structurally correct fix is at the OpenAI API layer — passing tool_choice: "required" forbids content emission entirely. As of this writing the parameter is not exposed on the Vapi assistant config schema; track via your customer's improvements.md if relevant and link to the platform request when filing.
What actually happens: When a squad handoff fires and the handoff tool has a request-start message (the spoken opening line, see squads.md → VM detection relay pattern), Vapi delivers that message via the SOURCE assistant's TTS pipeline — not the destination's. In the canonical transcript, the request-start text appears as a role: assistant content turn AFTER the source's tool_call turn, attributed to the source.
A passing transcript for a squad handoff looks like:
[user]: Hello.
[assistant (source) tool_calls: handoff_to_<destination>]: <empty content>
[tool]: "No handoff destination returned." ← platform success signal (see below)
[assistant (source) content]: "<request-start opener text>" ← attributed to source
[user]: <response>
[assistant (destination)]: <continuation turn — discovery question, brief ack, etc.>
An LLM judge with a rubric like "did the source emit any text before/alongside the handoff?" naturally interprets the request-start as the source speaking, even though the platform delivered it through the source's voice as part of the handoff mechanism.
Recommendation: In any rubric description that grades source-assistant text emission around the handoff, explicitly clarify:
The handoff tool's
request-startmessage is delivered via the SOURCE assistant's TTS pipeline, so it appears in the transcript as arole: assistantcontent turn AFTER the source's tool_call. This single occurrence is the EXPECTED, CORRECT delivery — count it as the one allowed opener (platform-delivered, not source-generated), not as a violation.
What you might expect: A tool-result string that reads "no destination returned" sounds like an error.
What actually happens: The platform emits the literal string "No handoff destination returned" as the source assistant's tool-result whenever a squad handoff fires successfully and control transfers to the destination. The English-language reading is the opposite of its semantic meaning — it indicates the handoff WORKED, not that it failed.
LLM judges (and humans skim-reading transcripts) routinely interpret this as a handoff failure, leading to dashboard narrative claims like "the source assistant never invoked the handoff tool" when the handoff worked perfectly.
Recommendation: In any rubric description grading "did the handoff fire," explicitly clarify:
A
toolresult with the literal string "No handoff destination returned" is the platform's STANDARD success signal for a squad handoff (control was transferred to the destination assistant). It is NOT an error. Treat it as positive confirmation that the handoff fired.
The three artifacts above share a common shape: the audio is clean (the customer hears the right thing), but the transcript JSON has artifacts the LLM judge interprets as failures. Prompt-tightening on the source assistant alone won't close the gap when the divergence is at the platform-transcript layer.
Recommendation: When debugging "the audio sounds good but the rubric fails," pull the raw messagesOpenAIFormatted for one failing call (via GET /eval/simulation/run/:id/item/:itemId → metadata.call.messages) before assuming the assistant misbehaved. The judge's narrative description in the dashboard will tell you which evaluator failed, but the structured transcript view tells you why. Cross-reference: squads.md → Request-start transcript attribution and destination prompt context for the handoff-side of the same architectural fact.
All simulation endpoints are alpha-tier (mounted at /api-alpha in Swagger, ApiTags(..., AlphaTag)), require Bearer auth (private API key OR org JWT), and are scoped to the caller's organization.
Base URL: https://api.vapi.ai
| Resource | Base path | Purpose |
|---|---|---|
| Simulation | /eval/simulation |
Pairs a scenario + personality for reuse |
| Scenario | /eval/simulation/scenario |
What the tester does + how it's evaluated |
| Personality | /eval/simulation/personality |
The tester assistant config (voice, model, persona) |
| Suite | /eval/simulation/suite |
A named bundle of simulations |
| Run | /eval/simulation/run |
Execute simulations/suites (batch); also exposes run items |
| Scenario Generator | /eval/simulation/scenario/generate |
AI-generated scenarios from an assistant/squad |
Folder paths: every resource type accepts an optional
path(max 255 chars, 1–3 lowercase segments separated by/, e.g.clients/acme). Maps to the GitOps folder layout. Set tonullon update to remove.
Response 201 — full Simulation (id, orgId, scenarioId, personalityId, name, path, createdAt, updatedAt).
Query params:
| Param | Type | Notes |
|---|---|---|
limit / page |
int | Triggers paginated response when present |
idAny |
csv of uuids | Filter to specific simulation IDs |
standaloneOnly |
bool | Only simulations not in a suite |
Returns Simulation[] (or { data, total, limit, page } paginated shape if pagination params are passed).
GET /eval/simulation/:id→SimulationPATCH /eval/simulation/:id→Simulation(body: any ofname,scenarioId,personalityId,path)DELETE /eval/simulation/:id→Simulation
Returns the org's voice-simulation concurrency budget:
{
"orgId": "uuid",
"concurrencyLimit": 20, // total call slots; voice sims use 2 each (tester + target)
"activeSimulations": 6, // call slots in use right now
"availableToStart": 7, // (concurrencyLimit - activeSimulations) / 2
"createdAt": "2026-04-01T...",
"updatedAt": "2026-04-15T...",
"isDefault": true // true if no override row → using platform default
}Voice simulations consume two call slots each (one for the tester, one for the target). Chat-mode simulations don't pull from voice concurrency in the same way — see existing gotchas section above.
{
"name": "Health Enrollment - Eligible Path", // required, max 80
"instructions": "You are calling to enroll...", // required, max 10000
"evaluations": [ // required, min 1
{
"structuredOutputId": "uuid", // OR structuredOutput inline (exactly one)
"comparator": "=", // = != > < >= <=
"value": true, // string | number | boolean
"required": true // optional, default true
}
],
"hooks": [ // optional; voice sims only
{ "on": "call.started", /* ... */ },
{ "on": "call.ended", /* ... */ }
],
"targetOverrides": { // optional AssistantOverrides
"variableValues": { "customerName": "Alice" }
},
"toolMocks": [ // optional
{ "toolName": "lookupAccount", "result": "...", "enabled": true }
],
"path": "clients/acme"
}Evaluation rules (enforced server-side):
- Inline
structuredOutput.schema.typeMUST be a primitive:string,number,integer,boolean. Objects and arrays are rejected. comparatorallowed values depend on schema type: booleans/strings only support=and!=; numbers/integers support all six.- Exactly one of
structuredOutputIdorstructuredOutputper evaluation item.
Response 201 — full Scenario.
GET /eval/simulation/scenario(query:limit,page,idAny,name)GET /eval/simulation/scenario/:idPATCH /eval/simulation/scenario/:id(any subset of the create fields)DELETE /eval/simulation/scenario/:id
{
"name": "Confused Carl", // required, max 80
"assistant": { /* CreateAssistantDTO */ }, // required — full assistant config
"path": "personas/confused"
}Only
assistant.model.messages[0].content, plus voice/model/transcriber, is used for the tester. Extra system messages are ignored. AnendCalltool is auto-appended if missing. (See "How Personalities Work" above.)
Response 201 — full Personality. Note orgId may be null for Vapi-provided defaults that are visible to all orgs.
GET /eval/simulation/personalityGET /eval/simulation/personality/:idPATCH /eval/simulation/personality/:id— body fields all optional:name,assistant,pathDELETE /eval/simulation/personality/:id
What actually happens: Every Vapi org is auto-seeded with 7 stock simulation personalities — confused-carl, decisive-derek, emotional-eva, impatient-irene, multitasking-maya, rambling-roger, skeptical-sam — with predictable UUIDs of the form a0000000-0000-4000-8000-00000000000<n>. Their orgId is null (the "Vapi-provided defaults" referenced above). The platform refuses to delete them: DELETE /eval/simulation/personality/:id returns HTTP 404 with the error string Personality <uuid> not found or is a Vapi default (cannot be deleted).
Why it matters for gitops: in a freshly-scaffolded org (no local simulations/personalities/ directory), the engine sees these 7 personalities as orphans on every push — "exist on the dashboard, not in local files." Without --force this is just log noise. With --force, the push halts on the FIRST delete attempt against an immortal default and exits non-zero, before reaching any legitimate orphans you actually wanted to clean.
Also note: .vapi-ignore is now bidirectional (symmetric on pull, push, and orphan-detect). Adding simulations/personalities/** to .vapi-ignore quiets future pulls from materializing the defaults locally AND orphan-protects them: ignored ids are excluded from the "pending deletions" sweep and a 🚫 personalities/<id> retained (matched .vapi-ignore — orphan-protected) line is emitted instead. --force does NOT override this — the orphan-protect always wins. A resource that references an ignored resource is a hard validation error; --strict push aborts before any API call.
Recommendations:
- Skip
--forceagainst fresh orgs that haven't had their stock fixtures touched. The pending-deletions warning is harmless without--force. - To clean other orphans (real assistants, tools you removed locally) without tripping on defaults, delete them via direct API call instead of
--force:Thencurl -X DELETE -H "Authorization: Bearer $VAPI_TOKEN" \ https://api.vapi.ai/assistant/<orphan-uuid>
npm run pull -- <org> --bootstrapto refresh state. This bypasses the engine's "delete all orphans" loop entirely. - Possible engine improvement: the orphan-deletion sweep could skip resources whose
orgId === null(the canonical "Vapi default" marker) or maintain a hardcoded UUID skiplist matchinga0000000-0000-4000-8000-00000000000<n>. Until that lands, the workarounds above are the operational path.
{
"name": "Checkout Flow Tests", // required, max 80
"simulationIds": ["uuid", "uuid"], // required
"slackWebhookUrl": "https://hooks.slack.com/...", // optional
"path": "clients/acme"
}Response 201 — full SimulationSuite (privileged fields + simulationIds).
GET /eval/simulation/suite(query:limit,page)GET /eval/simulation/suite/:idPATCH /eval/simulation/suite/:id—simulationIdsis replace semantics (not merge)DELETE /eval/simulation/suite/:id
This is the unified executor. A "run" is a batch — it expands into many runItems (one per simulation × iteration).
{
"simulations": [
// Mode A: existing simulation
{ "type": "simulation", "simulationId": "uuid" },
// Mode B: existing scenario + inline personality (mix-and-match)
{
"type": "simulation",
"scenarioId": "uuid",
"personality": { /* CreatePersonalityDTO */ },
"name": "Optional name"
},
// Mode C: fully inline
{
"type": "simulation",
"scenario": { /* CreateScenarioDTO */ },
"personality": { /* CreatePersonalityDTO */ }
},
// Mode D: expand a suite
{ "type": "simulationSuite", "simulationSuiteId": "uuid" }
],
"target": {
// discriminated by type
"type": "assistant", // OR "squad"
"assistantId": "uuid" // OR provide inline `assistant` (CreateAssistantDTO)
},
"iterations": 3, // optional, default 1
"transport": {
"provider": "vapi.websocket" // OR "vapi.webchat"
}
}Per-entry constraints:
- For each of
scenario/personality: pass either the ID or the inline object — never both. - If
simulationIdis set, the inline /scenarioId/personalityIdfields are ignored. simulationSuiteentries are expanded server-side into one entry per simulation in the suite.
Transport behavior:
vapi.websocket→ full voice STT/TTS pipeline (use for latency, realistic eval)vapi.webchat→ text-only (faster, cheaper); audio-only evaluations are auto-skipped (see chat-mode gotcha)
Response 201 — SimulationRun:
{
"id": "uuid",
"orgId": "uuid",
"status": "queued", // queued | running | ended
"simulations": [...], // echoed back
"target": {...},
"iterations": 3,
"transport": { "provider": "vapi.websocket" },
"queuedAt": "2026-04-27T...",
"startedAt": "2026-04-27T...", // optional
"endedAt": "2026-04-27T...", // optional
"endedReason": "completed", // optional
"itemCounts": { // aggregate of run items
"total": 9, "passed": 7, "failed": 1,
"running": 0, "queued": 0, "canceled": 1
},
"createdAt": "...",
"updatedAt": "..."
}Query params:
| Param | Type | Notes |
|---|---|---|
limit / page |
int | Pagination |
status |
enum | queued / running / ended |
filterStatus |
enum | Aggregate result: passed / failed / running |
targetType |
enum | assistant / squad |
targetId |
uuid | Filter to runs against this target |
GET /eval/simulation/run/:id→SimulationRunPATCH /eval/simulation/run/:id→ cancels the run and all its queued items. No body required.
Run items are system-managed — there's no create/update API for users; they're produced when a run starts.
Query params: limit, page, simulationId, runId, status (queued | running | evaluating | passed | failed | canceled).
Response — SimulationRunItem is rich; the highlights:
{
"id": "uuid",
"orgId": "uuid",
"runId": "uuid",
"simulationId": "uuid",
"scenarioId": "uuid", // resolved at run creation
"personalityId": "uuid", // resolved at run creation
"callId": "uuid", // the call placed against the target
"sessionId": "uuid", // present for chat-mode runs
"status": "passed", // queued | running | evaluating | passed | failed | canceled
"iterationNumber": 2, // 1-indexed
"queuedAt": "...", "startedAt": "...", "completedAt": "...",
"failedAt": "...", "canceledAt": "...",
"failureReason": "string up to 2000 chars",
"configurations": { "transport": { "provider": "vapi.websocket" } },
"metadata": {
"assistant": { /* snapshot at run-creation time */ },
"squad": { /* if target was a squad */ },
"scenario": { /* snapshot */ },
"personality": { /* snapshot */ },
"simulation": { /* snapshot */ },
"call": {
"transcript": "string",
"messages": [/* OpenAI-format messages */],
"recordingUrl": "https://...",
"monitor": { "listenUrl": "wss://..." } // live-listen during run
},
"hooks": { /* per-event hook execution state */ }
},
"results": {
"passed": true,
"evaluations": [
{
"structuredOutputId": "uuid" /* or "inline" */,
"name": "goal-achieved",
"comparator": "=",
"expectedValue": true,
"extractedValue": true,
"passed": true,
"required": true,
"error": "string", // optional, on extraction failure
"isSkipped": false,
"skipReason": "string" // e.g. "audio-only eval in chat mode"
}
],
"latencyMetrics": {
"turnCount": 12,
"avgTurn": 1850, "avgTranscriber": 320, "avgModel": 850,
"avgVoice": 410, "avgEndpointing": 270 // all milliseconds
}
},
"improvementSuggestions": { /* see generate endpoint below */ },
"hooks": [/* call.started / call.ended hook configs */],
"createdAt": "...", "updatedAt": "..."
}Snapshots are immutable. Editing the source scenario/personality after a run does NOT change historical run items — debug against the snapshot in
metadata.
Validates the item belongs to the run, then cancels the item. Returns the updated SimulationRunItem.
AI-generates suggestions for failed items. Cached per item; pass ?force=true to regenerate.
Response:
{
"analysis": "Why the evaluations failed (summary)",
"systemPromptSuggestions": [
{ "issue": "Agent didn't confirm identity", "suggestion": "Add a verification step..." }
],
"toolSuggestions": [
{ "issue": "...", "suggestion": "..." }
],
"scenarioSuggestions": [
{ "issue": "...", "suggestion": "..." }
],
"suggestedSystemPrompt": "Full revised prompt if major changes are needed" // optional
}Provide exactly one of assistantId or squadId:
{ "assistantId": "uuid" }
// OR
{ "squadId": "uuid" }Response 201:
{
"scenarios": [
{
"name": "Short descriptive name",
"instructions": "Tester instructions",
"category": "happy_path", // happy_path | edge_case | failure_mode
"reasoning": "Why this scenario is valuable"
}
],
"coverageNotes": "Summary of test coverage"
}The generator returns scenario drafts — they are not persisted. Take the output and
POSTto/eval/simulation/scenarioto save the ones you want.
Throws
400if neitherassistantIdnorsquadIdis supplied;404if the referenced target doesn't exist in the caller's org. Also subject to a feature-flag gate (simulationEnsureEnabled) —403if simulations aren't enabled for the org.
- Bearer token: any private API key, or an org-scoped JWT.
- CASL subjects used for authz:
- Read/create/update/delete simulation, scenario, personality, suite →
TEST_SUITE - Read/create/update run + run items →
TEST_SUITE_RUN
- Read/create/update/delete simulation, scenario, personality, suite →
- 403 if the org doesn't have simulations enabled (feature flag).
- 404 for any cross-org access attempt — the service filters by
orgIdfromRequestContext, so foreign IDs look the same as missing IDs. - 400 on validation: missing required fields, non-primitive eval schemas,
simulationIdand inline scenario together, etc.
{ "name": "Eligible Path with Confused User", // optional, max 80 "scenarioId": "uuid", // required "personalityId": "uuid", // required "path": "clients/acme" // optional }