Skip to content

Instantly share code, notes, and snippets.

@amponce
Created May 29, 2026 22:10
Show Gist options
  • Select an option

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

Select an option

Save amponce/cb1149f16f39cba02a72417a9ba79761 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.

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
    // (available as {{customer_name}} etc. in the agent's system prompt).
    // dynamicVariables: { customer_name: session.user.name },
    // 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=..."
}

The websocketUrl is relative — your frontend will prepend the base origin (e.g. wss://api.relayagent.com) when connecting. The example client below handles that automatically if you pass a full URL; if it's relative, prepend wss:// + your Relay Agent host.


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');
    }
    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");
}

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