Examples
Integrate Currai with Vapi
Capture Vapi browser calls as Currai sessions, readable conversation turns, and nested model events.
This workflow adds Currai capture to an existing browser voice application using Vapi. Vapi continues to own microphone audio, transcription, model execution, and playback. Currai receives the completed conversation and provider trace from your server.
To run the complete implementation first, use the public Vapi example on GitHub:
git clone git@github.com:curraiapp/examples.git
cd examples/vapi
cp .env.example .env.local
pnpm install
pnpm devWhat you need
- a Vapi account and assistant;
- a Vapi public key for browser calls;
@vapi-ai/webinstalled in your application;- a Currai workspace and ingestion key;
- a server route in the same application;
- HTTPS or
localhostfor browser microphone access.
If Vapi is not installed yet:
pnpm add @vapi-ai/webRecommended: connect Vapi with the Currai Skill
Install the published Currai Skill from the root of your application repository:
npx skills add https://github.com/curraiapp/skills --skill curraiAdd the Currai credentials described in step 1 to the server environment before asking the coding agent to make changes. Then paste this prompt:
Use $currai to connect this Vapi browser voice application to Currai using the
CURRAI_PUBLIC_KEY, CURRAI_SECRET_KEY, and CURRAI_BASE_URL already configured in
the server environment. Find the component that owns the live @vapi-ai/web call,
not only an assistant-creation route. Capture finalized transcript messages once,
keep partial transcripts display-only, and emit vapi.conversation.turn events with
nested vapi.model events. Keep capture best-effort and server-side. Restart or
deploy the application, complete one real Vapi call, and verify its readable User
Story and nested trace in Currai. Never print or copy credentials into source code
or chat.For this integration, the skill should detect the @vapi-ai/web client and prioritize its live message, error, and call-end handlers. It generates native HTTP capture, uses one UUID session for the complete call, waits for session creation before sending events, ignores duplicate or partial transcript evidence, redacts sensitive fields, and prevents Currai failures from interrupting Vapi.
The work is complete only after a real browser call appears in Observe → Events, the conversation is readable in Analyse → User Stories, and each vapi.model event is nested beneath the correct vapi.conversation.turn. A simple connectivity event is not sufficient.
The remaining steps show the same workflow manually. Use them to review, troubleshoot, or customize what the skill adds.
1. Configure credentials
Add the Vapi public key to the browser environment and the Currai keys to the server environment:
NEXT_PUBLIC_VAPI_PUBLIC_KEY=<your-vapi-public-key>
CURRAI_PUBLIC_KEY=<your-currai-public-key>
CURRAI_SECRET_KEY=<your-currai-secret-key>
CURRAI_BASE_URL=https://www.currai.appIf your application creates or updates Vapi assistants through its own server, also configure:
VAPI_PRIVATE_KEY=<your-vapi-private-key>The Vapi public key is intentionally available to the browser. The Vapi private key and Currai credentials must never enter client code or an API response.
Restart or redeploy after changing the environment.
2. Establish a working Vapi call
Prove the call works before adding capture:
import Vapi from "@vapi-ai/web";
const vapi = new Vapi(process.env.NEXT_PUBLIC_VAPI_PUBLIC_KEY!);
await vapi.start(assistantId);Complete one short browser conversation. Confirm the call connects, microphone input works, agent audio plays, transcript events arrive, and the call appears in Vapi's dashboard.
Do not debug provider audio and Currai capture at the same time. Start from a known-working call boundary.
3. Define the capture context
Create one context when the user starts a call:
type CapturedTurn = {
role: "user" | "assistant";
text: string;
agentEventId: string;
modelEventId: string;
};
type VoiceCapture = {
sessionId: string;
userId: string;
boundaryEventId: string;
assistantId: string;
callId?: string;
startedAt: number;
endedAt?: number;
success: boolean;
error?: string;
transcript: CapturedTurn[];
};Initialize it before calling vapi.start:
const capture: VoiceCapture = {
sessionId: crypto.randomUUID(),
userId: getCurrentOrAnonymousUserId(),
boundaryEventId: crypto.randomUUID(),
assistantId,
startedAt: Date.now(),
success: true,
transcript: [],
};Use your existing privacy-reviewed product user ID when possible. For an anonymous application, persist a random UUID. Do not derive identity from an IP address, transcript, prompt, or credential.
4. Store the Vapi call ID
Capture the provider call ID when Vapi returns it:
vapi.on("call-start-success", (event) => {
if (event.callId) capture.callId = event.callId;
});Depending on the SDK version, vapi.start may also return the call:
const call = await vapi.start(assistantId);
if (call?.id) capture.callId = call.id;Store the call ID as provider metadata. It links the Currai session to Vapi's call log when investigating audio or provider failures.
5. Capture finalized transcript messages
Vapi emits both partial and final transcript messages. Partial text changes while the speaker is talking. Display partial text in the interface, but do not persist it as separate conversation evidence.
vapi.on("message", (message) => {
if (message.type !== "transcript") return;
const role = message.role === "assistant" ? "assistant" : "user";
const text = message.transcript?.trim();
if (!text) return;
if (message.transcriptType === "partial") {
setPartialTranscript({ role, text });
return;
}
const previous = capture.transcript.at(-1);
if (previous?.role === role && previous.text === text) return;
capture.transcript.push({
role,
text,
agentEventId: crypto.randomUUID(),
modelEventId: crypto.randomUUID(),
});
setPartialTranscript(null);
});Without the partial check, one sentence can become several captured messages such as “change,” “change my,” and “change my delivery address.”
6. Record call failures
Provider failures are part of the trace:
vapi.on("call-start-failed", (event) => {
capture.success = false;
capture.error = safeErrorMessage(event.error);
void uploadCapture(capture);
});
vapi.on("message", (message) => {
const endedReason = readEndedReason(message);
if (!endedReason) return;
capture.success = false;
capture.error = safeEndedReason(endedReason);
});Never include raw authorization values, complete provider objects, or sensitive diagnostic data in the error field.
7. Upload once at call end
When Vapi emits call-end, freeze the completed payload and post it to your server:
vapi.on("call-end", () => {
capture.endedAt = Date.now();
void uploadCapture(capture);
});async function uploadCapture(capture: VoiceCapture) {
const payload = {
...capture,
endedAt: capture.endedAt ?? Date.now(),
};
for (const delay of [0, 500, 1_500]) {
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
try {
const response = await fetch("/api/currai/voice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
});
if (response.ok) return true;
if (response.status < 500 && response.status !== 429) return false;
} catch {
// Currai capture must never interrupt the Vapi call.
}
}
return false;
}Create payload once and reuse it. Do not generate fresh event IDs inside the retry loop.
8. Validate and normalize on the server
Your /api/currai/voice route should reject malformed browser input before it reaches Currai. Validate:
- a request size limit, such as 64 KB;
- UUIDs for session and event identities;
- a bounded assistant and call ID;
- valid timestamps and boolean success state;
- no more than a reasonable number of transcript entries;
- only
userandassistantroles; - bounded, non-empty text;
- the absence of secrets, tokens, cookies, and authorization fields.
Coalesce consecutive fragments from the same speaker, then pair a user turn with the following assistant response. Preserve assistant-first greetings and unpaired final messages. Never invent missing speech.
9. Create the Currai event tree
Await session creation first. Then emit the call boundary:
{
kind: "event",
name: "vapi.voice_call",
args: { assistantId },
result: {
transcriptEntries: transcript.length,
conversationTurns: turns.length,
...(error ? { error } : {}),
},
success,
latency: endedAt - startedAt,
metadata: { provider: "vapi", assistantId, callId },
}For each normalized turn, emit a top-level agent event:
{
eventId: agentEventId,
kind: "agent",
name: "vapi.conversation.turn",
args: input ? { input } : {},
result: output ? { output } : {},
success,
metadata: { provider: "vapi", assistantId, callId },
}Nest the model event under it:
{
eventId: modelEventId,
parentId: agentEventId,
kind: "model",
name: "vapi.model",
args: input ? { input } : {},
result: output ? { output } : {},
success,
metadata: { provider: knownProvider, model: knownModel },
}The resulting trace is:
vapi.voice_call
vapi.conversation.turn
└── vapi.modelReturn 503 if Currai rejects the session or a required event. The browser can retry the same stable payload.
10. Verify a real conversation
Restart or deploy the application and make one real call. Use a request with an obvious response, such as:
I need to move my appointment from Tuesday to Friday morning.
After the assistant answers, end the call and verify:
- The call appears in Vapi with the same call ID.
- Currai Events contains one session and
vapi.voice_call. vapi.modelis nested undervapi.conversation.turn.- Analyse → User Stories shows the user request or assistant response.
- The session metadata contains the correct assistant and call IDs.
An event row or empty User Story is not enough. The conversation must render recognizable evidence.
Troubleshooting
The call works, but Currai receives nothing
Confirm the three CURRAI_* values exist in the server process handling /api/currai/voice. Restart after environment changes. During debugging, log only safe response status and error categories.
The same sentence appears several times
Capture only Vapi transcript messages whose transcriptType is final. Keep partial messages in temporary UI state.
The User Story is empty
Inspect the top-level vapi.conversation.turn event. Put user text in args.input and assistant text in result.output. Nested model evidence does not replace missing agent evidence.
Retries produce duplicates
Generate session and event UUIDs before the first upload. Reuse the same completed payload for every retry.
For more context, read How to integrate Currai with Vapi.
