Skip to content

Commit 01f4b67

Browse files
feat(check): add the opt-in Vapi checks PR workflow and user docs
.github/workflows/vapi-checks.yml runs `npm run check` on pull requests (opened, synchronize, reopened, ready_for_review) and on manual dispatch, only when the repository variable VAPI_CHECKS_ENABLED is 'true' and a vapi-checks.yml exists — so on the upstream template it is dormant. - Live runs only for same-repository, non-Dependabot PRs and dispatch; forks and Dependabot get a keyless dry run, and secrets are passed only to live runs. Never pull_request_target. - Checks out the head SHA with full history (for --changed-since against origin/<base>) and persist-credentials: false. - --all posts the aggregate `Vapi Evals` status; dispatching one named check doesn't. The run step execs node so GitHub's cancel reaches it, inside a 22-minute budget under a 30-minute job timeout; concurrency cancels a superseded push's runs. - permissions: contents: read, statuses: write. Docs: a README "PR Checks" section (setup from test files to required status, the build-failure table, fork/Dependabot handling, CI orgs, cost and what stays real), the AGENTS.md simulations step, a "Inline PR Checks" section in docs/learnings/simulations.md, and improvements.md #34. Refs TEST-141 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e28e7d7 commit 01f4b67

6 files changed

Lines changed: 376 additions & 3 deletions

File tree

‎.github/workflows/vapi-checks.yml‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: Vapi checks
2+
3+
# Runs the simulation checks in vapi-checks.yml against the PR branch's own
4+
# files: each target is built inline and sent in one simulation run, so
5+
# nothing is deployed. Opt in with the repository variable
6+
# VAPI_CHECKS_ENABLED=true; see "PR checks" in the README.
7+
#
8+
# Never `pull_request_target`: the branch's code runs here, so it must only
9+
# ever get this repository's secrets when the branch is this repository's.
10+
11+
on:
12+
pull_request:
13+
types: [opened, synchronize, reopened, ready_for_review]
14+
workflow_dispatch:
15+
inputs:
16+
check:
17+
description: >-
18+
Check name from vapi-checks.yml. Leave blank to run every check and
19+
update the Vapi Evals status on the dispatched commit.
20+
required: false
21+
type: string
22+
23+
permissions:
24+
contents: read
25+
# Only to post the `Vapi Evals` commit statuses that link to each run.
26+
statuses: write
27+
28+
concurrency:
29+
group: vapi-checks-${{ github.event.pull_request.number || github.ref }}
30+
cancel-in-progress: true
31+
32+
jobs:
33+
vapi-checks:
34+
if: vars.VAPI_CHECKS_ENABLED == 'true'
35+
runs-on: ubuntu-latest
36+
# The run step's budget (22 min) leaves time to cancel runs and report.
37+
timeout-minutes: 30
38+
env:
39+
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
40+
41+
steps:
42+
- uses: actions/checkout@v4
43+
with:
44+
ref: ${{ github.event.pull_request.head.sha || github.sha }}
45+
fetch-depth: 0
46+
persist-credentials: false
47+
48+
- name: Look for vapi-checks.yml
49+
id: config
50+
shell: bash
51+
run: |
52+
if [[ -f vapi-checks.yml ]]; then
53+
echo "present=true" >> "$GITHUB_OUTPUT"
54+
else
55+
echo "No vapi-checks.yml at the repository root; nothing to check."
56+
echo "present=false" >> "$GITHUB_OUTPUT"
57+
fi
58+
59+
# Live runs only for this repository's own branches (not forks, not
60+
# Dependabot) and for manual dispatch. Everything else builds the
61+
# payloads without a key, and an affected PR gets an error status
62+
# until a maintainer dispatches the check on it.
63+
- name: Choose live or dry run
64+
id: mode
65+
if: steps.config.outputs.present == 'true'
66+
shell: bash
67+
env:
68+
EVENT: ${{ github.event_name }}
69+
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
70+
ACTOR: ${{ github.actor }}
71+
AUTHOR: ${{ github.event.pull_request.user.login }}
72+
run: |
73+
live=false
74+
if [[ "$EVENT" == "workflow_dispatch" ]]; then
75+
live=true
76+
elif [[ "$HEAD_REPO" == "$GITHUB_REPOSITORY" && "$ACTOR" != "dependabot[bot]" && "$AUTHOR" != "dependabot[bot]" ]]; then
77+
live=true
78+
fi
79+
echo "live=$live" >> "$GITHUB_OUTPUT"
80+
echo "Live run: $live"
81+
82+
- uses: actions/setup-node@v4
83+
if: steps.config.outputs.present == 'true'
84+
with:
85+
node-version: 22
86+
cache: npm
87+
88+
- run: npm ci
89+
if: steps.config.outputs.present == 'true'
90+
91+
- name: Run Vapi checks
92+
if: steps.config.outputs.present == 'true'
93+
shell: bash
94+
env:
95+
LIVE: ${{ steps.mode.outputs.live }}
96+
CHECK: ${{ inputs.check }}
97+
BASE_REF: ${{ github.base_ref }}
98+
GITHUB_TOKEN: ${{ github.token }}
99+
VAPI_CHECK_TOKENS: ${{ steps.mode.outputs.live == 'true' && secrets.VAPI_CHECK_TOKENS || '' }}
100+
VAPI_PRIVATE_API_KEY: ${{ steps.mode.outputs.live == 'true' && secrets.VAPI_PRIVATE_API_KEY || '' }}
101+
run: |
102+
set -euo pipefail
103+
args=()
104+
if [[ -n "$CHECK" ]]; then args+=("$CHECK"); else args+=(--all); fi
105+
if [[ "$LIVE" == "true" ]]; then
106+
args+=(--refresh-bindings)
107+
else
108+
args+=(--dry-run)
109+
fi
110+
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]] &&
111+
git rev-parse --verify --quiet "origin/$BASE_REF" > /dev/null; then
112+
args+=(--changed-since "origin/$BASE_REF")
113+
fi
114+
[[ -n "$VAPI_CHECK_TOKENS" ]] || unset VAPI_CHECK_TOKENS
115+
[[ -n "$VAPI_PRIVATE_API_KEY" ]] || unset VAPI_PRIVATE_API_KEY
116+
# exec, so GitHub's cancel signal (a newer push) reaches node,
117+
# which cancels its in-flight simulation runs.
118+
exec node --import tsx src/check-cmd.ts "${args[@]}" --budget-minutes 22

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,4 +1032,4 @@ When transferring to human:
10321032
2. Create scenarios (what the simulated caller says + evaluation criteria)
10331033
3. Create simulations (pair personality + scenario)
10341034
4. Create suites (batch simulations together)
1035-
5. Run via Vapi dashboard or API
1035+
5. Run against the deployed resources with `npm run sim`, or against the local files (nothing deployed) with `npm run check` — see "PR Checks" in the README. The PR workflow runs `npm run check` on every affected PR when `VAPI_CHECKS_ENABLED=true`

‎README.md‎

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,165 @@ from the destination without touching unrelated destination resources.
456456

457457
---
458458

459+
## PR Checks: simulations against your branch
460+
461+
`npm run check` runs your simulation suites against the **PR branch's own
462+
files** — prompts, tools, handoffs, structured outputs — without deploying
463+
anything. Each check target (an assistant or a squad) is built from
464+
`resources/<org>/` and sent inline, with its scenarios and judges, in one
465+
simulation run. Nothing is created in the org, so there is nothing to clean
466+
up, and it works with a single org.
467+
468+
### 1. Write tests
469+
470+
Under `resources/<org>/simulations/`:
471+
472+
- `personalities/calm-caller.yml` (or reference a stock personality by ID)
473+
- `scenarios/books-appointment.yml` — instructions, at least one **required
474+
text judge**, and `toolMocks` for the tools this scenario calls:
475+
476+
```yaml
477+
name: Books an appointment
478+
instructions: >
479+
You are John Smith calling to book a cleaning next Tuesday. End the call once it's confirmed.
480+
evaluations:
481+
- structuredOutput:
482+
name: booking-confirmed
483+
type: ai
484+
schema: { type: boolean, description: "Did the assistant confirm a booking time?" }
485+
comparator: "="
486+
value: true
487+
required: true
488+
toolMocks:
489+
- toolName: book_appointment
490+
result: '{"success": true, "time": "Tuesday 10:00"}'
491+
```
492+
493+
Judges may also reference a file with `structuredOutputId: <name>`. Over
494+
chat, yes/no judges (`=` with `value: true`) are the verified shape; don't
495+
use `hooks` or `messages-with-audio` judges with chat.
496+
- `tests/books-appointment-calm.yml`: `{ name, personalityId: calm-caller, scenarioId: books-appointment }`
497+
- `suites/core.yml`: `{ name: Core, simulationIds: [books-appointment-calm] }`
498+
499+
### 2. Configure the check
500+
501+
```bash
502+
cp vapi-checks.example.yml vapi-checks.yml
503+
```
504+
505+
```yaml
506+
version: 1
507+
checks:
508+
core:
509+
org: my-org
510+
targets: [squads/main-squad]
511+
suites: [core]
512+
```
513+
514+
### 3. Dry run locally (no key, nothing sent)
515+
516+
```bash
517+
npm run check -- core --dry-run --print-payload
518+
```
519+
520+
`tmp/check-payloads/` shows exactly what would be sent. The build fails,
521+
naming the field, when it can't make a safe, faithful payload:
522+
523+
| Problem | Fix |
524+
| --- | --- |
525+
| 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 |
526+
| A tool referenced by ID, or a `toolRefs` pin, with no file in `resources/<org>/tools/` | Pull the tool into gitops |
527+
| 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 |
528+
| A legacy `assistantDestinations` entry naming an assistant by ID | Convert it to a handoff tool |
529+
| Tools by ID inside `assistantOverrides`, `membersOverrides` or `targetOverrides` | Put them inline in `tools:append` |
530+
| Tools outside `model.tools` / `model.toolIds` (`model.functions`, reasoner skills, a recording-consent decline tool) | Move them to `model.tools` |
531+
| `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 |
532+
| Personality tools beyond `endCall`-style ones | Keep the personality free of side-effect tools |
533+
| Two tools with the same type and name on one assistant | Rename one |
534+
| Audio judges, scenario hooks, or no required text judge over chat | Add a text judge, or use `transport: voice` |
535+
536+
Two things to know:
537+
538+
- **Transfers never happen.** Every `transferCall` becomes a mocked
539+
function, so a scenario that needs a real transfer fails rather than
540+
falsely passing.
541+
- **Handoff names.** The check warns when a prompt mentions an
542+
auto-generated `handoff_to_…` name — those differ between inline and
543+
deployed assistants. Give that handoff an explicit `function.name`.
544+
545+
### 4. Live run locally
546+
547+
```bash
548+
npm run check -- core
549+
```
550+
551+
Uses `.env.<org>`, prints the run link, and exits 0 passed, 1 failed, 2
552+
config or build error, 3 incomplete. It uses simulation minutes.
553+
554+
### 5. Turn on the PR workflow
555+
556+
In GitHub → Settings → Secrets and variables → Actions:
557+
558+
- Secret `VAPI_PRIVATE_API_KEY` (single org), or `VAPI_CHECK_TOKENS` =
559+
`{"my-org":"<private key>"}` (several orgs, or a CI org).
560+
- Variable `VAPI_CHECKS_ENABLED=true`.
561+
562+
`.github/workflows/vapi-checks.yml` then runs every affected check on each
563+
PR push. It asks for `statuses: write` only to post the direct links.
564+
565+
### 6. What a PR shows
566+
567+
- `Vapi Evals`, plus `Vapi Evals / <check> / <target>` per target. **Details**
568+
opens the run in Vapi.
569+
- A job summary: per-target result, failing judges with expected vs actual,
570+
and any "unmocked tool called" notices. No PR comments.
571+
- PRs that touch neither a check's org, its state, `vapi-checks.yml`,
572+
`promotion.yml`, the engine (`src/**`, `package*.json`), nor the check's
573+
own `paths` skip it, and `Vapi Evals` posts success.
574+
- A newer push cancels the older run.
575+
576+
### 7. Make it required (after a burn-in)
577+
578+
Require the **commit status `Vapi Evals`** in branch protection — not the
579+
`vapi-checks` job (fork dry runs succeed) and not the per-target statuses
580+
(PRs that don't touch a check never get them).
581+
582+
- **Fork PRs** run a dry run without secrets and can't post statuses (the
583+
token is read-only), so a required `Vapi Evals` blocks them.
584+
- **Dependabot PRs** that change `package*.json` post `error`.
585+
- **To unblock either**, a maintainer runs Actions → Vapi checks → Run
586+
workflow on the PR's branch with `check` blank (push a fork's branch into
587+
the repository first). A later PR event on the same commit resets the
588+
status, so dispatch again after that. Running one named check by hand
589+
never changes `Vapi Evals`.
590+
591+
### Dedicated CI org (optional; recommended with `toolMocks: off`)
592+
593+
1. `npm run setup -- my-ci-org --resources none`.
594+
2. Create the credentials your agents need there, with the same names as
595+
production.
596+
3. `npm run pull -- my-ci-org --bootstrap --bindings-only`, then commit
597+
`.vapi-state.my-ci-org.json` so the state knows those credentials. The PR
598+
workflow refreshes bindings on every live run.
599+
4. Add `runOrg: my-ci-org` (and `baseUrl` for EU) to the check, and
600+
optionally `bindings:` (same shape as `promotion.yml`). Phone numbers are
601+
omitted by default.
602+
5. Put only the CI org's key in `VAPI_CHECK_TOKENS`.
603+
604+
### Cost and safety
605+
606+
- Every affected push starts paid runs; chat transport is the default.
607+
- Tool calls get their scenario mock or an error, and every assistant and
608+
function-tool server points at `https://vapi-gitops-ci.invalid`.
609+
- Still real in the run org: custom LLM, voice and transcriber servers see
610+
the conversation; org-wide and assistant monitors run; `observabilityPlan`
611+
exports transcripts; prompts are stored with the run. A CI org avoids all
612+
of these.
613+
- `.ts` resource files execute during the check, with the workflow's secrets
614+
on same-repository PRs.
615+
616+
---
617+
459618
## How to Use This Repo
460619

461620
1. **Run `npm run setup`** to configure your first org (or `npm run setup -- <org>` without a terminal)
@@ -812,7 +971,8 @@ vapi-gitops/
812971
│ ├── resources.ts # Resource loading (YAML, MD, TS)
813972
│ ├── resolver.ts # Reference resolution
814973
│ ├── credentials.ts # Credential resolution (name ↔ UUID)
815-
│ └── delete.ts # Deletion & orphan checks
974+
│ ├── delete.ts # Deletion & orphan checks
975+
│ └── check-cmd.ts # Entry point: PR simulation checks (check-*.ts)
816976
├── resources/
817977
│ └── <org>/ # One directory per configured org
818978
│ ├── assistants/
@@ -831,6 +991,7 @@ vapi-gitops/
831991
│ ├── path-matching.test.ts # Short-form path matching (P0-7 regression suite)
832992
│ ├── cleanup-safety.test.ts # --confirm + empty-state gates (P0-4 regression suite)
833993
│ └── cli-arg-parsing.test.ts # Bare-id refusal, --confirm pass-through (P0-7)
994+
├── vapi-checks.example.yml # Copy to vapi-checks.yml for PR simulation checks
834995
├── .env.<org> # Private API key per org (gitignored)
835996
└── .vapi-state.<org>.json # State file per org
836997
```

‎docs/learnings/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Gotchas and silent defaults for each resource type:
4444
| [assistants.md](assistants.md) | Model defaults, voice, transcriber, firstMessage, outbound modes, voicemailMessage, hooks, idle messages, endpointing, interruption, analysis, artifacts, background sound, server messages, HIPAA, tool resolution |
4545
| [squads.md](squads.md) | Name uniqueness, tools:append, assistantDestinations, handoff context, contextEngineeringPlan, VM detection relay pattern, override merge order, `membersOverrides` structuredDataPlan + fullMessageHistory |
4646
| [structured-outputs.md](structured-outputs.md) | Schema type gotchas, assistant_ids, default models, target modes, KPI patterns, squad `membersOverrides` vs standalone SOs |
47-
| [simulations.md](simulations.md) | Personalities, evaluation comparators, chat-mode gotcha, missing references, full `/eval/simulation/*` API reference |
47+
| [simulations.md](simulations.md) | Personalities, evaluation comparators, chat-mode gotcha, missing references, inline PR checks (`npm run check`), full `/eval/simulation/*` API reference |
4848
| [webhooks.md](webhooks.md) | Default server messages, timeouts, unreachable servers, credential resolution, payload shape |
4949
| [voice-providers.md](voice-providers.md) | Per-provider voice block layout (Cartesia vs 11labs vs OpenAI/Azure/Rime/LMNT/Minimax/Neuphonic/SmallestAI) — saves 400s at push time |
5050

‎docs/learnings/simulations.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,51 @@ Base URL: `https://api.vapi.ai`
227227

228228
---
229229

230+
## Inline PR Checks (`npm run check`)
231+
232+
`npm run check` sends the target and its tests **inline** in one
233+
`POST /eval/simulation/run` (`target.assistant` / `target.squad`, and
234+
`{type: "simulation", name, scenario, personality}` entries), built from the
235+
branch's files. Setup is in the README's "PR Checks" section; these are the
236+
behaviours worth knowing when a check surprises you.
237+
238+
- **Inline matches stored, with two known differences.** A 2026-10-01 parity
239+
run (TEST-141) scored inline and stored versions of the same squad 15/15
240+
each, with the same handoff and business-tool sequences. The differences:
241+
- **Generated handoff names:** `handoff_to_<assistantName>` inline vs
242+
`handoff_to_<uuid>` stored. A prompt or judge that names the generated
243+
function behaves differently — give the handoff an explicit
244+
`function.name`. The check warns when it sees `handoff_to_` in text.
245+
- **Tool order** decides which tool the model reaches for. The runtime
246+
puts `model.tools` first, then `toolIds` in order, then `toolRefs`; the
247+
check builds inline tools in that same order.
248+
- **Tool mocks fail closed.** Function tools are mocked by `function.name`,
249+
`apiRequest` tools by their top-level `name`. A tool the scenario doesn't
250+
mock answers `{"error":"vapi-gitops-ci: <tool> is not mocked in this
251+
scenario"}`, and the report lists it as "unmocked tool called". A scenario
252+
mock with `enabled: false` is replaced by that error.
253+
- **Hook-fired tools bypass scenario mocks.** Scenario `toolMocks` apply on
254+
the LLM tool-call path; tools fired from an assistant's `hooks[].do[]`
255+
probably don't consult them. Their safeguard is the dead server: expect an
256+
error result for them in transcripts, not a mock.
257+
- **Servers are replaced, never deleted.** Every assistant and function tool
258+
gets `server: https://vapi-gitops-ci.invalid` (1 s timeout) and
259+
`serverMessages: []`. A *deleted* server falls back to the phone number's
260+
or the org's server URL, which would leak the conversation.
261+
- **Transfers can't happen.** `transferCall` becomes a mocked dead-server
262+
function under the same name. A scenario that needs a real transfer fails.
263+
- **What still reaches real systems** in the run org: custom LLM, voice and
264+
transcriber providers, org-wide and assistant monitors, and
265+
`observabilityPlan` exports. Use a dedicated CI org (`runOrg`) to avoid
266+
them.
267+
- **Over chat**, `messages-with-audio` judges and scenario `hooks` don't
268+
work, and a scenario with no required text judge can't fail; the build
269+
refuses all three.
270+
- **Run items echo the scenario.** `metadata.scenario.toolMocks` includes the
271+
default error mocks whether or not a tool was called; read tool results
272+
from `metadata.call.messages` (`tool_calls` carry the ids and names,
273+
`tool_call_result` the answers by `toolCallId`).
274+
230275
## Simulations (`/eval/simulation`)
231276

232277
### Create simulation — `POST /eval/simulation`

0 commit comments

Comments
 (0)