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.
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:
- A backend route (server-side) that creates a web call using
@relay-agent/sdkand returns the short-lived access token + WebSocket URL to your client. - A frontend component that uses the
RelayWebCallclass (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.
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. |
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.
These are real issues observed in production integrations; the SDK doesn't paper over them, but here's the recommended handling.
-
Web-call
call.endedwebhook reliability. Relay does dispatchcall.endedfor 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 withWEBHOOK_SIGNING_SECRET(not the API key). As a defensive pattern, pollclient.calls.get(callId)with backoff untilstatus === 'completed'or'failed'— keep the polling fallback for one release while you confirm webhook delivery from your side. -
Analysis field shape drift. Defensive reads (
summary,sentiment,successEvaluation,recording_url) appearing at different paths reflect transient server changes. TheCallandCallAnalysisResulttypes document the current canonical shape — but keep defensive?.access for one release after you migrate, and report any mismatches. -
PUT /v1/agents/{id}returns 405. Useclient.agents.update(id, params)(PATCH). Any "PUT-then-POST-fallback" code path is silently creating duplicate agents today — audit before deleting.
| 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 |
npm install @relay-agent/sdkSet 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 URLThis 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.
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,
});
}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;{
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.
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;
}
}
}"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.
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.
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. |
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
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");
}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);
}
}- Never put
ra_keys in client code. The frontend only ever sees the short-lived, call-scopedrat_token returned by your backend. Anyone who gets ara_key gets full org access. - HTTPS is required in production for microphone access.
localhostis fine for dev. - One call per
RelayWebCallinstance. Callend()before starting a new one (or create a fresh instance). - Don't poll for transcripts. They stream via the
transcriptevent. UseisFinal: trueto 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. webhookUrlevents are signed with HMAC-SHA256 using yourWEBHOOK_SIGNING_SECRET(not your API key — they are different values). Always verify before processing.- Spend cap is enforced server-side.
POST /v1/calls/webreturns 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({...}).
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);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
inboundAgentIdon aphone-numberviaclient.phoneNumbers.update; Relay handles the rest, you just receive webhooks. - Multi-tenant SaaS where each of your users gets their own workspace + key —
client.provisioning.provision({ email, name, orgName, source, externalUserId })returns amagicLinkyou redirect to. - Existing voice infrastructure with a SIP trunk you bring —
client.sipTrunks.*.
Each of those follows the same SDK pattern. Ask the team for a tailored guide if needed.