skills/vapi/SKILL.md
--- name: vapi description: Wire VAPI (vapi.ai) voice agents into a TanStack Start + Cloudflare Workers app, and drive its REST API. Covers the public-vs-private key split, the @vapi-ai/web browser SDK (in-app voice) and @vapi-ai/server-sdk (Workers-safe), creating assistants + custom function tools, the tool-call webhook contract (request shape, the { results: [{ toolCallId, result }] } response, X-Vapi-Secret verification in a Worker), outbound calls, and phone numbers. Use when the user wants
npx skillsauth add RonanCodes/ronan-skills skills/vapiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Wire VAPI into a TanStack Start + Cloudflare Workers app, or call its REST API directly. VAPI runs voice agents ("assistants") that orchestrate transcriber + LLM + TTS and call custom function tools back to your own server. Voice is a transport: the assistant decides intent and calls a tool; your app does the real work and returns a spoken result.
For Souso this is issue #17 (voice replan): the assistant calls tools that wrap the existing replan-server / shopping-server / staples-server fns. See docs/PRD-vapi.md in the smart-cart repo for the full design (webhook at /api/vapi/tool, identity by caller phone, X-Vapi-Secret).
/ro:vapi install # env + server client + (optional) web SDK
/ro:vapi add-web-voice # @vapi-ai/web in-app "talk to it" button (public key)
/ro:vapi add-assistant # create/update an assistant via REST (private key)
/ro:vapi add-tool-webhook # the /api/vapi/tool endpoint: verify secret + dispatch + reply
/ro:vapi add-outbound # outbound call from a phone number
/ro:vapi ops # API-ops cheat-sheet (curl), no app wiring
~/.claude/.env (megathon block): VAPI_PUBLIC_KEY, VAPI_PRIVATE_KEY (currently empty — fill it; the pasted value duplicated the public key), VAPI_ASSISTANT_ID, VAPI_ORG_ID.| Key | Where | Use |
| --- | --- | --- |
| Public (VAPI_PUBLIC_KEY) | browser, @vapi-ai/web | start in-app voice calls. Safe to ship. CANNOT call the REST API. |
| Private (VAPI_PRIVATE_KEY) | server only, REST + @vapi-ai/server-sdk | Bearer token, full account access. Never ship to the browser. |
Base URL https://api.vapi.ai, REST auth Authorization: Bearer $VAPI_PRIVATE_KEY.
| Resource | Method + path | Purpose |
| --- | --- | --- |
| Assistants | POST /assistant / GET /assistant / GET /assistant/{id} / PATCH /assistant/{id} | CRUD assistants |
| Tools | POST /tool / GET /tool | custom function tools |
| Calls | POST /call / GET /call | outbound call / list (filter assistantId, phoneNumberId) |
| Phone numbers | GET /phone-number / POST /phone-number | list / import a number |
Verified directly:
POST /assistant,GET /assistant,POST /call,GET /call./tooland/phone-numberfollow VAPI's singular-resource convention + SDK resource names (high confidence, not quoted verbatim).
Create an assistant:
curl -X POST https://api.vapi.ai/assistant -H "Authorization: Bearer $VAPI_PRIVATE_KEY" \
-H "Content-Type: application/json" -d '{
"name":"Souso Helper",
"firstMessage":"Hi, how can I change your week?",
"transcriber":{"provider":"deepgram","model":"nova-3","language":"multi"},
"model":{"provider":"anthropic","model":"claude-haiku-4-5-20251001",
"messages":[{"role":"system","content":"You help update a weekly meal plan."}]},
"voice":{"provider":"vapi","voiceId":"Elliot"},
"server":{"url":"https://souso.app/api/vapi/tool","secret":"<VAPI_SERVER_SECRET>"}
}'
# .dev.vars (gitignored)
VAPI_PRIVATE_KEY=... # server only
VAPI_SERVER_SECRET=... # the X-Vapi-Secret you set on the assistant
VITE_VAPI_PUBLIC_KEY=... # browser, for @vapi-ai/web
VITE_VAPI_ASSISTANT_ID=...
wrangler secret put VAPI_PRIVATE_KEY
wrangler secret put VAPI_SERVER_SECRET
VITE_-prefixed vars are the only ones safe in the client bundle (public key + assistant id).
pnpm add @vapi-ai/server-sdk
The server SDK is fetch-based and lists Cloudflare Workers as supported. Construct it inside the handler with the env binding, not at module top level:
// src/lib/vapi-server.ts
import { createServerFn } from '@tanstack/react-start'
export const listVapiCalls = createServerFn({ method: 'GET' }).handler(async () => {
const { VapiClient } = await import('@vapi-ai/server-sdk')
const { env } = await import('cloudflare:workers')
const client = new VapiClient({ token: env.VAPI_PRIVATE_KEY })
return client.calls.list()
})
In-app "talk to it" using the PUBLIC key. Create the Vapi instance once, bind events in useEffect, clean up on unmount.
pnpm add @vapi-ai/web
import Vapi from '@vapi-ai/web'
import { useEffect, useRef, useState } from 'react'
export function VoiceButton() {
const vapi = useRef<Vapi | null>(null)
const [live, setLive] = useState(false)
useEffect(() => {
const v = new Vapi(import.meta.env.VITE_VAPI_PUBLIC_KEY)
vapi.current = v
v.on('call-start', () => setLive(true))
v.on('call-end', () => setLive(false))
v.on('message', (m) => console.log('[vapi]', m)) // transcripts + tool messages
v.on('error', (e) => console.error('[vapi]', e))
return () => v.stop()
}, [])
return (
<button onClick={() => (live ? vapi.current?.stop() : vapi.current?.start(import.meta.env.VITE_VAPI_ASSISTANT_ID))}>
{live ? 'Stop' : 'Talk to Souso'}
</button>
)
}
The tool webhook is a server-to-server call from VAPI; it does NOT carry your app's session cookie. So for an in-app embedded call you can't read "who's signed in" in the webhook. Bind identity at call start instead:
vapi.start, call a server fn that mints a short-lived signed token (JWT/HMAC, a few-minute TTL) for the signed-in user/household.vapi.start(assistantId, { metadata: { token } }).call.metadata.token, verify the signature server-side, and derive the user/household from it. Never trust an id sent in tool arguments (client-set, spoofable); only the signed token is authoritative.The exact path VAPI echoes start-time metadata into the webhook (
call.metadatavscall.assistantOverrides.metadatavs top-level) varies by version. Log the first real payload and read defensively. The pattern (signed token in, verify out) is sound regardless.
This replaces phone-number lookup for in-app calls (no phone field / SMS needed). Use phone lookup only for the true phone-call channel.
When the assistant calls a custom tool, VAPI POSTs to the tool's (or assistant's) server.url. Your handler verifies the secret, dispatches, and returns a string result.
// src/routes/api/vapi/tool.ts (TanStack server route)
export const Route = createFileRoute('/api/vapi/tool')({
server: { handlers: { POST: async ({ request }) => {
const { env } = await import('cloudflare:workers')
// 1. verify shared secret (timing-safe), no Bearer prefix
const got = request.headers.get('X-Vapi-Secret') ?? ''
if (!safeEqual(got, env.VAPI_SERVER_SECRET ?? '')) {
return new Response('unauthorized', { status: 401 })
}
const body = await request.json()
const msg = body.message ?? {}
// 2. defensive: VAPI versions differ — toolCallList vs toolCalls, flat vs .function
const calls = msg.toolCallList ?? msg.toolCalls ?? []
const phone = msg.call?.customer?.number ?? body.call?.customer?.number
const results = []
for (const c of calls) {
const name = c.name ?? c.function?.name
const args = c.arguments ?? c.function?.arguments ?? {}
const result = await dispatch(name, args, phone, env) // wraps replan-server etc.
results.push({ toolCallId: c.id, result: String(result) }) // result MUST be a string
}
// 3. ALWAYS return 200 with { results: [...] }
return Response.json({ results })
}}},
})
Webhook contract (hard rules):
toolCallId must exactly match the request id, or the result is dropped.result (and error) must be a string (stringify objects yourself).error string). Non-200 is ignored entirely.Timing-safe compare in a Worker:
function safeEqual(a: string, b: string) {
const x = new TextEncoder().encode(a), y = new TextEncoder().encode(b)
if (x.byteLength !== y.byteLength) return false
return crypto.subtle.timingSafeEqual ? crypto.subtle.timingSafeEqual(x, y) : a === b
}
Securing options: X-Vapi-Secret shared secret (simplest, above); Bearer credential; or HMAC (recompute crypto.subtle.sign('HMAC', key, rawBody) over the raw body before any JSON parse). For HMAC, read the raw bytes first.
curl -X POST https://api.vapi.ai/call -H "Authorization: Bearer $VAPI_PRIVATE_KEY" \
-H "Content-Type: application/json" \
-d '{"assistantId":"'$VAPI_ASSISTANT_ID'","phoneNumberId":"<id>","customer":{"number":"+31..."}}'
replan_week, add_items, generate_cart → wrap existing server fns; identity from call.customer.number.@vapi-ai/web (no phone number needed).result a string, status always 200, toolCallId matched.toolCallList vs toolCalls, nested .function vs flat, message.call vs top-level call). Log the first real payload and read defensively (the snippet above does)./phone-number and /tool exact paths are by-convention, not quoted from the reference; confirm against a live response if it matters.VAPI_PRIVATE_KEY= # server only (REST)
VAPI_SERVER_SECRET= # X-Vapi-Secret for the tool webhook
VITE_VAPI_PUBLIC_KEY= # browser (@vapi-ai/web)
VITE_VAPI_ASSISTANT_ID=
VAPI_ORG_ID=
Private key is full account access, secret only. Verify X-Vapi-Secret on every tool call and derive identity (e.g. household) server-side from the caller number, never from tool arguments (a caller can't be allowed to pick another account). The assistant must never claim success a tool did not confirm.
@vapi-ai/web, @vapi-ai/server-sdksmart-cart/docs/PRD-vapi.md (issue #17). Sibling voice/notify: /ro:tts-elevenlabs, /ro:telegramtesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".