Guides
Next.js integration guide
A complete server-to-server integration: push-to-talk in the browser, API key kept on your server the whole time. The full runnable source for this guide lives at examples/nextjs-voice-agent/ in the Wakili repo.
Setup
- In the Wakili dashboard, open your agent → Connect tab, copy the agent id and generate an API key.
cp .env.local.example .env.localand fill in:WAKILI_API_URL=https://api.wakili.dev WAKILI_API_KEY=wak_... WAKILI_AGENT_ID=...npm install && npm run dev, openhttp://localhost:3000, allow mic access.
Architecture
Browser --POST /api/start-call--> Next.js server --POST /agents/{id}/calls--> Wakili API
Browser <---------- ws_url --------------|
Browser ============= WebSocket (audio in, transcript/reply out) =============> Wakili API
The API key only ever touches the Next.js server. The browser gets back a single-use ws_url and talks to the Wakili talk WebSocket directly from there — audio doesn't round-trip through your server.
The server route
app/api/start-call/route.ts — calls POST /agents/{agentId}/calls with the API key, and returns just the ws_url (rewritten to an absolute wss:// URL) to the browser:
export async function POST(request: Request) {
const apiUrl = process.env.WAKILI_API_URL;
const apiKey = process.env.WAKILI_API_KEY;
const agentId = process.env.WAKILI_AGENT_ID;
const { conversationId } = await request.json().catch(() => ({ conversationId: undefined }));
const res = await fetch(`${apiUrl}/agents/${agentId}/calls`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ conversation_id: conversationId }),
});
if (!res.ok) return Response.json({ error: await res.text() }, { status: res.status });
const call = await res.json();
const wsBase = process.env.WAKILI_WS_URL ?? apiUrl!.replace(/^http/, "ws");
return Response.json({ ...call, ws_url: `${wsBase}${call.ws_url}` });
}
The WebSocket client
lib/talkClient.ts — a ~90-line TalkClient class implementing the talk WebSocket protocol: opens the socket, records mic audio via MediaRecorder while a button is held, sends binary frames as chunks arrive, sends {"type":"end_turn"} on release, and on the response side buffers binary reply-audio frames until reply_audio_end then plays them back via AudioContext.
export type TalkEvent =
| { type: "connected" }
| { type: "closed" }
| { type: "transcript"; text: string }
| { type: "reply_text"; text: string }
| { type: "reply_audio_end" }
| { type: "error"; message: string }
| { type: "call_ended"; reason: string };
export class TalkClient {
private socket: WebSocket | null = null;
private mediaRecorder: MediaRecorder | null = null;
private stream: MediaStream | null = null;
private replyChunks: Uint8Array[] = [];
constructor(private wsUrl: string, private onEvent: (event: TalkEvent) => void) {}
connect(): void {
this.socket = new WebSocket(this.wsUrl);
this.socket.binaryType = "arraybuffer";
this.socket.onopen = () => this.onEvent({ type: "connected" });
this.socket.onmessage = (event) => this.handleMessage(event);
}
async startTurn(): Promise<void> {
this.stream ??= await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: "audio/webm" });
this.mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0 && this.socket?.readyState === WebSocket.OPEN) this.socket.send(e.data);
};
this.mediaRecorder.onstop = () => {
this.socket?.send(JSON.stringify({ type: "end_turn" }));
};
this.mediaRecorder.start(250);
}
endTurn(): void {
this.mediaRecorder?.stop();
}
private handleMessage(event: MessageEvent): void {
if (event.data instanceof ArrayBuffer) {
this.replyChunks.push(new Uint8Array(event.data));
return;
}
const parsed = JSON.parse(event.data) as TalkEvent;
if (parsed.type === "reply_audio_end") this.playReplyAudio();
this.onEvent(parsed);
}
private async playReplyAudio(): Promise<void> {
// concat this.replyChunks, decodeAudioData, play — see full source for detail
}
}
Full file (buffer concatenation, cleanup, disconnect handling) is in the example repo.
Wiring it up
app/page.tsx calls POST /api/start-call, gets ws_url back, constructs a TalkClient, and wires a press-and-hold button to startTurn()/endTurn(), rendering the running transcript from transcript/reply_text events.
Going further
- Continuous conversation instead of push-to-talk: swap the hold-button trigger for client-side voice-activity detection (silence detection on the mic stream) to call
endTurn()automatically — this is what the Wakili dashboard's own call UI does. - Multi-turn context across separate calls: pass the previous call's
conversation_idintoPOST /agents/{id}/calls— see Calls API. - Framework other than Next.js: the pattern is framework-agnostic — any server that can hold the API key and proxy one
POSTworks. Only the WebSocket step needs to happen from wherever the mic audio is captured.