Claude
Skills
Sign in
Back

voice-retell

Included with Lifetime
$97 forever

Browser voice calling via Retell AI — WebRTC voice mode toggle in chat, web call token API, transcript bridging to chat messages, and mute/unmute controls. Use this skill when the user says "add voice", "voice calling", "setup retell", "add voice mode", or "setup voice-retell".

Image & Video

What this skill does


# Voice Retell

Browser-based voice calling powered by Retell AI. Adds a "Switch to voice" toggle inside the chat UI that connects via WebRTC, streams real-time audio to/from a Retell voice agent, and bridges call transcripts back into the same chat session for unified history.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `ai-chat` skill installed (chat UI at `src/components/ai/chat.tsx`, sessions API)
- `auth` skill installed (`withAuth` at `@/lib/auth-guard`)
- `env-config` skill installed (`src/env.ts`)
- shadcn/ui initialized

## Installation

```bash
bun add retell-client-js-sdk
```

## Environment Variables

Add to `.env.local`:

```env
# Retell AI
RETELL_API_KEY=your-retell-api-key-here
RETELL_AGENT_ID=your-retell-agent-id-here
```

### Update `src/env.ts`

Add to the `server` object:

```typescript
  server: {
    // ... existing variables
    RETELL_API_KEY: z.string(),
    RETELL_AGENT_ID: z.string(),
  },
```

Add to the `runtimeEnv` object:

```typescript
  runtimeEnv: {
    // ... existing variables
    RETELL_API_KEY: process.env.RETELL_API_KEY,
    RETELL_AGENT_ID: process.env.RETELL_AGENT_ID,
  },
```

## What Gets Created

```
src/
├── app/
│   └── api/
│       └── ai/
│           └── voice/
│               └── route.ts                   # POST — create web call, return access token
├── lib/
│   └── voice/
│       └── retell.ts                          # Server-side Retell API helper
└── components/
    └── ai/
        ├── voice-toggle.tsx                   # Mic button that starts/stops voice mode
        └── voice-overlay.tsx                  # Active call overlay with status + mute
```

## What Gets Modified

```
src/
└── components/
    └── ai/
        └── chat.tsx                           # Add VoiceToggle to input area
```

## Setup Steps

### Step 1: Create `src/lib/voice/retell.ts`

```typescript
type CreateWebCallResponse = {
  call_type: "web_call";
  access_token: string;
  call_id: string;
  call_status: string;
};

type CreateWebCallOptions = {
  agentId: string;
  metadata?: Record<string, string>;
  dynamicVariables?: Record<string, string>;
};

/**
 * Create a Retell web call and return the access token.
 * Must be called server-side — uses RETELL_API_KEY.
 */
export async function createWebCall(
  options: CreateWebCallOptions
): Promise<CreateWebCallResponse> {
  const apiKey = process.env.RETELL_API_KEY;
  if (!apiKey) throw new Error("RETELL_API_KEY is not set");

  const response = await fetch("https://api.retellai.com/v2/create-web-call", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      agent_id: options.agentId,
      ...(options.metadata && { metadata: options.metadata }),
      ...(options.dynamicVariables && {
        retell_llm_dynamic_variables: options.dynamicVariables,
      }),
    }),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`Retell API error ${response.status}: ${errorText}`);
  }

  return response.json() as Promise<CreateWebCallResponse>;
}
```

### Step 2: Create `src/app/api/ai/voice/route.ts`

```typescript
import { NextResponse } from "next/server";
import { withAuth } from "@/lib/auth-guard";
import { createWebCall } from "@/lib/voice/retell";
import { db } from "@/db";
import { chatSession, chatMessage } from "@/db/schema/chat";
import { eq, and } from "drizzle-orm";

type VoiceCallBody = {
  sessionId?: string;
};

/** POST /api/ai/voice — create a Retell web call token */
export const POST = withAuth(async (request, { user }) => {
  const body: VoiceCallBody = await request.json();

  const agentId = process.env.RETELL_AGENT_ID;
  if (!agentId) {
    return NextResponse.json(
      { error: "RETELL_AGENT_ID is not configured" },
      { status: 500 }
    );
  }

  // Resolve or create a chat session for transcript bridging
  let activeSessionId = body.sessionId;

  if (activeSessionId) {
    const existing = await db
      .select({ id: chatSession.id })
      .from(chatSession)
      .where(
        and(
          eq(chatSession.id, activeSessionId),
          eq(chatSession.userId, user.id)
        )
      )
      .limit(1);

    if (existing.length === 0) {
      return NextResponse.json(
        { error: "Session not found" },
        { status: 404 }
      );
    }
  } else {
    const [created] = await db
      .insert(chatSession)
      .values({ userId: user.id, title: "Voice Call" })
      .returning({ id: chatSession.id });
    activeSessionId = created.id;
  }

  try {
    const call = await createWebCall({
      agentId,
      metadata: {
        userId: user.id,
        sessionId: activeSessionId,
      },
    });

    return NextResponse.json({
      accessToken: call.access_token,
      callId: call.call_id,
      sessionId: activeSessionId,
    });
  } catch (error) {
    return NextResponse.json(
      {
        error:
          error instanceof Error ? error.message : "Failed to create call",
      },
      { status: 500 }
    );
  }
});
```

### Step 3: Create `src/components/ai/voice-overlay.tsx`

```tsx
"use client";

import { useCallback } from "react";
import { Microphone, MicrophoneSlash, Phone, X } from "@phosphor-icons/react";

type VoiceOverlayProps = {
  isConnected: boolean;
  isAgentTalking: boolean;
  isMuted: boolean;
  onMuteToggle: () => void;
  onEndCall: () => void;
  transcript: string | null;
};

export function VoiceOverlay({
  isConnected,
  isAgentTalking,
  isMuted,
  onMuteToggle,
  onEndCall,
  transcript,
}: VoiceOverlayProps) {
  if (!isConnected) return null;

  return (
    <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-background/95 backdrop-blur-sm">
      {/* Pulsing indicator */}
      <div className="relative mb-8">
        <div
          className={`h-24 w-24 rounded-full ${
            isAgentTalking
              ? "animate-pulse bg-primary/20"
              : "bg-muted"
          } flex items-center justify-center`}
        >
          <Phone
            className={`h-10 w-10 ${
              isAgentTalking ? "text-primary" : "text-muted-foreground"
            }`}
          />
        </div>
        {isAgentTalking && (
          <div className="absolute inset-0 animate-ping rounded-full bg-primary/10" />
        )}
      </div>

      {/* Status */}
      <p className="mb-2 text-sm font-medium">
        {isAgentTalking ? "Agent is speaking..." : "Listening..."}
      </p>

      {/* Live transcript */}
      {transcript && (
        <p className="mb-8 max-w-md px-4 text-center text-sm text-muted-foreground">
          {transcript}
        </p>
      )}

      {/* Controls */}
      <div className="flex gap-4">
        <button
          type="button"
          onClick={onMuteToggle}
          className={`flex h-14 w-14 items-center justify-center rounded-full transition-colors ${
            isMuted
              ? "bg-destructive/10 text-destructive"
              : "bg-muted hover:bg-muted/80"
          }`}
          title={isMuted ? "Unmute" : "Mute"}
        >
          {isMuted ? (
            <MicrophoneSlash className="h-6 w-6" />
          ) : (
            <Microphone className="h-6 w-6" />
          )}
        </button>

        <button
          type="button"
          onClick={onEndCall}
          className="flex h-14 w-14 items-center justify-center rounded-full bg-destructive text-destructive-foreground transition-colors hover:bg-destructive/90"
          title="End call"
        >
          <X className="h-6 w-6" />
        </button>
      </div>
    </div>
  );
}
```

### Step 4: Create `src/components/ai/voice-toggle.tsx`

```tsx
"use client";

import { useState, useCallback, useRef, useEffect } from "react";
import { RetellWebClient } from "retell-client-js-sdk";
import { Microphone } from "@phosphor-icons/react";
import { VoiceOverlay } from "./voice-overlay";

type TranscriptUpdate = {
  t
Files: 1
Size: 16.0 KB
Complexity: 26/100
Category: Image & Video

Related in Image & Video