npm run check runs your simulation suites against the PR branch's own
files — prompts, tools, handoffs, structured outputs — without deploying
anything. Each check target (an assistant or a squad) is built from
resources/<org>/ and sent inline, with its scenarios and judges, in one
simulation run. Nothing is created in the org, so there is nothing to clean
up, and it works with a single org.
Under resources/<org>/simulations/:
-
personalities/calm-caller.yml(or reference a stock personality by ID) -
scenarios/books-appointment.yml— instructions, at least one required text judge, andtoolMocksfor the tools this scenario calls:name: Books an appointment instructions: > You are John Smith calling to book a cleaning next Tuesday. End the call once it's confirmed. evaluations: - structuredOutput: name: booking-confirmed type: ai schema: { type: boolean, description: "Did the assistant confirm a booking time?" } comparator: "=" value: true required: true toolMocks: - toolName: book_appointment result: '{"success": true, "time": "Tuesday 10:00"}'
Judges may also reference a file with
structuredOutputId: <name>. Over chat, yes/no judges (=withvalue: true) are the verified shape; don't usehooksormessages-with-audiojudges with chat. -
tests/books-appointment-calm.yml:{ name, personalityId: calm-caller, scenarioId: books-appointment } -
suites/core.yml:{ name: Core, simulationIds: [books-appointment-calm] }
cp vapi-checks.example.yml vapi-checks.ymlversion: 1
checks:
core:
org: my-org
targets: [squads/main-squad]
suites: [core]npm run check -- core --dry-run --print-payloadtmp/check-payloads/ shows exactly what would be sent. The build fails,
naming the field, when it can't make a safe, faithful payload:
| Problem | Fix |
|---|---|
A tool that can't be mocked (SMS, MCP, code, integrations), or an apiRequest with no name |
Remove it, name the apiRequest, or set toolMocks: off with a dedicated CI org |
A tool referenced by ID, or a toolRefs pin, with no file in resources/<org>/tools/ |
Pull the tool into gitops |
A handoff leaving the squad (dynamic, another squad, a non-member), or an assistant target that hands off |
Make the target a squad of those assistants |
A legacy assistantDestinations entry naming an assistant by ID |
Convert it to a handoff tool |
Tools by ID inside assistantOverrides, membersOverrides or targetOverrides |
Put them inline in tools:append |
Tools outside model.tools / model.toolIds (model.functions, reasoner skills, a recording-consent decline tool) |
Move them to model.tools |
model.knowledgeBaseId, a custom knowledge base, or a knowledge base / query tool when the check runs in another org |
Use a knowledge-base tool in the same org |
Personality tools beyond endCall-style ones |
Keep the personality free of side-effect tools |
| Two tools with the same type and name on one assistant | Rename one |
| Audio judges, scenario hooks, or no required text judge over chat | Add a text judge, or use transport: voice |
Two things to know:
- Transfers never happen. Every
transferCallbecomes a mocked function, so a scenario that needs a real transfer fails rather than falsely passing. - Handoff names. The check warns when a prompt mentions an
auto-generated
handoff_to_…name — those differ between inline and deployed assistants. Give that handoff an explicitfunction.name.
npm run check -- coreUses .env.<org>, prints the run link, and exits 0 passed, 1 failed, 2
config or build error, 3 incomplete. It uses simulation minutes.
In GitHub → Settings → Secrets and variables → Actions:
- Secret
VAPI_PRIVATE_API_KEY(single org), orVAPI_CHECK_TOKENS={"my-org":"<private key>"}(several orgs, or a CI org). - Variable
VAPI_CHECKS_ENABLED=true.
.github/workflows/vapi-checks.yml then runs every affected check on each
PR push. It asks for statuses: write only to post the direct links.
Vapi Evals, plusVapi Evals / <check> / <target>per target. Details opens the run in Vapi.- A job summary: per-target result, failing judges with expected vs actual, and any "unmocked tool called" notices. No PR comments.
- PRs that touch neither a check's org, its state,
vapi-checks.yml,promotion.yml, the engine (src/**,package*.json), nor the check's ownpathsskip it, andVapi Evalsposts success. - A newer push cancels the older run.
- Separately, the Validate resources check (in
ci.yml) runsnpm run validateon every org, including resources no check targets. It's offline and runs whether or not PR checks are turned on.
Require the commit status Vapi Evals in branch protection — not the
vapi-checks job (fork dry runs succeed) and not the per-target statuses
(PRs that don't touch a check never get them).
- Fork PRs run a dry run without secrets and can't post statuses (the
token is read-only), so a required
Vapi Evalsblocks them. - Dependabot PRs that change
package*.jsonposterror. - To unblock either, a maintainer runs Actions → Vapi checks → Run
workflow on the PR's branch with
checkblank (push a fork's branch into the repository first). A later PR event on the same commit resets the status, so dispatch again after that. Running one named check by hand never changesVapi Evals.
Multi-org repos can require a check to pass in an org before anything is
promoted out of it: set orgs.<org>.check: <name> in promotion.yml (see
Check before promoting).
npm run setup -- my-ci-org --resources none.- Create the credentials your agents need there, with the same names as production.
npm run pull -- my-ci-org --bootstrap --bindings-only, then commit.vapi-state.my-ci-org.jsonso the state knows those credentials. The PR workflow refreshes bindings on every live run.- Add
runOrg: my-ci-org(andbaseUrlfor EU) to the check, and optionallybindings:(same shape aspromotion.yml). Phone numbers are omitted by default. - Put only the CI org's key in
VAPI_CHECK_TOKENS.
- Every affected push starts paid runs; chat transport is the default.
- Tool calls get their scenario mock or an error, and every assistant and
function-tool server points at
https://vapi-gitops-ci.invalid. - Still real in the run org: custom LLM, voice and transcriber servers see
the conversation; org-wide and assistant monitors run;
observabilityPlanexports transcripts; prompts are stored with the run. A CI org avoids all of these. .tsresource files execute during the check, with the workflow's secrets on same-repository PRs.