Next.js에서 Agents Platform을 활용한 데이터 수집 및 분석

Agents Platform 및 Next.js를 사용해 통화 후 웹훅에서 데이터를 수집하고 분석합니다.

튜토리얼 · ElevenAgents 빠른 시작을 완료했고 Next.js 프로젝트를 설정했다고 가정합니다.

소개

이 튜토리얼에서는 대화를 통해 사용자로부터 정보를 수집하고, 데이터를 구조화된 방식으로 분석 및 추출한 다음 통화 후 웹훅을 통해 애플리케이션으로 전송하는 음성 에이전트를 구축하는 방법을 알아봅니다.

요구 사항

  • API 키가 있는 ElevenLabs 계정
  • 컴퓨터에 설치된 Node.js v18 이상

설정

새 Next.js 프로젝트 만들기

애플리케이션의 시작점으로 v0.dev Agents Platform 템플릿을 사용하는 것이 좋습니다. 이 템플릿은 ElevenLabs 에이전트가 이미 통합된 프로덕션 준비 완료 Next.js 애플리케이션입니다.

Agents Platform 설정

설치 및 구성 단계는 Next.js 가이드를 따르세요. 그런 다음 여기로 돌아와 고급 기능을 구축합니다.

에이전트 구성

1

ElevenLabs에 로그인

elevenlabs.io로 이동하여 계정에 로그인합니다.

2

새 에이전트 만들기

Agents Platform > Agents로 이동한 후 빈 템플릿에서 새 에이전트를 만듭니다.

3

첫 번째 메시지 설정

첫 번째 메시지를 설정하고 플랫폼의 동적 변수를 지정합니다.

Hi {{user_name}}, I'm Jess from the ElevenLabs team. I'm here to help you design your very own ElevenLabs agent! To kick things off, let me know what kind of agent you're looking to create. For example, do you want a support agent, to help your users answer questions, or a sales agent to sell your products, or just a friend to chat with?
4

시스템 프롬프트 설정

시스템 프롬프트를 설정합니다. 여기에 동적 변수를 포함할 수도 있습니다.

You are Jess, a helpful agent helping {{user_name}} to design their very own ElevenLabs agent. The design process involves the following steps:
"initial": In the first step, collect the information about the kind of agent the user is looking to create. Summarize the user's needs back to them and ask if they are ready to continue to the next step. Only once they confirm proceed to the next step.
"training": Tell the user to create the agent's knowledge base by uploading documents, or submitting URLs to public websites with information that should be available to the agent. Wait patiently without talking to the user. Only when the user confirms that they've provided everything then proceed to the next step.
"voice": Tell the user to describe the voice they want their agent to have. For example: "A professional, strong spoken female voice with a slight British accent." Repeat the description of their voice back to them and ask if they are ready to continue to the next step. Only once they confirm proceed to the next step.
"email": Tell the user that we've collected all necessary information to create their ElevenLabs agent and ask them to provide their email address to get notified when the agent is ready.
Always call the `set_ui_state` tool when moving between steps!
5

클라이언트 도구 설정

단계 간 이동에 사용할 다음 클라이언트 도구를 설정합니다.

  • 이름: set_ui_state
    • 설명: 이 클라이언트 측 도구를 사용하여 여러 UI 상태 간에 이동합니다.
    • 응답 대기: true
    • 응답 시간 제한(초): 1
    • 매개변수:
      • 데이터 유형: string
      • 식별자: step
      • 필수: true
      • 값 유형: LLM Prompt
      • 설명: UI에서 이동할 단계입니다. 시스템 프롬프트에 정의된 단계만 사용하세요!
6

에이전트 음성 설정

Voice 탭으로 이동하여 에이전트의 음성을 설정합니다. Agents Platform에 권장되는 음성 목록은 대화형 음성 디자인 문서에서 확인할 수 있습니다.

7

평가 기준 설정

Analysis 탭으로 이동하여 새 평가 기준을 추가합니다.

  • 이름: all_data_provided
    • 프롬프트: 사용자가 생성하려는 에이전트에 대한 설명과 해당 에이전트가 가져야 할 음성에 대한 설명을 제공했는지 평가합니다.
8

데이터 수집 구성

통화 후 분석을 사용하여 대화에서 데이터를 추출할 수 있습니다. Analysis 탭의 Data Collection에서 다음 항목을 추가합니다.

  • 식별자: voice_description
    • data-type: String
    • 설명: 사용자가 에이전트에 적용하고자 하는 음성 설명을 바탕으로, 가능한 경우 나이, 억양, 톤 및 성격을 포함한 간결한 음성 설명을 생성합니다.
  • 식별자: agent_description
    • data-type: String
    • 설명: 사용자가 설계하려는 에이전트에 대한 설명을 바탕으로, 모델이 해당 에이전트로 작동하도록 학습하는 데 사용할 수 있는 프롬프트를 생성합니다.
9

통화 후 웹훅 구성

통화 후 웹훅은 통화가 종료되고 분석 및 데이터 추출 단계가 완료되었을 때 알림을 보내는 데 사용됩니다.

이 예시에서 통화 후 웹훅은 다음 단계를 수행합니다.

  1. voice_description을 기반으로 맞춤 음성 디자인을 생성합니다.
  2. 사용자가 제공한 agent_description을 기반으로 사용자를 위한 ElevenLabs 에이전트를 생성합니다.
  3. Redis에 저장된 대화 상태에서 지식 베이스 문서를 가져와 에이전트에 연결합니다.
  4. 맞춤 ElevenLabs 에이전트가 대화할 준비가 되었음을 알리는 이메일을 사용자에게 보냅니다.

로컬에서 실행하는 경우 로컬 서버를 인터넷에 노출하려면 ngrok과 같은 도구가 필요합니다.

ngrok http 3000

Agents Platform 설정으로 이동한 후 Post-Call Webhook에서 새 웹훅을 만들고 ngrok URL https://<your-url>.ngrok-free.app/api/convai-webhook을 붙여넣습니다.

웹훅을 저장하면 웹훅 시크릿을 받게 됩니다. 나중에 .env 파일에 설정해야 하므로 이 시크릿을 안전하게 보관하세요.

고급 기능 통합

대화 상태 저장을 위한 Redis 데이터베이스 설정

이 예시에서는 Redis를 사용하여 대화 상태를 저장합니다. 이를 통해 통화가 끝난 후 대화 상태에서 지식 베이스 문서를 가져올 수 있습니다.

Vercel에 배포하는 경우 Upstash for Redis 통합을 구성하거나, 무료 Upstash 계정에 가입하여 새 데이터베이스를 만들 수 있습니다.

통화 후 이메일 전송을 위한 Resend 설정

이 예시에서는 Resend를 사용하여 사용자에게 통화 후 이메일을 전송합니다. 이를 위해 무료 Resend 계정을 만들고 새 API 키를 설정해야 합니다.

환경 변수 설정

프로젝트 루트에 .env 파일을 만들고 다음 변수를 추가합니다.

ELEVENLABS_CONVAI_WEBHOOK_SECRET=
ELEVENLABS_API_KEY=
ELEVENLABS_AGENT_ID=
# Resend
RESEND_API_KEY=
RESEND_FROM_EMAIL=
# Upstash Redis
KV_URL=
KV_REST_API_READ_ONLY_TOKEN=
REDIS_URL=
KV_REST_API_TOKEN=
KV_REST_API_URL=

보안 및 인증 구성

ElevenLabs 에이전트를 보호하려면 에이전트 구성의 Security 탭에서 인증을 활성화해야 합니다.

인증을 활성화한 후에는 에이전트와의 대화를 시작하기 위해 보안이 유지되는 서버 측 환경에서 서명된 URL을 생성해야 합니다. Next.js에서는 새 API 라우트를 설정하여 이를 수행할 수 있습니다.

./app/api/signed-url/route.ts
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import { NextResponse } from "next/server";
export async function GET() {
const agentId = process.env.ELEVENLABS_AGENT_ID;
if (!agentId) {
throw Error("ELEVENLABS_AGENT_ID is not set");
}
try {
const elevenlabs = new ElevenLabsClient();
const response = await elevenlabs.conversationalAi.conversations.getSignedUrl({
agentId,
});
return NextResponse.json({ signedUrl: response.signedUrl });
} catch (error) {
console.error("Error:", error);
return NextResponse.json({ error: "Failed to get signed URL" }, { status: 500 });
}
}

대화 세션 시작

대화를 시작하려면 먼저 API 라우트를 호출하여 서명된 URL을 가져온 다음, useConversation 훅을 사용해 대화 세션을 설정합니다.

./page.tsx
import { useConversation } from "@elevenlabs/react";
async function getSignedUrl(): Promise<string> {
const response = await fetch("/api/signed-url");
if (!response.ok) {
throw Error("Failed to get signed url");
}
const data = await response.json();
return data.signedUrl;
}
export default function Home() {
// ...
const [currentStep, setCurrentStep] = useState<
"initial" | "training" | "voice" | "email" | "ready"
>("initial");
const [conversationId, setConversationId] = useState("");
const [userName, setUserName] = useState("");
const conversation = useConversation({
onConnect: () => console.log("Connected"),
onDisconnect: () => console.log("Disconnected"),
onMessage: (message: string) => console.log("Message:", message),
onError: (error: Error) => console.error("Error:", error),
});
const startConversation = useCallback(async () => {
try {
// Request microphone permission
await navigator.mediaDevices.getUserMedia({ audio: true });
// Start the conversation with your agent
const signedUrl = await getSignedUrl();
const convId = await conversation.startSession({
signedUrl,
dynamicVariables: {
user_name: userName,
},
clientTools: {
set_ui_state: ({ step }: { step: string }): string => {
// Allow agent to navigate the UI.
setCurrentStep(step as "initial" | "training" | "voice" | "email" | "ready");
return `Navigated to ${step}`;
},
},
});
setConversationId(convId);
console.log("Conversation ID:", convId);
} catch (error) {
console.error("Failed to start conversation:", error);
}
}, [conversation, userName]);
const stopConversation = useCallback(async () => {
await conversation.endSession();
}, [conversation]);
// ...
}

클라이언트 도구 및 동적 변수

앞서 에이전트 구성에서 에이전트가 여러 UI 상태 간에 이동할 수 있도록 set_ui_state 클라이언트 도구를 등록했습니다. 이를 모두 연결하려면 conversation.startSession 옵션에 클라이언트 도구 구현을 전달해야 합니다.

여기에서 대화에 동적 변수를 전달할 수도 있습니다.

./page.tsx
const convId = await conversation.startSession({
signedUrl,
dynamicVariables: {
user_name: userName,
},
clientTools: {
set_ui_state: ({ step }: { step: string }): string => {
// Allow agent to navigate the UI.
setCurrentStep(step as "initial" | "training" | "voice" | "email" | "ready");
return `Navigated to ${step}`;
},
},
});

지식 베이스에 문서 업로드

Training 단계에서 에이전트는 사용자에게 문서를 업로드하거나 에이전트가 사용할 수 있어야 하는 정보가 있는 공개 웹사이트 URL을 제출하도록 요청합니다. 여기서는 Next.js 15의 새로운 after 함수를 활용하여 백그라운드에서 문서를 업로드할 수 있습니다.

양식 제출 시 지식 베이스 생성을 처리할 새 upload 서버 액션을 만듭니다. 모든 지식 베이스 문서가 생성되면 대화 ID와 지식 베이스 ID를 Redis 데이터베이스에 저장합니다.

./app/actions/upload.ts
"use server";
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import { Redis } from "@upstash/redis";
import { redirect } from "next/navigation";
import { after } from "next/server";
// Initialize Redis
const redis = Redis.fromEnv();
const elevenlabs = new ElevenLabsClient({
apiKey: process.env.ELEVENLABS_API_KEY,
});
export async function uploadFormData(formData: FormData) {
const knowledgeBase: Array<{
id: string;
type: "file" | "url";
name: string;
}> = [];
const files = formData.getAll("file-upload") as File[];
const email = formData.get("email-input");
const urls = formData.getAll("url-input");
const conversationId = formData.get("conversation-id");
after(async () => {
// Upload files as background job
// Create knowledge base entries
// Loop through files and create knowledge base entries
for (const file of files) {
if (file.size > 0) {
const response = await elevenlabs.conversationalAi.knowledgeBase.documents.createFromFile({
file,
});
if (response.id) {
knowledgeBase.push({
id: response.id,
type: "file",
name: file.name,
});
}
}
}
// Append all urls
for (const url of urls) {
const response = await elevenlabs.conversationalAi.knowledgeBase.documents.createFromUrl({
url: url as string,
});
if (response.id) {
knowledgeBase.push({
id: response.id,
type: "url",
name: `url for ${conversationId}`,
});
}
}
// Store knowledge base IDs and conversation ID in database.
const redisRes = await redis.set(
conversationId as string,
JSON.stringify({ email, knowledgeBase })
);
console.log({ redisRes });
});
redirect("/success");
}

통화 후 웹훅 처리

통화 후 웹훅은 통화가 종료되고 분석 및 데이터 추출 단계가 완료되면 트리거됩니다.

여기에서는 다음 단계를 수행합니다.

  1. 웹훅 시크릿을 검증하고 웹훅 페이로드를 구성합니다.
  2. voice_description을 기반으로 맞춤 음성 디자인을 생성합니다.
  3. 사용자가 제공한 agent_description을 기반으로 사용자를 위한 ElevenLabs 에이전트를 생성합니다.
  4. Redis에 저장된 대화 상태에서 지식 베이스 문서를 가져와 에이전트에 연결합니다.
  5. 맞춤 ElevenLabs 에이전트가 대화할 준비가 되었음을 알리는 이메일을 사용자에게 보냅니다.
./app/api/convai-webhook/route.ts
import { ElevenLabsClient } from "@elevenlabs/elevenlabs-js";
import { Redis } from "@upstash/redis";
import crypto from "crypto";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Resend } from "resend";
import { EmailTemplate } from "@/components/email/post-call-webhook-email";
// Initialize Redis
const redis = Redis.fromEnv();
// Initialize Resend
const resend = new Resend(process.env.RESEND_API_KEY);
const elevenlabs = new ElevenLabsClient({
apiKey: process.env.ELEVENLABS_API_KEY,
});
export async function GET() {
return NextResponse.json({ status: "webhook listening" }, { status: 200 });
}
export async function POST(req: NextRequest) {
const secret = process.env.ELEVENLABS_CONVAI_WEBHOOK_SECRET; // Add this to your env variables
const { event, error } = await constructWebhookEvent(req, secret);
if (error) {
return NextResponse.json({ error: error }, { status: 401 });
}
if (event.type === "post_call_transcription") {
const { conversation_id, analysis, agent_id } = event.data;
if (
agent_id === process.env.ELEVENLABS_AGENT_ID &&
analysis.evaluation_criteria_results.all_data_provided?.result === "success" &&
analysis.data_collection_results.voice_description?.value
) {
try {
// Design the voice
const voicePreview = await elevenlabs.textToVoice.createPreviews({
voiceDescription: analysis.data_collection_results.voice_description.value,
text: "The night air carried whispers of betrayal, thick as London fog. I adjusted my cufflinks - after all, even spies must maintain appearances, especially when the game is afoot.",
});
const voice = await elevenlabs.textToVoice.createVoiceFromPreview({
voiceName: `voice-${conversation_id}`,
voiceDescription: `Voice for ${conversation_id}`,
generatedVoiceId: voicePreview.previews[0].generatedVoiceId,
});
// Get the knowledge base from redis
const redisRes = await getRedisDataWithRetry(conversation_id);
if (!redisRes) throw new Error("Conversation data not found!");
// Handle agent creation
const agent = await elevenlabs.conversationalAi.agents.create({
name: `Agent for ${conversation_id}`,
conversationConfig: {
tts: { voiceId: voice.voiceId },
agent: {
prompt: {
prompt:
analysis.data_collection_results.agent_description?.value ??
"You are a helpful assistant.",
knowledgeBase: redisRes.knowledgeBase,
},
firstMessage: "Hello, how can I help you today?",
},
},
});
console.log("Agent created", { agent: agent.agentId });
// Send email to user
console.log("Sending email to", redisRes.email);
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: redisRes.email,
subject: "Your ElevenLabs agent is ready to chat!",
react: EmailTemplate({ agentId: agent.agentId }),
});
} catch (error) {
console.error(error);
return NextResponse.json({ error }, { status: 500 });
}
}
}
return NextResponse.json({ received: true }, { status: 200 });
}
const constructWebhookEvent = async (req: NextRequest, secret?: string) => {
const body = await req.text();
const signatureHeader = req.headers.get("ElevenLabs-Signature");
return await elevenlabs.webhooks.constructEvent(body, signatureHeader, secret);
};
async function getRedisDataWithRetry(
conversationId: string,
maxRetries = 5
): Promise<{
email: string;
knowledgeBase: Array<{
id: string;
type: "file" | "url";
name: string;
}>;
} | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const data = await redis.get(conversationId);
return data as any;
} catch (error) {
if (attempt === maxRetries) throw error;
console.log(`Redis get attempt ${attempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return null;
}

각 단계를 자세히 살펴보겠습니다.

웹훅 시크릿 검증 및 웹훅 페이로드 구성

웹훅 요청을 받으면 먼저 웹훅 시크릿을 검증하고 웹훅 페이로드를 구성합니다.

./app/api/convai-webhook/route.ts
// ...
export async function POST(req: NextRequest) {
const secret = process.env.ELEVENLABS_CONVAI_WEBHOOK_SECRET;
const { event, error } = await constructWebhookEvent(req, secret);
// ...
}
// ...
const constructWebhookEvent = async (req: NextRequest, secret?: string) => {
const body = await req.text();
const signatureHeader = req.headers.get("ElevenLabs-Signature");
return await elevenlabs.webhooks.constructEvent(body, signatureHeader, secret);
};
async function getRedisDataWithRetry(
conversationId: string,
maxRetries = 5
): Promise<{
email: string;
knowledgeBase: Array<{
id: string;
type: "file" | "url";
name: string;
}>;
} | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const data = await redis.get(conversationId);
return data as any;
} catch (error) {
if (attempt === maxRetries) throw error;
console.log(`Redis get attempt ${attempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return null;
}

voice_description을 기반으로 맞춤 음성 디자인 생성

웹훅 페이로드의 voice_description을 사용하여 맞춤 음성 디자인을 생성합니다.

./app/api/convai-webhook/route.ts
// ...
// Design the voice
const voicePreview = await elevenlabs.textToVoice.createPreviews({
voiceDescription: analysis.data_collection_results.voice_description.value,
text: "The night air carried whispers of betrayal, thick as London fog. I adjusted my cufflinks - after all, even spies must maintain appearances, especially when the game is afoot.",
});
const voice = await elevenlabs.textToVoice.createVoiceFromPreview({
voiceName: `voice-${conversation_id}`,
voiceDescription: `Voice for ${conversation_id}`,
generatedVoiceId: voicePreview.previews[0].generatedVoiceId,
});
// ...

Redis에 저장된 대화 상태에서 지식 베이스 문서 가져오기

문서 업로드는 웹훅 데이터 분석보다 오래 걸릴 수 있으므로, 문서가 업로드될 때까지 Redis에서 대화 상태를 폴링해야 합니다.

./app/api/convai-webhook/route.ts
// ...
// Get the knowledge base from redis
const redisRes = await getRedisDataWithRetry(conversation_id);
if (!redisRes) throw new Error("Conversation data not found!");
// ...
async function getRedisDataWithRetry(
conversationId: string,
maxRetries = 5
): Promise<{
email: string;
knowledgeBase: Array<{
id: string;
type: "file" | "url";
name: string;
}>;
} | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const data = await redis.get(conversationId);
return data as any;
} catch (error) {
if (attempt === maxRetries) throw error;
console.log(`Redis get attempt ${attempt} failed, retrying...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
return null;
}

사용자가 제공한 agent_description을 기반으로 사용자를 위한 ElevenLabs 에이전트 생성

사용자가 제공한 agent_description을 기반으로 ElevenLabs 에이전트를 생성하고, 새로 생성된 음성 디자인과 지식 베이스를 에이전트에 연결합니다.

./app/api/convai-webhook/route.ts
// ...
// Handle agent creation
const agent = await elevenlabs.conversationalAi.agents.create({
name: `Agent for ${conversationId}`,
conversationConfig: {
tts: { voiceId: voice.voiceId },
agent: {
prompt: {
prompt:
analysis.data_collection_results.agent_description?.value ??
"You are a helpful assistant.",
knowledgeBase: redisRes.knowledgeBase,
},
firstMessage: "Hello, how can I help you today?",
},
},
});
console.log("Agent created", { agent: agent.agentId });
// ...

맞춤 ElevenLabs 에이전트가 대화할 준비가 되었음을 알리는 이메일을 사용자에게 전송

에이전트가 생성되면 맞춤 ElevenLabs 에이전트가 대화할 준비가 되었음을 알리는 이메일을 사용자에게 보낼 수 있습니다.

./app/api/convai-webhook/route.ts
import { Resend } from "resend";
import { EmailTemplate } from "@/components/email/post-call-webhook-email";
// ...
// Send email to user
console.log("Sending email to", redisRes.email);
await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: redisRes.email,
subject: "Your ElevenLabs agent is ready to chat!",
react: EmailTemplate({ agentId: agent.agentId }),
});
// ...

Resend 팀의 유용한 도구인 new.email을 사용하면 이메일 템플릿을 바이브 디자인할 수 있습니다. 템플릿이 마음에 들면 새 컴포넌트를 만들고 에이전트 ID를 prop으로 추가합니다.

./components/email/post-call-webhook-email.tsx
import {
Body,
Button,
Container,
Head,
Html,
Section,
Text,
Tailwind,
} from "@react-email/components";
import * as React from "react";
const EmailTemplate = (props: any) => {
const { agentId } = props;
return (
<Html>
<Head />
<Tailwind>
<Body className="bg-[#151516] font-sans">
<Container className="mx-auto my-[40px] max-w-[600px] rounded-[8px] bg-[#0a1929] p-[20px]">
{/* Top Section */}
<Section className="mb-[32px] mt-[32px] text-center">
<Text className="m-0 text-[28px] font-bold text-[#9c27b0]">
Your ElevenLabs agent is ready to chat!
</Text>
</Section>
{/* Content Area with Icon */}
<Section className="mb-[32px] text-center">
{/* Circle Icon with Checkmark */}
<div className="mx-auto mb-[24px] flex h-[80px] w-[80px] items-center justify-center rounded-full bg-gradient-to-r from-[#9c27b0] to-[#3f51b5]">
<div className="text-[40px] text-white">✓</div>
</div>
{/* Descriptive Text */}
<Text className="mb-[24px] text-[18px] text-white">
Your ElevenLabs agent is ready to chat!
</Text>
</Section>
{/* Call to Action Button */}
<Section className="mb-[32px] text-center">
<Button
href={`https://elevenlabs.io/app/talk-to?agent_id=${agentId}`}
className="box-border rounded-[8px] bg-[#9c27b0] px-[40px] py-[20px] text-[24px] font-bold text-white no-underline"
>
Chat now!
</Button>
</Section>
{/* Footer */}
<Section className="mt-[40px] border-t border-[#2d3748] pt-[20px] text-center">
<Text className="m-0 text-[14px] text-white">
Powered by{" "}
<a
href="https://elevenlabs.io/conversational-ai"
target="_blank"
rel="noopener noreferrer"
className="underline transition-colors hover:text-gray-400"
>
ElevenLabs Agents
</a>
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export { EmailTemplate };

앱 실행

앱을 로컬에서 처음부터 끝까지 실행하려면 먼저 Next.js 개발 서버를 실행한 다음, 별도의 터미널에서 ngrok 터널을 실행하여 웹훅 핸들러를 인터넷에 노출해야 합니다.

  • 터미널 1:
    • pnpm dev를 실행하여 Next.js 개발 서버를 시작합니다.
pnpm dev
  • 터미널 2:
    • ngrok http 3000을 실행하여 웹훅 핸들러를 인터넷에 노출합니다.
ngrok http 3000

이제 http://localhost:3000을 열고 나만의 음성으로 맞춤 ElevenLabs 에이전트를 디자인해 보세요!

다음 단계