Guides

View raw .md

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

  1. In the Wakili dashboard, open your agent → Connect tab, copy the agent id and generate an API key.
  2. cp .env.local.example .env.local and fill in:
    WAKILI_API_URL=https://api.wakili.dev
    WAKILI_API_KEY=wak_...
    WAKILI_AGENT_ID=...
    
  3. npm install && npm run dev, open http://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_id into POST /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 POST works. Only the WebSocket step needs to happen from wherever the mic audio is captured.