Skip to content

Instantly share code, notes, and snippets.

@amponce
Created May 30, 2026 04:24
Show Gist options
  • Select an option

  • Save amponce/16ff34233a0c08bf9cb33924109c144e to your computer and use it in GitHub Desktop.

Select an option

Save amponce/16ff34233a0c08bf9cb33924109c144e to your computer and use it in GitHub Desktop.

Integrating Relay Agent

A complete, self-contained guide for building a voice AI integration with Relay Agent. Designed to be readable end-to-end by a coding agent (Lovable, Cursor, Claude Code, etc.) without needing to fetch any other file.

Status: Relay Agent is currently invite-only. Your platform's users cannot self-register. You will be issued an API key (ra_...) and one or more pre-created agent IDs to use in this integration.


What you're building

A browser voice call: a user clicks a button, microphone connects, they have a real-time conversation with an AI voice agent (sub-second latency), and the transcript streams back to your UI.

┌──────────────────┐    POST /v1/calls/web    ┌─────────────────────┐
│  Your backend    │ ───────────────────────▶ │  Relay Agent API    │
│  (API route)     │ ◀─────────────────────── │  api.relayagent.com │
└──────────────────┘   { accessToken,         └─────────────────────┘
       │                  websocketUrl }                ▲
       │ returns token + URL                            │ WSS audio
       ▼                                                │
┌──────────────────┐                                    │
│  Your frontend   │ ───── RelayWebCall(WSS) ───────────┘
│  (React/Vue/JS)  │
└──────────────────┘

You implement two things:

  1. A backend route (server-side) that creates a web call using @relay-agent/sdk and returns the short-lived access token + WebSocket URL to your client.
  2. A frontend component that uses the RelayWebCall class (drop-in, zero dependencies) to capture the mic and play back the agent's audio.

You never expose your ra_... key to the browser. The client only ever sees a short-lived, call-scoped rat_... token returned by the backend.


Migrating from raw fetch to @relay-agent/sdk

If you're already hitting the Relay REST API directly (e.g. via Supabase edge functions, Next.js API routes, or a Cloudflare Worker), this table maps every public endpoint to the SDK call that replaces it. All SDK methods are typed, validated, retried on 5xx/429/408 with exponential backoff, and throw typed errors on non-2xx.

Raw call SDK call
POST /v1/provisioning/provision client.provisioning.provision({ email, name, orgName, source, externalUserId }) → returns { user, org, magicLink }.
POST /v1/provisioning/upgrade-to-platform client.provisioning.upgradeToPlatform({ orgId, apiKeyName? }) → returns { success, org, apiKey } (key shown once).
POST /v1/provisioning/deprovision client.provisioning.deprovision({ source, externalUserId }).
Provision + mint magic-link, host rewriting Just use client.provisioning.provision(...) — the returned magicLink is already correct for the deployment. No rewriting needed.
GET /v1/agents client.agents.list({ limit?, offset? }){ data, hasMore }. Single canonical shape — no need for the 3-overload extractor.
POST /v1/agents client.agents.create(params).
PUT /v1/agents/{id} ⚠️ The server only accepts PATCH, not PUT. Switch to client.agents.update(id, params) which uses PATCH. (Existing PUT → POST fallback code creates a duplicate agent silently — fix this when you migrate.)
POST /v1/agents/{id}/publish client.agents.publish(id, { versionTitle?, versionDescription? }).
GET /v1/templates/agents client.templates.listAgents({ industry?, tag?, search? }).
GET /v1/templates/workflows client.templates.listWorkflows({ industry?, tag?, search? }).
POST /v1/templates/agents/{id}/create (and the 4 fallback URL shapes) client.templates.createFromAgentTemplate(id, overrides?) — single canonical endpoint, no guessing.
POST /v1/templates/workflows/{id}/create (and the 4 fallback URL shapes) client.templates.createFromWorkflowTemplate(id, opts?) — same.
GET /v1/workflows client.workflows.list(){ data: WorkflowSummary[] }. (Composed agents instantiated from workflow templates; distinct from client.agents.listComposed() which returns all composed agents.)
POST /v1/calls/phone client.calls.createPhone({ agentId, from, to, dynamicVariables?, metadata?, composedAgentId? }).
POST /v1/calls/web client.calls.createWeb({ agentId, dynamicVariables?, metadata?, composedAgentId? }){ accessToken, websocketUrl, callId }. Token is short-lived and call-scoped.
GET /v1/calls/{id} client.calls.get(id, { includeOriginal? }).
WSS connect for browser audio The drop-in RelayWebCall (Step 3 below) handles the WebSocket, mic capture (PCM16 via AudioWorklet), playback, and events.
Webhook signature verification RelayClient.verify(rawBody, WEBHOOK_SIGNING_SECRET, signatureHeader) — note the second arg is the webhook signing secret, NOT your API key. See the Webhooks section below.

If you're on Supabase edge functions

The SDK runs anywhere fetch is available, including Deno-based Supabase functions. Skip npm install and import via esm.sh:

// supabase/functions/example/index.ts
import { RelayClient } from "https://esm.sh/@relay-agent/sdk";

Deno.serve(async (req) => {
  // Per-tenant API key (your existing pattern: tenant_api_keys table, fall
  // back to platform key). Construct the client per request — the SDK is
  // cheap to instantiate.
  const tenantKey = await getTenantKey(req); // your existing helper
  const apiKey = tenantKey ?? Deno.env.get("RELAY_PLATFORM_API_KEY")!;

  const relay = new RelayClient({
    apiKey,
    baseUrl: Deno.env.get("RELAY_API_URL")!,
  });

  const call = await relay.calls.createWeb({
    agentId: /* resolved from your DB */ "agent_xxx",
  });
  return new Response(JSON.stringify(call), { headers: { "Content-Type": "application/json" } });
});

This replaces the body construction in create-web-call, including the accessToken shape and websocketUrl assembly — the SDK returns both fields ready to use.

Deno / serverless edge gotchas

The SDK is plain ESM and works under Deno, Bun, Cloudflare Workers, Vercel Edge, and Supabase Edge Functions (Deno 1.40+). A few tuning notes from production:

  • Network permission. Deno Deploy needs --allow-net for the Relay API host. In Supabase functions this is implicit; on Deno Deploy declare it.
  • Request-scoped timeouts. Most serverless platforms kill the request handler after 10–60 seconds. The SDK default is maxRetries: 2 with exponential backoff (~0.5s + 1s = 1.5s ceiling for retries). That's safe. If you raise maxRetries, ensure the worst-case wait fits inside your runtime's request timeout — or set maxRetries: 0 on serverless and let the client retry.
  • Cold-start instantiation cost. new RelayClient(...) is essentially free (no network at construction time), so creating one per request inside the handler is fine — preferred, in fact, when you're switching between tenant keys.

Multi-tenant provisioning semantics

client.provisioning.provision({ email, name, orgName, source, externalUserId, externalOrgId? }) is idempotent on (source, externalOrgId) (when provided) or (source, externalUserId):

  • First call: creates a user, an org, a membership, seeds system tools, returns { provisioned: true, user, org, magicLink }.
  • Subsequent calls with the same (source, externalOrgId): does NOT re-create. Returns { provisioned: false, reason: 'already_exists', user, org, magicLink }magicLink is freshly minted each time, so you can use the call as a "give me a sign-in link" operation.
  • source is the namespace (e.g. "find_legal_counsel", "acme_internal"). Choose one per integrating platform.
  • externalUserId / externalOrgId are scoped per source — collision across different sources is fine.
  • Collisions across emails (same email, different source): the email is normalized lowercase; if a user with that email already exists, they're added as a member of the new org rather than re-created.

Idempotency-Key (safe retries)

POST /v1/calls/phone and POST /v1/calls/web honor the Idempotency-Key header (Stripe/OpenAI-compatible):

  • Same key + same body → cached response returned, with idempotent-replay: true response header. Safe on flaky mobile networks.
  • Same key + different body → 422 with code idempotency_conflict. Catch and use a fresh key.
  • TTL: 24 hours per key, scoped to your org.
const idempotencyKey = crypto.randomUUID();
const call = await fetch("/v1/calls/web", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, "Authorization": `Bearer ${apiKey}` },
  body: JSON.stringify({ agentId }),
}).then((r) => r.json());

(The SDK doesn't expose this header as a method arg yet — pass headers via baseUrl proxy if you need it; native SDK support is queued for the next minor.)

call.analyzed webhook + waitForAnalysis helper

Post-call analysis (summary, sentiment, topics, resolution, etc.) finishes seconds to tens of seconds after call.ended. Two ways to react:

Push (webhook):

import type { CallAnalyzedEvent } from "@relay-agent/sdk";

// Fires when analysis is persisted; the `data` includes a quick summary/sentiment
// snapshot, and the full analysis is on GET /v1/calls/{id}.
function onCallAnalyzed(event: CallAnalyzedEvent) {
  console.log(`Call ${event.callId}: ${event.data.summary}`);
  // Optionally re-fetch the call for the full analysis payload
}

Pull (SDK helper):

const call = await client.calls.waitForAnalysis(callId, { timeoutMs: 60_000 });
console.log(call.analysis?.summary);

waitForAnalysis polls GET /v1/calls/{id} with exponential backoff (1s → 5s cap) until call.analysis is populated or the deadline hits. Lets you delete external "sync this call" cron jobs.

Webhook signing secret — self-serve

The webhook signing secret is now returned in every provision() response and via a dedicated endpoint:

const { webhookSigningSecret } = await client.provisioning.getWebhookSecret();
// Store securely; pass to RelayClient.verify(body, webhookSigningSecret, signature).

API stability — /v1/

The /v1/ API surface is additive-only:

  • New endpoints, new optional fields, new event types, new webhook headers — all non-breaking and may ship in any release.
  • Removing endpoints, changing response field types, or renaming event types are breaking and would require /v2/.
  • Analysis payload shape changes (e.g. adding/removing fields under call.analysis) are treated as breaking — pin to a known shape via the SDK types.

Known gaps & workarounds

These are real issues observed in production integrations; the SDK doesn't paper over them, but here's the recommended handling.

  1. Web-call call.ended webhook reliability. Relay does dispatch call.ended for web calls; failures observed in the wild have been receiving endpoints returning non-2xx (signature mismatch, body validation), after which Relay's retry queue gives up. Triage your endpoint first: check that you're verifying with WEBHOOK_SIGNING_SECRET (not the API key). As a defensive pattern, poll client.calls.get(callId) with backoff until status === 'completed' or 'failed' — keep the polling fallback for one release while you confirm webhook delivery from your side.

  2. Analysis field shape drift. Defensive reads (summary, sentiment, successEvaluation, recording_url) appearing at different paths reflect transient server changes. The Call and CallAnalysisResult types document the current canonical shape — but keep defensive ?. access for one release after you migrate, and report any mismatches.

  3. PUT /v1/agents/{id} returns 405. Use client.agents.update(id, params) (PATCH). Any "PUT-then-POST-fallback" code path is silently creating duplicate agents today — audit before deleting.


Prerequisites

Item What you need How to get it
API key (ra_...) Server-side credential for @relay-agent/sdk. Treat like a database password. Request one from the Relay Agent team (invite-only).
Agent ID (agent_...) Identifies which voice agent answers the call. The team will provision a default agent for you, or you can create one (see Creating an agent below).
Base URL The Relay Agent API endpoint. Default: https://api.relayagent.com. May be a custom deployment URL — confirm with the team. Provided alongside the API key.
HTTPS in production Browsers require HTTPS for microphone access. localhost works for dev. Standard for any modern deployment.
Node 18+ on your backend Required by @relay-agent/sdk. n/a

Step 1 — Install the SDK on your backend

npm install @relay-agent/sdk

Set these environment variables on your backend (Vercel/Netlify/your own server):

RELAY_API_KEY=ra_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
RELAY_AGENT_ID=agent_xxxxxxxxxxxxxxxxxxxxxxxx
RELAY_BASE_URL=https://api.relayagent.com   # or your custom deployment URL

Step 2 — Create the backend route

This route is called by your frontend right before opening a voice call. It uses your secret ra_ key (server-only) to create a web call and returns the short-lived accessToken + websocketUrl for the client to connect with.

Next.js (App Router) — app/api/voice-call/route.ts

import { NextResponse } from "next/server";
import { RelayClient } from "@relay-agent/sdk";

const relay = new RelayClient({
  apiKey: process.env.RELAY_API_KEY!,
  baseUrl: process.env.RELAY_BASE_URL, // optional; defaults to api.relayagent.com
});

export async function POST() {
  // (Optional) Authenticate the user here — block anonymous calls if you want.
  // e.g. const session = await getSession();
  //      if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const call = await relay.calls.createWeb({
    agentId: process.env.RELAY_AGENT_ID!,
    // Optional: pass dynamic per-call context the agent can reference in its
    // prompt as {{customer_name}} etc. Values MUST be flat strings —
    // dynamicVariables is typed `Record<string, string>` on the platform.
    // No nested objects, no auto-flattening: serialize them yourself if you
    // need structured data (e.g. JSON.stringify) and parse in the agent prompt.
    // dynamicVariables: { customer_name: session.user.name, plan: "pro" },
    // Optional: arbitrary metadata stored on the call record.
    // metadata: { userId: session.user.id, source: "homepage_widget" },
  });

  // accessToken is a short-lived `rat_...` token. websocketUrl is a relative path.
  // The frontend will resolve the full WSS URL.
  return NextResponse.json({
    accessToken: call.accessToken,
    websocketUrl: call.websocketUrl,
    callId: call.id,
  });
}

Express — routes/voice-call.ts

import express from "express";
import { RelayClient } from "@relay-agent/sdk";

const relay = new RelayClient({
  apiKey: process.env.RELAY_API_KEY!,
  baseUrl: process.env.RELAY_BASE_URL,
});

const router = express.Router();

router.post("/voice-call", async (_req, res) => {
  const call = await relay.calls.createWeb({
    agentId: process.env.RELAY_AGENT_ID!,
  });
  res.json({
    accessToken: call.accessToken,
    websocketUrl: call.websocketUrl,
    callId: call.id,
  });
});

export default router;

Response shape

{
  callId: string;        // e.g. "call_25uWA3hNXt0LKdaiC24Iw"
  accessToken: string;   // e.g. "rat_tok_qQg377YxBbwnrKVt933kPg" (scoped to this one call)
  websocketUrl: string;  // e.g. "/v1/calls/web-stream?callId=...&agentId=...&token=..."
}

What the platform actually returns

The Relay Agent platform returns websocketUrl as a relative path with the token already embedded in the query string:

/v1/calls/web-stream?callId=call_xxx&agentId=agent_xxx&token=rat_xxx

Your frontend either (a) prepends wss:// + your Relay Agent host before connecting, or (b) connects same-origin if your app is hosted under the same domain as the Relay API.

If your backend wraps POST /v1/calls/web (e.g. via a Supabase edge function) and rewrites this to an absolute URL like wss://api.relayagent.com/..., that's a choice your wrapper made — the platform itself does not rewrite. The RelayWebCall client below handles both shapes safely and won't double-append token= if the URL already has it.


Step 3 — Drop in the browser client

Save the following as lib/relay-web-call.ts in your frontend project. Zero external dependencies. Works in any browser project (React, Vue, vanilla JS, Svelte, etc.).

/**
 * RelayWebCall — Complete, self-contained web call client for Relay Agent.
 *
 * Drop this file into any browser project. Zero external dependencies.
 * Handles: mic capture → PCM16 encoding → WebSocket streaming → audio playback.
 *
 * Usage:
 *   const call = new RelayWebCall();
 *   call.on('connected', () => console.log('Connected'));
 *   call.on('transcript', (e) => console.log(`${e.role}: ${e.text}`));
 *   call.on('disconnected', () => console.log('Done'));
 *   await call.connect({ websocketUrl, accessToken });
 *   // later:
 *   call.mute();   call.unmute();   call.end();
 */

// ── Types ───────────────────────────────────────────────────────────────────

export interface RelayConnectOptions {
  /** WebSocket URL returned by POST /v1/calls/web. May be relative (starts with `/`); the client will prepend `wss://` + the value of `relayHost` or the current origin. */
  websocketUrl: string;
  /** Access token returned by POST /v1/calls/web. */
  accessToken: string;
  /** Host to use when `websocketUrl` is relative. Defaults to the current page origin. */
  relayHost?: string;
  /** Sample rate for capture and playback. Default: 16000. */
  sampleRate?: number;
}

export type RelayEvent =
  | { type: 'connected' }
  | { type: 'disconnected'; reason?: string }
  | { type: 'transcript'; text: string; role: 'user' | 'agent'; isFinal: boolean }
  | { type: 'agent_speaking'; speaking: boolean }
  | { type: 'error'; error: Error }
  | { type: 'debug'; message: string };

type EventCallback = (event: RelayEvent) => void;

// ── AudioWorklet processor source (inline, no external file needed) ─────────

const WORKLET_SOURCE = `
class PcmCaptureProcessor extends AudioWorkletProcessor {
  process(inputs) {
    const input = inputs[0]?.[0];
    if (input && input.length > 0) {
      const pcm16 = new Int16Array(input.length);
      for (let i = 0; i < input.length; i++) {
        const s = Math.max(-1, Math.min(1, input[i]));
        pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
      }
      this.port.postMessage(pcm16.buffer, [pcm16.buffer]);
    }
    return true;
  }
}
registerProcessor('pcm-capture-processor', PcmCaptureProcessor);
`;

// ── Main class ──────────────────────────────────────────────────────────────

export class RelayWebCall {
  private ws: WebSocket | null = null;
  private audioContext: AudioContext | null = null;
  private mediaStream: MediaStream | null = null;
  private workletNode: AudioWorkletNode | null = null;
  private scriptNode: ScriptProcessorNode | null = null;
  private sourceNode: MediaStreamAudioSourceNode | null = null;

  private playbackCtx: AudioContext | null = null;
  private nextPlayTime = 0;

  private sampleRate = 16000;
  private muted = false;
  private agentSpeaking = false;
  private connected = false;
  private listeners: EventCallback[] = [];

  on(type: RelayEvent['type'] | '*', callback: EventCallback): void;
  on(callback: EventCallback): void;
  on(first: RelayEvent['type'] | '*' | EventCallback, second?: EventCallback): void {
    if (typeof first === 'function') {
      this.listeners.push(first);
    } else if (second) {
      this.listeners.push((e) => { if (first === '*' || e.type === first) second(e); });
    }
  }

  async connect(options: RelayConnectOptions): Promise<void> {
    if (this.ws) throw new Error('Already connected. Call end() first.');

    this.sampleRate = options.sampleRate ?? 16000;

    const url = this.buildUrl(options.websocketUrl, options.accessToken, options.relayHost);
    this.ws = new WebSocket(url);
    this.ws.binaryType = 'arraybuffer';
    await this.waitOpen();

    this.ws.onmessage = (e) => this.handleMessage(e);
    this.ws.onclose = () => this.handleClose();
    this.ws.onerror = () => this.emit({ type: 'error', error: new Error('WebSocket error') });

    await this.startCapture();

    this.playbackCtx = new AudioContext({ sampleRate: this.sampleRate });
    this.nextPlayTime = 0;

    this.connected = true;
    this.emit({ type: 'connected' });
    this.emit({ type: 'debug', message: `Connected via WebSocket, sampleRate=${this.sampleRate}` });
  }

  mute(): void {
    this.muted = true;
    this.sendJson({ type: 'mute', muted: true });
    this.emit({ type: 'debug', message: 'Muted' });
  }

  unmute(): void {
    this.muted = false;
    this.sendJson({ type: 'mute', muted: false });
    this.emit({ type: 'debug', message: 'Unmuted' });
  }

  end(): void {
    this.sendJson({ type: 'end_call' });
    this.cleanup();
    this.emit({ type: 'debug', message: 'Call ended by client' });
  }

  // ── Mic capture (AudioWorklet with ScriptProcessorNode fallback) ────────

  private async startCapture(): Promise<void> {
    this.mediaStream = await navigator.mediaDevices.getUserMedia({
      audio: {
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
        sampleRate: this.sampleRate,
      },
    });

    this.audioContext = new AudioContext({ sampleRate: this.sampleRate });
    this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);

    try {
      const blob = new Blob([WORKLET_SOURCE], { type: 'application/javascript' });
      const workletUrl = URL.createObjectURL(blob);
      await this.audioContext.audioWorklet.addModule(workletUrl);
      URL.revokeObjectURL(workletUrl);

      this.workletNode = new AudioWorkletNode(this.audioContext, 'pcm-capture-processor');
      this.workletNode.port.onmessage = (e: MessageEvent) => {
        if (this.muted || this.agentSpeaking || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
        this.ws.send(e.data as ArrayBuffer);
      };
      this.sourceNode.connect(this.workletNode);
      this.workletNode.connect(this.audioContext.destination);
      this.emit({ type: 'debug', message: 'Mic capture via AudioWorklet' });
    } catch {
      const bufferSize = 4096;
      this.scriptNode = this.audioContext.createScriptProcessor(bufferSize, 1, 1);
      this.scriptNode.onaudioprocess = (e: AudioProcessingEvent) => {
        if (this.muted || this.agentSpeaking || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
        const input = e.inputBuffer.getChannelData(0);
        const pcm16 = new Int16Array(input.length);
        for (let i = 0; i < input.length; i++) {
          const s = Math.max(-1, Math.min(1, input[i]));
          pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
        }
        this.ws!.send(pcm16.buffer);
      };
      this.sourceNode.connect(this.scriptNode);
      this.scriptNode.connect(this.audioContext.destination);
      this.emit({ type: 'debug', message: 'Mic capture via ScriptProcessorNode (fallback)' });
    }
  }

  // ── Audio playback ──────────────────────────────────────────────────────

  private playAudio(pcm16: ArrayBuffer): void {
    if (!this.playbackCtx) return;
    const int16 = new Int16Array(pcm16);
    const float32 = new Float32Array(int16.length);
    for (let i = 0; i < int16.length; i++) {
      float32[i] = int16[i] / 32768;
    }
    const buffer = this.playbackCtx.createBuffer(1, float32.length, this.sampleRate);
    buffer.getChannelData(0).set(float32);
    const source = this.playbackCtx.createBufferSource();
    source.buffer = buffer;
    source.connect(this.playbackCtx.destination);
    const now = this.playbackCtx.currentTime;
    if (this.nextPlayTime < now) this.nextPlayTime = now;
    source.start(this.nextPlayTime);
    this.nextPlayTime += buffer.duration;
  }

  // ── WebSocket message handling ──────────────────────────────────────────

  private handleMessage(event: MessageEvent): void {
    if (event.data instanceof ArrayBuffer) {
      this.playAudio(event.data);
      return;
    }
    try {
      const msg = JSON.parse(event.data as string);
      switch (msg.type) {
        case 'agent_config':
          if (typeof msg.sampleRate === 'number' && msg.sampleRate !== this.sampleRate) {
            this.sampleRate = msg.sampleRate;
            if (this.playbackCtx) {
              this.playbackCtx.close().catch(() => {});
              this.playbackCtx = new AudioContext({ sampleRate: this.sampleRate });
              this.nextPlayTime = 0;
            }
          }
          break;
        case 'transcript':
          this.emit({
            type: 'transcript',
            text: msg.content ?? msg.text ?? '',
            role: msg.role ?? 'agent',
            isFinal: msg.isFinal ?? false,
          });
          break;
        case 'agent_speaking':
          this.agentSpeaking = Boolean(msg.speaking);
          this.emit({ type: 'agent_speaking', speaking: this.agentSpeaking });
          break;
        case 'clear_audio':
          this.nextPlayTime = 0;
          break;
        case 'call_ended':
          this.emit({ type: 'disconnected', reason: 'server_ended' });
          this.cleanup();
          break;
        case 'error':
          this.emit({ type: 'error', error: new Error(String(msg.message ?? msg.error ?? 'Unknown')) });
          break;
      }
    } catch {
      // Non-JSON text frame, ignore
    }
  }

  private handleClose(): void {
    const was = this.connected;
    this.cleanup();
    if (was) this.emit({ type: 'disconnected', reason: 'websocket_closed' });
  }

  private buildUrl(base: string, token: string, host?: string): string {
    let full = base;
    if (full.startsWith('/')) {
      const origin = host ?? (typeof window !== 'undefined' ? window.location.origin : '');
      full = origin.replace(/^http/, 'ws').replace(/\/$/, '') + full;
    } else if (full.startsWith('http')) {
      full = full.replace(/^http/, 'ws');
    }
    // The platform's websocketUrl already embeds `token=...` in the query
    // string. Only append if it's missing (e.g. a custom edge-function
    // wrapper stripped it).
    if (/[?&]token=/.test(full)) return full;
    const sep = full.includes('?') ? '&' : '?';
    return `${full}${sep}token=${encodeURIComponent(token)}`;
  }

  private waitOpen(): Promise<void> {
    return new Promise((resolve, reject) => {
      if (!this.ws) return reject(new Error('No WebSocket'));
      if (this.ws.readyState === WebSocket.OPEN) return resolve();
      this.ws.onopen = () => resolve();
      this.ws.onerror = () => reject(new Error('WebSocket connection failed'));
    });
  }

  private sendJson(msg: Record<string, unknown>): void {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(msg));
  }

  private emit(event: RelayEvent): void {
    for (const fn of this.listeners) {
      try { fn(event); } catch { /* listener error */ }
    }
  }

  private cleanup(): void {
    this.connected = false;
    if (this.workletNode) { this.workletNode.disconnect(); this.workletNode = null; }
    if (this.scriptNode) { this.scriptNode.onaudioprocess = null; this.scriptNode.disconnect(); this.scriptNode = null; }
    if (this.sourceNode) { this.sourceNode.disconnect(); this.sourceNode = null; }
    if (this.audioContext) { this.audioContext.close().catch(() => {}); this.audioContext = null; }
    if (this.mediaStream) { this.mediaStream.getTracks().forEach((t) => t.stop()); this.mediaStream = null; }
    if (this.playbackCtx) { this.playbackCtx.close().catch(() => {}); this.playbackCtx = null; }
    if (this.ws) {
      this.ws.onclose = null; this.ws.onmessage = null; this.ws.onerror = null;
      try { this.ws.close(); } catch { /* already closed */ }
      this.ws = null;
    }
  }
}

Step 4 — Wire up a React component

"use client";

import { useEffect, useRef, useState } from "react";
import { RelayWebCall } from "@/lib/relay-web-call";

// If your Relay Agent API is on a different host than your frontend,
// set this to e.g. "https://api.relayagent.com". Otherwise leave undefined
// and the client will use the current page origin.
const RELAY_HOST: string | undefined = process.env.NEXT_PUBLIC_RELAY_HOST;

export default function VoiceCallButton() {
  const callRef = useRef<RelayWebCall | null>(null);
  const [status, setStatus] = useState<"idle" | "connecting" | "live" | "ending">("idle");
  const [transcript, setTranscript] = useState<{ role: string; text: string }[]>([]);

  const start = async () => {
    setStatus("connecting");
    setTranscript([]);

    // 1. Ask your backend for a fresh access token.
    const res = await fetch("/api/voice-call", { method: "POST" });
    if (!res.ok) {
      setStatus("idle");
      alert("Could not start call");
      return;
    }
    const { accessToken, websocketUrl } = (await res.json()) as {
      accessToken: string;
      websocketUrl: string;
    };

    // 2. Connect via the drop-in browser client.
    const call = new RelayWebCall();
    callRef.current = call;

    call.on("connected", () => setStatus("live"));
    call.on("disconnected", () => {
      setStatus("idle");
      callRef.current = null;
    });
    call.on("transcript", (e) => {
      if (!e.isFinal) return; // ignore partials, append only finalized turns
      setTranscript((prev) => [...prev, { role: e.role, text: e.text }]);
    });
    call.on("error", (e) => console.error("RelayWebCall error:", e.error));

    try {
      await call.connect({ websocketUrl, accessToken, relayHost: RELAY_HOST });
    } catch (err) {
      console.error(err);
      setStatus("idle");
    }
  };

  const stop = () => {
    setStatus("ending");
    callRef.current?.end();
  };

  // Clean up if the component unmounts mid-call
  useEffect(() => () => callRef.current?.end(), []);

  return (
    <div>
      {status === "idle" && <button onClick={start}>Talk to the AI</button>}
      {status === "connecting" && <button disabled>Connecting…</button>}
      {status === "live" && <button onClick={stop}>End call</button>}
      {status === "ending" && <button disabled>Ending…</button>}

      <ul>
        {transcript.map((t, i) => (
          <li key={i}>
            <strong>{t.role}:</strong> {t.text}
          </li>
        ))}
      </ul>
    </div>
  );
}

That's the full integration. A button, a backend route, a drop-in client. ~150 lines of your code total.


Creating an agent (one-time setup)

If the team hasn't pre-provisioned an agent for you, create one once and reuse its id. Run this from a backend script (never from the browser):

import { RelayClient } from "@relay-agent/sdk";

const relay = new RelayClient({ apiKey: process.env.RELAY_API_KEY! });

const agent = await relay.agents.create({
  name: "Support Agent",
  model: {
    provider: "openai",
    model: "gpt-4.1-mini",
    systemPrompt:
      "You are a helpful customer support agent for Acme Inc. " +
      "Keep responses concise and natural for spoken conversation.",
  },
  voice: {
    provider: "elevenlabs",
    voiceId: "rachel",
  },
  firstMessage: "Hi, this is Acme support. How can I help you today?",
});

// Publish to make it callable.
await relay.agents.publish(agent.id);

console.log("Agent ID — put this in RELAY_AGENT_ID:", agent.id);

Provider options:

  • LLM: openai, anthropic, xai, deepseek, together, cerebras, openrouter.
  • Voice: elevenlabs, cartesia, minimax, inworld.
  • STT (optional override): deepgram, assemblyai.

Event reference (RelayEvent)

type Payload Fires when
connected The WebSocket is open and the mic is capturing.
disconnected { reason?: string } The server closed the call (server_ended) or the socket dropped (websocket_closed).
transcript { text, role: 'user' | 'agent', isFinal } An utterance was transcribed. Use isFinal: true for completed turns; partials stream as the user/agent speaks.
agent_speaking { speaking: boolean } The agent has started or stopped audio output. Useful for showing a "speaking" indicator and disabling mic visualization while the agent talks.
error { error: Error } Any error during the call.
debug { message: string } Lifecycle log — useful while integrating, ignore in production.

SDK surface — what's available on RelayClient

The SDK currently covers 17 resources. Each maps to /v1/* REST endpoints, fully typed.

Namespace What it does
client.agents CRUD agents, publish/unpublish, versions (publish history + rollback), composed agents (multi-agent workflows).
client.calls createPhone, createWeb, list/get, transcripts, whisper (push text to agent mid-call), interject (force agent to say something), terminate, recording URLs, live event stream.
client.phoneNumbers Search available numbers, purchase, list/update/delete, sync from Twilio.
client.knowledgeBases CRUD + add/remove/list files, check processing status, query (RAG).
client.voices List with filters (gender/accent/language/etc.), generate a TTS preview.
client.campaigns Batch outbound calling: create, start/pause/resume/cancel, add contacts, results.
client.abTests Create variant tests, start, promote winner.
client.usage Cost summary, daily breakdown, per-agent.
client.provisioning Multi-tenant: provision/deprovision a user + org from your platform (returns a magic-link URL).
client.sipTrunks BYOC SIP — credentials + IP ACLs.
client.tools Custom function tools the agent can call mid-conversation.
client.templates Pre-built agent + workflow templates.
client.analytics Latency percentiles (overall + STT/LLM/TTS breakdown).
client.guardrails Per-agent behavioral rules; AI-extract guardrails from a poorly-rated call.
client.testSuites Scenario-based agent regression testing with scored runs.
client.transferDirectory Managed list of transfer destinations (phone + SIP) with bulk import.
client.heartbeatTasks Background AI tasks that monitor your platform and notify on events.

Full typed reference: https://www.npmjs.com/package/@relay-agent/sdk


Verifying a webhook from Relay Agent

When you give Relay Agent a webhookUrl (per-call, per-agent serverUrl, or per-org), it POSTs signed JSON for events like call.started, call.ended, tool.invoked, transcript.final, etc.

Headers Relay sends:

Header Value
Content-Type application/json
X-Relay-Signature HMAC-SHA256 of the raw request body, hex-encoded
X-Relay-Event The event type, e.g. call.ended
X-Relay-Timestamp ISO 8601 timestamp when the event was dispatched

The signing secret is your WEBHOOK_SIGNING_SECRET, NOT your API key. They are different values on the server side. Get the WEBHOOK_SIGNING_SECRET from whoever issued your API key — it's the shared secret your Relay Agent deployment was configured with.

import { RelayClient } from "@relay-agent/sdk";
import type { WebhookEvent } from "@relay-agent/sdk";

export async function POST(request: Request) {
  const raw = await request.text();
  const signature = request.headers.get("x-relay-signature") ?? "";

  if (!RelayClient.verify(raw, process.env.RELAY_WEBHOOK_SECRET!, signature)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(raw) as WebhookEvent;
  switch (event.type) {
    case "call.ended":
      console.log(`Call ${event.data.callId} ended:`, event.data.reason);
      console.log(`Cost: ${event.data.cost.totalCents} cents`);
      break;
    case "transcript.final":
      // Persist or display the final transcript
      break;
  }
  return new Response("OK");
}

Per-event TypeScript types

WebhookEvent is a discriminated union on type, so switch (event.type) already narrows in TypeScript. For function-handler ergonomics, named per-event aliases are exported:

import type {
  CallStartedEvent,
  CallEndedEvent,
  CallFailedEvent,
  CallTransferredEvent,
  TranscriptFinalEvent,
  ToolInvokedEvent,
  ToolResponseEvent,
  AgentTransitionedEvent,
  EmailSentEvent,
  MessageCreatedEvent,
  CallbackScheduledEvent,
  HeartbeatCompletedEvent,
} from "@relay-agent/sdk";

function onCallEnded(e: CallEndedEvent) {
  // e.data is fully typed as CallEndedData
  console.log(e.data.cost.totalCents, e.data.durationMs);
}

Delivery & retry policy

Relay's webhook dispatcher uses BullMQ-backed retries with exponential backoff:

  • 5 total attempts per event.
  • Backoff: 1s, 2s, 4s, 8s, 16s (exponential, base 1s).
  • Total time window from first attempt to final failure: ~31 seconds.
  • After the 5th failure, the event is logged as failed and no further delivery is attempted. Plan idempotency around a ~30-second window, not minutes.
  • A response is considered successful when the status is 2xx; any non-2xx counts as a failure and triggers the next retry.

Your endpoint should return 2xx as quickly as possible — process work asynchronously, don't block the response on heavy lifting. If you need to persist for later processing, ack first then enqueue.

Full event catalog

Event type Trigger data shape
call.started Phone call moves to in-progress. Not currently fired for web calls — see issue #13. CallStartedData (Record<string, never>; the envelope's callId is the key field).
call.ended Call completes normally. CallEndedData — duration, reason, cost breakdown, transcript reference.
call.failed Call terminates due to error (provider failure, initiation_failed, etc.). CallEndedData (same shape; check reason).
call.transferred Agent performs a transfer (warm or cold). CallTransferredData — destination, transfer type.
transcript.final A finalized speaker turn is committed (after analysis pass). TranscriptFinalData — role, text, timestamps.
tool.invoked Agent calls a tool. ToolInvokedData — tool name, args.
tool.response Tool returns a result. ToolResponseData — tool name, result.
agent.transitioned Composed-agent workflow moves between roles. AgentTransitionedData — from/to role, reason.
email.sent Built-in email tool sent a message. EmailSentData.
message.created A scheduled outbound SMS/email message was created. MessageCreatedData.
callback.scheduled Caller requested a callback (built-in tool). CallbackScheduledData.
heartbeat.completed A client.heartbeatTasks run finished. HeartbeatCompletedData.

All envelopes share: { id, type, occurredAt, callId?, agentId?, orgId, data }.

Configuring your webhook endpoint

Set the destination URL at one of these levels (resolution order):

  1. Agent-level serverUrl — wins for events scoped to that agent.
  2. Org-level webhookUrl — fallback for everything else.

The WEBHOOK_SIGNING_SECRET is a single value per Relay Agent deployment; the same secret signs every event. There is no per-org webhook secret today — that's tracked in #13 as a gap (you currently have to ask whoever issued your API key for it).


Error handling

Every SDK method throws a typed error on non-2xx responses:

import {
  RelayAPIError,        // base
  BadRequestError,      // 400
  AuthenticationError,  // 401 — bad/missing API key
  PaymentRequiredError, // 402 — org over spend cap
  ForbiddenError,       // 403
  InviteOnlyError,      // 403 with code 'invite_only' — sign-up gated
  NotFoundError,        // 404
  ConflictError,        // 409
  RateLimitError,       // 429
  InternalServerError,  // 500
} from "@relay-agent/sdk";

try {
  await relay.calls.createWeb({ agentId: "agent_xxx" });
} catch (err) {
  if (err instanceof PaymentRequiredError) {
    // The org has hit its outstanding-balance cap. Show a billing prompt.
  } else if (err instanceof NotFoundError) {
    // The agentId doesn't exist or isn't visible to this API key's org.
  } else if (err instanceof RelayAPIError) {
    console.error(`Relay error [${err.code}] (status ${err.statusCode}):`, err.message);
  }
}

Common pitfalls

  • Never put ra_ keys in client code. The frontend only ever sees the short-lived, call-scoped rat_ token returned by your backend. Anyone who gets a ra_ key gets full org access.
  • HTTPS is required in production for microphone access. localhost is fine for dev.
  • One call per RelayWebCall instance. Call end() before starting a new one (or create a fresh instance).
  • Don't poll for transcripts. They stream via the transcript event. Use isFinal: true to detect finalized turns; partial transcripts are useful for live captions.
  • Mute does not stop the agent. It only suppresses outbound mic frames. Use end() to terminate the call.
  • webhookUrl events are signed with HMAC-SHA256 using your WEBHOOK_SIGNING_SECRET (not your API key — they are different values). Always verify before processing.
  • Spend cap is enforced server-side. POST /v1/calls/web returns 402 when an org's outstanding balance exceeds its cap. Surface this gracefully in your UI.
  • Sign-up is invite-only right now. If your platform users need their own Relay Agent accounts, contact the team to be set up as a provisioning partner — you'll get a key that can create users on demand via client.provisioning.provision({...}).

Reference — full SDK example

import { RelayClient } from "@relay-agent/sdk";

const relay = new RelayClient({
  apiKey: process.env.RELAY_API_KEY!,
  baseUrl: process.env.RELAY_BASE_URL, // optional
  timeout: 30000,                       // ms, default 30s
  maxRetries: 2,                        // default 2 (retries on 5xx, 429, 408 w/ exp backoff)
});

// Create an agent
const agent = await relay.agents.create({ /* see "Creating an agent" above */ });

// Make an outbound phone call
await relay.calls.createPhone({
  agentId: agent.id,
  from: "+15551234567",
  to: "+15559876543",
});

// Create a web call (returns accessToken + websocketUrl for the browser)
const web = await relay.calls.createWeb({ agentId: agent.id });

// Query latency analytics
const latency = await relay.analytics.getLatency({ period: "7d" });
console.log(`P95 turn latency: ${latency.overall.p95}ms`);

// Run a scenario test suite
const suite = await relay.testSuites.create({ name: "Regression", agentId: agent.id });
const run = await relay.testSuites.run(suite.id);

Need a different integration?

This guide covers the browser voice call flow because it's the most common Lovable / no-code use case. If you need something else:

  • Outbound phone (auto-dialer / campaigns)client.campaigns.* + client.calls.createPhone.
  • Inbound phone (someone calls your Twilio number) — set inboundAgentId on a phone-number via client.phoneNumbers.update; Relay handles the rest, you just receive webhooks.
  • Multi-tenant SaaS where each of your users gets their own workspace + keyclient.provisioning.provision({ email, name, orgName, source, externalUserId }) returns a magicLink you redirect to.
  • Existing voice infrastructure with a SIP trunk you bringclient.sipTrunks.*.

Each of those follows the same SDK pattern. Ask the team for a tailored guide if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment