Examples

Integrate Currai with Retell AI

Capture Retell AI browser calls with authoritative transcripts, readable Currai conversations, and nested model traces.

This workflow adds Currai capture to an existing Retell AI browser voice application. Retell runs the real-time conversation. Your server creates the temporary call token, retrieves the completed transcript, and sends normalized evidence to Currai.

To run the complete implementation first, use the public Retell AI example on GitHub:

Shell
git clone git@github.com:curraiapp/examples.git
cd examples/retell
cp .env.example .env.local
pnpm install
pnpm dev

What you need

  • a Retell AI account and voice agent;
  • a server-side Retell API key;
  • a working Retell browser call;
  • a Currai workspace and ingestion key;
  • a server route in the same application;
  • HTTPS or localhost for browser microphone access.

You can use Retell's browser SDK or your existing supported audio transport. The Currai integration attaches to the call-start, transcript-update, failure, and call-end boundaries.

Install the published Currai Skill from the root of your application repository:

Shell
npx skills add https://github.com/curraiapp/skills --skill currai

Add the Retell AI and Currai credentials described in step 1 to the server environment before asking the coding agent to make changes. Then paste this prompt:

Prompt
Use $currai to connect this Retell AI browser voice application to Currai using
the RETELL_API_KEY, CURRAI_PUBLIC_KEY, CURRAI_SECRET_KEY, and CURRAI_BASE_URL
already configured in the server environment. Find the live Retell call path, not
only the LLM, voice-agent, or web-token creation routes. Reconcile rolling browser
transcript updates, prefer the completed Retell call transcript on the server, and
emit retell.conversation.turn events with nested retell.model events. Keep capture
best-effort and server-side. Restart or deploy the application, complete one real
Retell AI call, and verify its readable User Story and nested trace in Currai.
Never print or copy credentials or temporary access tokens into source code or chat.

For this integration, the skill should distinguish Retell's control-plane routes from the live user conversation. It generates native HTTP capture, creates one UUID session per complete call, waits for session creation before sending events, reconciles repeated rolling utterances, fetches the authoritative completed transcript when available, redacts credentials and temporary tokens, and prevents Currai failures from interrupting Retell.

The work is complete only after a real browser call appears in Observe → Events, the conversation is readable in Analyse → User Stories, and each retell.model event is nested beneath the correct retell.conversation.turn. A successful web-call token request or standalone connectivity event is not sufficient.

The remaining steps show the same workflow manually. Use them to review, troubleshoot, or customize what the skill adds.

1. Configure server credentials

Add both providers' credentials to the server environment:

Environment
RETELL_API_KEY=<your-retell-api-key>
CURRAI_PUBLIC_KEY=<your-currai-public-key>
CURRAI_SECRET_KEY=<your-currai-secret-key>
CURRAI_BASE_URL=https://www.currai.app

Do not use NEXT_PUBLIC_ or another browser-public prefix. The browser must never receive RETELL_API_KEY, CURRAI_PUBLIC_KEY, or CURRAI_SECRET_KEY.

Restart or redeploy after changing the environment.

2. Establish a working Retell agent

Create and test the Retell agent before adding Currai capture. You may create it in the Retell dashboard or through the API.

When using the API, a common sequence is:

  1. Create a Retell LLM with the system prompt and first message.
  2. Create a voice agent connected to that LLM and a valid Retell voice.
  3. If agent creation fails, delete the newly created LLM to avoid an orphaned resource.

Currai capture belongs to the live call, not the agent-creation route. Agent creation is a control-plane action. The user conversation happens later.

3. Create a web-call token on the server

Add a route such as POST /api/calls. Validate the agent ID, then call Retell with the server-only key:

TypeScript
const response = await fetch("https://api.retellai.com/v2/create-web-call", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RETELL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ agent_id: agentId }),
  cache: "no-store",
});

Return only the temporary token and call ID:

TypeScript
return Response.json({
  accessToken: body.access_token,
  callId: body.call_id,
});

Create the token only after the user selects Start call, then connect immediately. Do not preload, cache, log, or persist it unnecessarily.

4. Define the capture context

Create one capture context before requesting the call token:

TypeScript
type TranscriptEntry = {
  role: "user" | "assistant";
  text: string;
};

type VoiceCapture = {
  sessionId: string;
  userId: string;
  boundaryEventId: string;
  agentId: string;
  callId?: string;
  startedAt: number;
  endedAt?: number;
  success: boolean;
  error?: string;
  transcript: TranscriptEntry[];
};

const capture: VoiceCapture = {
  sessionId: crypto.randomUUID(),
  userId: getCurrentOrAnonymousUserId(),
  boundaryEventId: crypto.randomUUID(),
  agentId,
  startedAt: Date.now(),
  success: true,
  transcript: [],
};

After /api/calls responds, store callId on the capture context. The Retell call ID links Currai evidence to Retell Call History and allows the server to retrieve the completed transcript.

5. Start the browser call

Request microphone permission, call your server route, then pass the temporary access token to your Retell browser client or supported transport:

TypeScript
await navigator.mediaDevices.getUserMedia({ audio: true });

const response = await fetch("/api/calls", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ agentId }),
});

const { accessToken, callId } = await response.json();
capture.callId = callId;

await retellClient.startCall({ accessToken });

Handle microphone denial, unavailable devices, token expiry, connection failures, and browser autoplay restrictions separately. Record a safe failure reason on the capture context without exposing the token.

6. Reconcile rolling transcript updates

Retell sends live transcript updates as a rolling utterance window. A later update may repeat earlier turns and revise the active utterance. Do not append every update.

Normalize provider roles first:

TypeScript
function parseRetellTranscript(update: unknown): TranscriptEntry[] {
  if (!update || typeof update !== "object") return [];
  const transcript = (update as { transcript?: unknown }).transcript;
  if (!Array.isArray(transcript)) return [];

  return transcript.flatMap((item) => {
    if (!item || typeof item !== "object") return [];
    const value = item as { role?: unknown; content?: unknown };
    const role = value.role === "agent" ? "assistant" : value.role;
    const text = typeof value.content === "string" ? value.content.trim() : "";

    return (role === "user" || role === "assistant") && text
      ? [{ role, text } as TranscriptEntry]
      : [];
  });
}

Then reconcile the incoming window with retained history:

TypeScript
retellClient.on("update", (update) => {
  const incoming = parseRetellTranscript(update);
  if (incoming.length === 0) return;

  capture.transcript = reconcileRollingWindow(capture.transcript, incoming);
});

Your reconciler should:

  • find exact overlap between retained history and the incoming window;
  • append only turns after the overlap;
  • replace a growing or revised active utterance;
  • suppress exact duplicates;
  • retain turns after they leave Retell's rolling window.

The retained browser transcript is a fallback. Retell's completed call record is authoritative when available.

7. Upload at call end

When the call ends, freeze the payload and post it to a protected server route:

TypeScript
retellClient.on("call_ended", () => {
  capture.endedAt = Date.now();
  void uploadCapture(capture);
});
TypeScript
async function uploadCapture(capture: VoiceCapture) {
  const payload = {
    ...capture,
    endedAt: capture.endedAt ?? Date.now(),
  };

  for (const delay of [0, 750, 2_000]) {
    if (delay) await new Promise((resolve) => setTimeout(resolve, delay));

    try {
      const response = await fetch("/api/currai/voice", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
        keepalive: true,
      });

      if (response.ok) return true;
      if (response.status < 500 && response.status !== 429) return false;
    } catch {
      // Capture is best-effort and must never interrupt the Retell call.
    }
  }

  return false;
}

Build payload once and reuse it on retries. Never generate fresh IDs inside the retry loop.

8. Validate the browser payload

Before contacting Retell or Currai, the server should validate:

  • a bounded request size;
  • UUID format for session and boundary IDs;
  • Retell agent and call ID format;
  • valid timestamps and success state;
  • bounded transcript entry count and text length;
  • only user and assistant roles;
  • the absence of authorization, cookie, password, secret, API-key, bearer, and access-token fields.

Reject malformed input with 400 and oversized input with 413. These are permanent payload errors and should not be retried unchanged.

9. Retrieve the completed Retell transcript

Use callId and RETELL_API_KEY on the server:

TypeScript
const response = await fetch(
  `https://api.retellai.com/v2/get-call/${encodeURIComponent(callId)}`,
  {
    headers: {
      Authorization: `Bearer ${process.env.RETELL_API_KEY}`,
    },
    cache: "no-store",
  },
);

Normalize transcript_object into { role, text } entries. Prefer it when it contains turns:

TypeScript
const authoritative = await getCompletedRetellTranscript(callId);
const transcript = authoritative ?? browserTranscript;
const transcriptSource = authoritative ? "retell-call" : "browser-fallback";

The fallback matters because call completion and transcript processing may not finish simultaneously. Record transcriptSource on every Currai event.

10. Normalize conversation turns

Coalesce consecutive fragments from the same speaker:

Prompt
user:      I need to change
user:      my delivery address
assistant: I can help with that

becomes:

Prompt
user:      I need to change my delivery address
assistant: I can help with that

Pair a user message with the assistant message that follows it. Preserve assistant-first greetings and unpaired final turns. Never invent missing speech.

11. Create the Currai event tree

Await session creation, then emit the call boundary:

TypeScript
{
  eventId: boundaryEventId,
  kind: "event",
  name: "retell.voice_call",
  args: { agentId },
  result: {
    transcriptEntries: transcript.length,
    conversationTurns: turns.length,
    ...(error ? { error } : {}),
  },
  success,
  latency: endedAt - startedAt,
  metadata: {
    provider: "retell",
    agentId,
    callId,
    transcriptSource,
  },
}

For every normalized turn, emit readable agent evidence:

TypeScript
{
  eventId: agentEventId,
  kind: "agent",
  name: "retell.conversation.turn",
  args: input ? { input } : {},
  result: output ? { output } : {},
  success,
  metadata: { provider: "retell", agentId, callId, transcriptSource },
}

Nest the model event beneath it:

TypeScript
{
  eventId: modelEventId,
  parentId: agentEventId,
  kind: "model",
  name: "retell.model",
  args: input ? { input } : {},
  result: output ? { output } : {},
  success,
  metadata: { provider: knownProvider, model: knownModel },
}

Derive child IDs deterministically from the stable boundary ID and turn index, or generate and store them before the first upload.

The resulting trace is:

Prompt
retell.voice_call

retell.conversation.turn
└── retell.model

Return 503 if Currai rejects the session or a required event. The browser can retry the same stable payload.

12. Verify a real conversation

Restart or deploy the application and make one real Retell call. Use a request with an obvious response, such as:

I need to move my appointment from Tuesday to Friday morning.

After the agent answers, end the call and verify:

  1. Retell Call History contains the matching call ID and completed transcript.
  2. Currai Events contains one session and retell.voice_call.
  3. The call event includes agent ID, call ID, duration, success, and transcript source.
  4. retell.model is nested under retell.conversation.turn.
  5. Analyse → User Stories shows the real user request or agent response.

Do not stop after seeing only an event row. A completed integration must render conversation evidence.

Troubleshooting

Agent or call creation fails

Confirm RETELL_API_KEY exists in the server process. Validate the agent ID and inspect the normalized Retell response without logging authorization headers.

The web-call token expires

Create it only after the user starts the call, then connect immediately. Do not create tokens during page load.

Transcript lines repeat

Reconcile Retell's rolling updates instead of appending each window. Replace active utterance revisions and suppress exact overlap.

The completed transcript is not ready

Use the retained browser transcript and mark it as browser-fallback. Do not fail capture solely because Retell is still processing transcript_object.

The User Story is empty

Inspect the top-level retell.conversation.turn event. Put user text in args.input and assistant text in result.output. Nested model evidence does not replace missing agent evidence.

For more context, read How to integrate Currai with Retell AI.