# 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`](/docs/calls-api#start-a-call) with the API key, and returns just the `ws_url` (rewritten to an absolute `wss://` URL) to the browser:

```ts
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](/docs/talk-websocket): 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`.

```ts
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](/docs/calls-api#start-a-call).
- **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.
