Guide

How to build a voice agent in Next.js

Add a route handler that starts a Crackle call with your API key, and a client component that joins the call with livekit-client. The key stays on the server; the browser only gets a join token for one room. That is two files, and the agent itself (its voice, instructions and tools) is made in Crackle’s studio.

What you need.

  • A Next.js app with the App Router.
  • An agent and its ID, and a workspace API key (ck_…), both from the studio.

Step by step.

  1. Put the key and agent in the environmentServer-side variables only: no NEXT_PUBLIC_ prefix, so neither reaches the browser.

    .env.local

    CRACKLE_API_KEY=ck_your_key_here
    CRACKLE_AGENT_ID=ua_your_agent_id
  2. Start the call on the serverA route handler asks Crackle for a call and passes the browser only what it needs to join.

    app/api/call/route.ts

    // app/api/call/route.ts: starts a call with your API key, which never leaves the server.
    export async function POST() {
      const response = await fetch('https://goblins-call-api.onrender.com/v1/calls', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.CRACKLE_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ agentId: process.env.CRACKLE_AGENT_ID }),
      });
      if (!response.ok) return Response.json(await response.json(), { status: 502 });
    
      // Only what the browser needs to join one room.
      const { callId, livekitUrl, token } = await response.json();
      return Response.json({ callId, livekitUrl, token });
    }
  3. Join it from the pageInstall LiveKit’s browser client, then add a button that starts the call, plays the agent and turns the microphone on. Start it from a click: browsers hold sound back until the visitor has interacted with the page. If the microphone is blocked or the visitor leaves the page, the button leaves the call as well, so no call stays open without a way to hang up.

    Terminal

    npm install livekit-client

    app/talk-button.tsx

    // app/talk-button.tsx
    'use client';
    
    import { useEffect, useRef, useState } from 'react';
    import { Room, RoomEvent } from 'livekit-client';
    
    export default function TalkButton() {
      const room = useRef<Room | null>(null);
      const [state, setState] = useState<'idle' | 'connecting' | 'live'>('idle');
    
      // Leaving the page hangs up, even while the call is still connecting.
      useEffect(() => () => {
        room.current?.disconnect();
        room.current = null;
      }, []);
    
      async function start() {
        // Hold the room before anything can fail, so a failure or leaving the page always reaches it.
        const next = new Room();
        room.current = next;
        setState('connecting');
    
        const ended = () => {
          if (room.current !== next) return;
          room.current = null;
          setState('idle');
        };
        // Play the agent's voice as soon as it arrives, and remove it when the call ends.
        next.on(RoomEvent.TrackSubscribed, (track) => {
          if (track.kind === 'audio') document.body.appendChild(track.attach());
        });
        next.on(RoomEvent.TrackUnsubscribed, (track) => track.detach().forEach((element) => element.remove()));
        next.on(RoomEvent.Disconnected, ended);
    
        try {
          const response = await fetch('/api/call', { method: 'POST' });
          if (!response.ok) throw new Error('The call could not be started.');
          const session = await response.json();
          if (room.current !== next) return; // the page was left while waiting
    
          await next.connect(session.livekitUrl, session.token);
          await next.localParticipant.setMicrophoneEnabled(true);
          if (room.current === next) setState('live');
        } catch {
          // The room may be connected already, as when the visitor blocks the microphone: leave it.
          next.disconnect();
          ended();
        }
      }
    
      if (state === 'live') return <button onClick={() => room.current?.disconnect()}>Hang up</button>;
      return (
        <button onClick={start} disabled={state === 'connecting'}>
          {state === 'connecting' ? 'Connecting…' : 'Talk to us'}
        </button>
      );
    }
  4. Put the button on a pageImport TalkButton into any page or layout. The agent greets the visitor once they are in, and the transcript is in the studio’s call log when they hang up.

All of it, in the docs.

The worked example has every file, and the guide explains each part.

Questions.

Can I use the Pages Router?

Yes. The same request goes in an API route under pages/api; the button does not change.

Can I show live captions?

Yes. The agent publishes what both sides say, and whether it is listening, thinking or speaking, as data messages in the room. The web and mobile clients guide shows how to read them.

Where do I get the transcript?

From the call log, from GET /v1/calls/:id, or pushed to your server by a webhook when the call ends.

Other guides.

Give your agent a voice today.

Build one in the studio and talk to it in your browser. Sign-up credit is there for your first test calls.