I have this setup in my edge function with the intended purpose of creating a new dispatch per incoming call to twilio which twilio is setup to call this webhook for edge fucntion.
Its also intended to pass the meatadata from the database based on the number called so it can be dispatched with the agent creation.
That part seems to work but then when we attempt to send the twinml to livekit, i get an endless ring if i dont have a dispatch rule serup indie livekit manually. If i add a manual dispatch rule the dispatch thats created by the webhook isnt used and the dispatch rule in livekit creates its own rule and agent runtime.
import "
https://deno.land/x/xhr@0.1.0/mod.ts";
import { serve } from "
https://deno.land/std@0.168.0/http/server.ts";
const cors = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type"
};
// load and log environment variables for debugging
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY") ?? "";
const LIVEKIT_URL = Deno.env.get("LIVEKIT_URL") ?? "";
const LIVEKIT_SIP_DOMAIN = Deno.env.get("LIVEKIT_SIP_DOMAIN") ?? "2xuw81c7g3b.sip.livekit.cloud";
const LIVEKIT_API_KEY = Deno.env.get("LIVEKIT_API_KEY") ?? "";
const LIVEKIT_API_SECRET = Deno.env.get("LIVEKIT_API_SECRET") ?? "";
const AGENT_NAME = Deno.env.get("AGENT_NAME") ?? "voice-ai-agent";
console.log("[env] SUPABASE_URL =", SUPABASE_URL);
console.log("[env] LIVEKIT_URL =", LIVEKIT_URL);
console.log("[env] LIVEKIT_SIP_DOMAIN =", LIVEKIT_SIP_DOMAIN);
function b64url(input: string) {
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function createAdminToken(room: string) {
if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) {
throw new Error('Missing LIVEKIT_API_KEY or LIVEKIT_API_SECRET');
}
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const now = Math.floor(Date.now() / 1000);
const payload = b64url(JSON.stringify({
iss: LIVEKIT_API_KEY,
sub: LIVEKIT_API_KEY,
nbf: now,
exp: now + 60,
video: { room, roomAdmin: true }
}));
const message = `${header}.${payload}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(LIVEKIT_API_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message));
const sig = b64url(String.fromCharCode(...new Uint8Array(sigBuf)));
return `${message}.${sig}`;
}
function errorTwiML() {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Sorry, we couldn’t connect you. Please try again later.</Say>
<Hangup/>
</Response>`;
}
//
* UPDATED: single-line SIP + inline headers
*
function connectTwiML(dest: string, sid: string, from: string) {
const d = dest.trim();
const dom = LIVEKIT_SIP_DOMAIN.trim();
if (!/^\+\d+$/.test(d) || !/^[\w.-]+$/.test(dom)) {
console.error('[connectTwiML] invalid dest/domain', { d, dom });
return errorTwiML();
}
// build URI + inline <Header> tags (no whitespace or newlines between)
const uri = `sip:${d}@${dom};transport=udp`;
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say>Connecting you now.</Say>
<Dial>
<Sip>${uri}<Header name="X-CallSid">${sid}</Header><Header name="X-Caller">${from}</Header></Sip>
</Dial>
</Response>`;
}
async function dispatchAgent(room: string, meta: Record<string,any>) {
if (!LIVEKIT_URL) throw new Error('Missing LIVEKIT_URL');
const base = LIVEKIT_URL.replace(/^wss?:/, "https:");
const url = `${base}/twirp/livekit.AgentDispatchService/CreateDispatch`;
const token = await createAdminToken(room);
console.log('[dispatch] POST', url, meta);
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization":
Bearer ${token}
},
body: JSON.stringify({
room,
agent_name: AGENT_NAME,
metadata: JSON.stringify(meta)
})
});
console.log('[dispatch] status', res.status);
if (!res.ok) {
const err = await res.text();
console.error('[dispatch] error', err);
throw new Error(err);
}
}
async function handleVoice(req: Request) {
let form: string;
try {
form = await req.text();
} catch (e) {
console.error('[voice] read body error', e);
return new Response(errorTwiML(), {
status: 200,
headers: { ...cors, "Content-Type": "application/xml" }
});
}
const p = new URLSearchParams(form);
const CallSid = p.get("CallSid") || "";
const From = p.get("From") || "";
const To = p.get("To") || "";
console.log('[voice] params', { CallSid, From, To });
if (!CallSid || !From || !To) {
console.error('[voice] missing CallSid/From/To');
return new Response(errorTwiML(), {
status: 200,
headers: { ...cors, "Content-Type": "application/xml" }
});
}
// fetch per-number config
let cfg: Record<string, any> = {};
if (SUPABASE_URL) {
try {
const supa = `${SUPABASE_URL}/rest/v1/rpc/get_agent_by_phone`;
console.log('[voice] supabase URL', supa);
const r = await fetch(supa, {
method: "POST",
headers: {
"Content-Type": "application/json",
"apikey": SUPABASE_ANON_KEY,
"Authorization":
Bearer ${SUPABASE_ANON_KEY}
},
body: JSON.stringify({ phone_num: To })
});
console.log('[voice] supabase status', r.status);
if (r.ok) {
const rows = await r.json();
console.log('[voice] supabase rows', rows);
if (Array.isArray(rows) && rows.length) cfg = rows[0];
}
} catch (e) {
console.warn('[voice] supabase error', e);
}
}
// dispatch + TwiML bridge
const room = `call-${CallSid}`;
const meta = { callSid: CallSid, caller: From, callee: To, ...cfg };
console.log('[voice] dispatch meta', meta);
try {
await dispatchAgent(room, meta);
console.log('[voice] dispatch OK');
} catch (e) {
console.error('[voice] dispatchAgent failed', e);
}
return new Response(connectTwiML(To, CallSid, From), {
status: 200,
headers: { ...cors, "Content-Type": "application/xml" }
});
}
async function handleStatus(req: Request) {
const t = await req.text();
console.log('[status]', t);
return new Response("OK", { status: 200, headers: cors });
}
serve((req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: cors });
}
const path = new URL(req.url).pathname;
if (path.endsWith("/voice")) return handleVoice(req);
if (path.endsWith("/status")) return handleStatus(req);
return new Response("Not found", { status: 404 });
});
Whats the best route to pass dynamic metadata from this edge function to connect the call to the same agent that was created with the original dispatch?