diff --git a/README.md b/README.md
index de1bf3f..f6e6f8c 100644
--- a/README.md
+++ b/README.md
@@ -31,11 +31,26 @@ npm start
This launches `http-server` on `http://localhost:8080` so you can verify styling and functionality before publishing.
+### GitHub Pages voice bridge (serverless)
+
+When the site is deployed to GitHub Pages you can use the bundled serverless functions instead of hosting the `server/` application yourself.
+
+1. Enable **Pages Functions** for the repository (Settings → Pages → Build and deployment → Functions).
+2. Add the following environment variables in the same settings screen so the functions can authenticate with Twilio:
+ - `TWILIO_ACCOUNT_SID`
+ - `TWILIO_AUTH_TOKEN`
+ - `TWILIO_PHONE_NUMBER`
+ - Optional: `POLLINATIONS_VOICE` for a default Pollinations preset, `ALLOWED_ORIGIN` to lock down CORS, and `POLLINATIONS_TOKEN` if you use a private Pollinations referrer/token.
+3. Redeploy the site. GitHub Pages will publish the functions at `/_functions/*`.
+4. Open the Unity chat settings and leave the **Voice bridge URL** blank. The UI will automatically call the serverless voice bridge.
+
+The traditional Node server is still available for self-hosting or when you deploy Unity Chat somewhere other than GitHub Pages.
+
### Starting a phone call from the UI
Open the **Settings** modal and scroll to the **Unity Phone Call** card. Provide:
-1. **Voice bridge URL** – The HTTPS base URL where you deployed the server found in `server/`. It must expose the `/api/start-call` endpoint.
+1. **Voice bridge URL** – The HTTPS base URL where you deployed the server found in `server/`, or leave the field blank to use the built-in GitHub Pages voice bridge.
2. **Phone number** – Destination number in E.164 format (e.g. `+15551234567`).
3. **Initial topic** (optional) – Unity will open the call with this context.
4. **Pollinations voice** – Voice preset the Twilio call should use (`nova`, `alloy`, `fable`, `onyx`, `shimmer`, or `echo`).
diff --git a/functions/_shared/voiceBridge.js b/functions/_shared/voiceBridge.js
new file mode 100644
index 0000000..0543332
--- /dev/null
+++ b/functions/_shared/voiceBridge.js
@@ -0,0 +1,221 @@
+const SYSTEM_PROMPT =
+ "You are Unity Voice, an AI assistant speaking with a caller over the phone. " +
+ "Keep every reply under 200 characters, speak naturally, and ask follow-up questions to keep the chat going.";
+
+const DEFAULT_GATHER_PROMPT =
+ "After the message, speak your reply and stay on the line for the assistant to respond.";
+
+const MAX_HISTORY_PAIRS = 6; // 6 user/assistant turns keeps URLs manageable
+
+function sanitizeForTts(text) {
+ if (!text) return "";
+ const compact = text.replace(/\s+/g, " ").trim();
+ if (compact.length <= 380) return compact;
+ return `${compact.slice(0, 377)}...`;
+}
+
+function createTtsUrl(text, voice = "nova") {
+ const sanitized = sanitizeForTts(text);
+ const encoded = encodeURIComponent(sanitized);
+ const url = new URL(`https://text.pollinations.ai/${encoded}`);
+ url.searchParams.set("model", "openai-audio");
+ url.searchParams.set("voice", voice);
+ return url.toString();
+}
+
+function escapeXml(str = "") {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function buildFunctionsBaseUrl(requestUrl) {
+ const parsed = new URL(requestUrl);
+ const idx = parsed.pathname.indexOf("/_functions");
+ if (idx === -1) {
+ return parsed.origin;
+ }
+ return `${parsed.origin}${parsed.pathname.slice(0, idx + "/_functions".length)}`;
+}
+
+function trimMessages(messages) {
+ if (!Array.isArray(messages)) return [];
+ const systemMessages = messages.filter(msg => msg?.role === "system");
+ const nonSystem = messages.filter(msg => msg?.role !== "system");
+ const trimmed = nonSystem.slice(-MAX_HISTORY_PAIRS * 2); // user + assistant pairs
+ if (systemMessages.length > 0) {
+ return [systemMessages[0], ...trimmed];
+ }
+ return trimmed;
+}
+
+function encodeState(state) {
+ const safeState = { ...state, messages: trimMessages(state.messages) };
+ const json = JSON.stringify(safeState);
+ return encodeURIComponent(btoa(json));
+}
+
+function decodeState(param) {
+ if (!param) return null;
+ try {
+ const json = atob(decodeURIComponent(param));
+ const parsed = JSON.parse(json);
+ if (!Array.isArray(parsed.messages)) parsed.messages = [];
+ return parsed;
+ } catch (error) {
+ console.error("Failed to decode session state", error);
+ return null;
+ }
+}
+
+function buildTwimlResponse(state, encodedState, functionsBase, gatherPrompt = DEFAULT_GATHER_PROMPT) {
+ const audioUrl = createTtsUrl(state.lastAssistant, state.voice);
+ const gatherUrl = `${functionsBase}/gather?state=${encodedState}`;
+ const prompt = escapeXml(gatherPrompt || DEFAULT_GATHER_PROMPT);
+
+ return `\n\n ${audioUrl}\n \n ${prompt}\n \n \n No response detected. Ending the call.\n \n`;
+}
+
+function buildErrorTwiml(message) {
+ const safe = escapeXml(message || "The session is no longer active. Goodbye.");
+ return `\n\n ${safe}\n \n`;
+}
+
+function buildCorsHeaders(request, allowedOrigin) {
+ const requestOrigin = request.headers.get("Origin");
+ const headers = new Headers();
+ headers.set("Access-Control-Allow-Methods", "POST, OPTIONS");
+ headers.set("Access-Control-Allow-Headers", "Content-Type");
+ headers.set("Vary", "Origin");
+
+ if (!allowedOrigin || allowedOrigin === "*") {
+ headers.set("Access-Control-Allow-Origin", "*");
+ return headers;
+ }
+
+ if (allowedOrigin === requestOrigin) {
+ headers.set("Access-Control-Allow-Origin", requestOrigin);
+ } else {
+ headers.set("Access-Control-Allow-Origin", allowedOrigin);
+ }
+ return headers;
+}
+
+async function fetchPollinationsResponse(env, state, userMessage) {
+ if (!state.messages) state.messages = [];
+ if (!state.voice) state.voice = env?.POLLINATIONS_VOICE || "nova";
+ if (!state.gatherPrompt) state.gatherPrompt = DEFAULT_GATHER_PROMPT;
+
+ const trimmed = typeof userMessage === "string" ? userMessage.trim() : "";
+ if (trimmed) {
+ state.messages.push({ role: "user", content: trimmed });
+ }
+
+ const payload = {
+ model: "openai",
+ messages: trimMessages(state.messages.length ? state.messages : [{ role: "system", content: SYSTEM_PROMPT }]),
+ temperature: 0.8,
+ max_output_tokens: 300,
+ top_p: 0.95,
+ presence_penalty: 0,
+ frequency_penalty: 0,
+ stream: false
+ };
+
+ const headers = new Headers({ "Content-Type": "application/json" });
+ const token = env?.POLLINATIONS_TOKEN || env?.pollinationsToken;
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ const response = await fetch("https://text.pollinations.ai/openai", {
+ method: "POST",
+ headers,
+ body: JSON.stringify(payload)
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Pollinations API error: ${response.status} ${text}`);
+ }
+
+ const data = await response.json();
+ const assistantMessage = data?.choices?.[0]?.message?.content?.trim();
+ if (!assistantMessage) {
+ throw new Error("Pollinations API returned an empty response.");
+ }
+
+ state.messages.push({ role: "assistant", content: assistantMessage });
+ state.messages = trimMessages(state.messages);
+ state.lastAssistant = assistantMessage;
+ return assistantMessage;
+}
+
+async function createInitialState(env, voice, initialPrompt) {
+ const state = {
+ id: crypto.randomUUID(),
+ voice: voice || env?.POLLINATIONS_VOICE || "nova",
+ gatherPrompt: DEFAULT_GATHER_PROMPT,
+ messages: [{ role: "system", content: SYSTEM_PROMPT }],
+ lastAssistant: ""
+ };
+
+ const seedPrompt = initialPrompt && initialPrompt.trim()
+ ? initialPrompt.trim()
+ : "Greet the caller briefly and ask how you can help.";
+
+ await fetchPollinationsResponse(env, state, seedPrompt);
+ return state;
+}
+
+async function startTwilioCall(env, phoneNumber, voiceResponseUrl) {
+ const accountSid = env?.TWILIO_ACCOUNT_SID;
+ const authToken = env?.TWILIO_AUTH_TOKEN;
+ const fromNumber = env?.TWILIO_PHONE_NUMBER;
+
+ if (!accountSid || !authToken || !fromNumber) {
+ throw new Error("Twilio credentials are not fully configured.");
+ }
+
+ const auth = btoa(`${accountSid}:${authToken}`);
+ const body = new URLSearchParams({
+ To: phoneNumber,
+ From: fromNumber,
+ Url: voiceResponseUrl,
+ Method: "POST"
+ });
+
+ const endpoint = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`;
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ Authorization: `Basic ${auth}`,
+ "Content-Type": "application/x-www-form-urlencoded"
+ },
+ body: body.toString()
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Twilio API error: ${response.status} ${text}`);
+ }
+
+ return response.json();
+}
+
+export {
+ SYSTEM_PROMPT,
+ DEFAULT_GATHER_PROMPT,
+ buildCorsHeaders,
+ buildErrorTwiml,
+ buildFunctionsBaseUrl,
+ buildTwimlResponse,
+ createInitialState,
+ createTtsUrl,
+ decodeState,
+ encodeState,
+ fetchPollinationsResponse,
+ startTwilioCall,
+ trimMessages
+};
diff --git a/functions/api/start-call.js b/functions/api/start-call.js
new file mode 100644
index 0000000..fe09f0f
--- /dev/null
+++ b/functions/api/start-call.js
@@ -0,0 +1,88 @@
+import {
+ buildCorsHeaders,
+ buildFunctionsBaseUrl,
+ createInitialState,
+ encodeState,
+ startTwilioCall
+} from "../_shared/voiceBridge.js";
+
+function jsonResponse(status, data, headers = new Headers()) {
+ const merged = new Headers(headers);
+ if (!merged.has("Content-Type")) {
+ merged.set("Content-Type", "application/json");
+ }
+ merged.set("Cache-Control", "no-store");
+ return new Response(JSON.stringify(data), { status, headers: merged });
+}
+
+function validateEnvironment(env) {
+ const missing = [];
+ if (!env?.TWILIO_ACCOUNT_SID) missing.push("TWILIO_ACCOUNT_SID");
+ if (!env?.TWILIO_AUTH_TOKEN) missing.push("TWILIO_AUTH_TOKEN");
+ if (!env?.TWILIO_PHONE_NUMBER) missing.push("TWILIO_PHONE_NUMBER");
+ if (missing.length) {
+ throw new Error(`Missing environment variables: ${missing.join(", ")}`);
+ }
+}
+
+export async function onRequestOptions(context) {
+ const { request, env } = context;
+ const headers = buildCorsHeaders(request, env?.ALLOWED_ORIGIN);
+ headers.set("Content-Length", "0");
+ return new Response(null, { status: 204, headers });
+}
+
+export async function onRequestPost(context) {
+ const { request, env } = context;
+ const corsHeaders = buildCorsHeaders(request, env?.ALLOWED_ORIGIN);
+
+ let payload = null;
+ try {
+ payload = await request.json();
+ } catch (error) {
+ return jsonResponse(400, { error: "Invalid JSON payload." }, corsHeaders);
+ }
+
+ const phoneNumber = (payload?.phoneNumber || "").trim();
+ const initialPrompt = typeof payload?.initialPrompt === "string" ? payload.initialPrompt : "";
+ const voice = (payload?.voice || env?.POLLINATIONS_VOICE || "nova").trim();
+
+ if (!phoneNumber) {
+ return jsonResponse(400, { error: "A destination phoneNumber is required." }, corsHeaders);
+ }
+ if (!phoneNumber.startsWith("+") || phoneNumber.length < 8) {
+ return jsonResponse(400, { error: "Phone number must be in E.164 format (e.g. +15551234567)." }, corsHeaders);
+ }
+
+ try {
+ validateEnvironment(env);
+ } catch (error) {
+ return jsonResponse(500, { error: error.message }, corsHeaders);
+ }
+
+ const functionsBase = buildFunctionsBaseUrl(request.url);
+
+ try {
+ const state = await createInitialState(env, voice, initialPrompt);
+ const encodedState = encodeState(state);
+ const voiceResponseUrl = `${functionsBase}/voice-response?state=${encodedState}`;
+
+ await startTwilioCall(env, phoneNumber, voiceResponseUrl);
+
+ return jsonResponse(
+ 200,
+ {
+ status: "initiated",
+ message: "Call started. Answer the phone to begin the voice chat.",
+ sessionToken: encodedState,
+ gatherPrompt: state.gatherPrompt,
+ voice: state.voice,
+ usingPagesBridge: true
+ },
+ corsHeaders
+ );
+ } catch (error) {
+ console.error("Failed to start call", error);
+ return jsonResponse(500, { error: error.message || "Failed to start call." }, corsHeaders);
+ }
+}
diff --git a/functions/gather.js b/functions/gather.js
new file mode 100644
index 0000000..414b52d
--- /dev/null
+++ b/functions/gather.js
@@ -0,0 +1,64 @@
+import {
+ buildErrorTwiml,
+ buildFunctionsBaseUrl,
+ buildTwimlResponse,
+ decodeState,
+ encodeState,
+ fetchPollinationsResponse
+} from "./_shared/voiceBridge.js";
+
+async function handleGather(context) {
+ const { request, env } = context;
+ const url = new URL(request.url);
+ const stateParam = url.searchParams.get("state");
+
+ if (!stateParam) {
+ return new Response(buildErrorTwiml("Session information was not provided."), {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ }
+
+ const state = decodeState(stateParam);
+ if (!state) {
+ return new Response(buildErrorTwiml("The voice session could not be restored."), {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ }
+
+ const formData = await request.formData();
+ const speechResult = formData.get("SpeechResult");
+ const confidenceRaw = formData.get("Confidence");
+ const confidenceValue = confidenceRaw === null ? NaN : Number(confidenceRaw);
+ const lowConfidence = Number.isFinite(confidenceValue) ? confidenceValue < 0.1 : false;
+
+ const functionsBase = buildFunctionsBaseUrl(request.url);
+
+ if (!speechResult || lowConfidence) {
+ const encodedState = encodeState(state);
+ const twiml = buildTwimlResponse(state, encodedState, functionsBase, "I didn't catch that. Please respond after the message.");
+ return new Response(twiml, {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ }
+
+ try {
+ await fetchPollinationsResponse(env, state, speechResult);
+ const encodedState = encodeState(state);
+ const twiml = buildTwimlResponse(state, encodedState, functionsBase, "Share your next reply when you are ready.");
+ return new Response(twiml, {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ } catch (error) {
+ console.error("Error generating Pollinations response", error);
+ return new Response(buildErrorTwiml("Something went wrong while generating a response. Ending the call."), {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ }
+}
+
+export const onRequestPost = handleGather;
diff --git a/functions/voice-response.js b/functions/voice-response.js
new file mode 100644
index 0000000..d204ecf
--- /dev/null
+++ b/functions/voice-response.js
@@ -0,0 +1,40 @@
+import {
+ buildErrorTwiml,
+ buildFunctionsBaseUrl,
+ buildTwimlResponse,
+ decodeState,
+ encodeState
+} from "./_shared/voiceBridge.js";
+
+async function handleVoiceResponse(context) {
+ const { request } = context;
+ const url = new URL(request.url);
+ const stateParam = url.searchParams.get("state");
+
+ if (!stateParam) {
+ return new Response(buildErrorTwiml("Session information was not provided."), {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ }
+
+ const state = decodeState(stateParam);
+ if (!state || !state.lastAssistant) {
+ return new Response(buildErrorTwiml("The voice session is no longer active."), {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+ }
+
+ const functionsBase = buildFunctionsBaseUrl(request.url);
+ const encodedState = encodeState(state);
+ const twiml = buildTwimlResponse(state, encodedState, functionsBase, state.gatherPrompt);
+
+ return new Response(twiml, {
+ status: 200,
+ headers: { "Content-Type": "text/xml", "Cache-Control": "no-store" }
+ });
+}
+
+export const onRequestGet = handleVoiceResponse;
+export const onRequestPost = handleVoiceResponse;
diff --git a/index.html b/index.html
index 60004bd..e52b44d 100644
--- a/index.html
+++ b/index.html
@@ -253,7 +253,7 @@
placeholder="https://your-voice-bridge.example.com"
/>
- Provide the HTTPS base URL of the deployed server that exposes the /api/start-call route.
+ Provide the HTTPS base URL of the deployed server that exposes the /api/start-call route, or leave this field blank to use the built-in GitHub Pages voice bridge.
diff --git a/ui.js b/ui.js
index 52b31b1..33bbdc6 100644
--- a/ui.js
+++ b/ui.js
@@ -104,7 +104,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
if (twilioStatusEl) {
- updateTwilioStatus("Ready to place a call. Enter your server URL and phone number, then press Call My Phone.");
+ updateTwilioStatus("Ready to place a call. Enter your server URL and phone number, or leave the URL blank to use the built-in GitHub Pages voice bridge.");
}
if (twilioServerInput) {
@@ -114,8 +114,13 @@ document.addEventListener("DOMContentLoaded", () => {
}
twilioServerInput.addEventListener("change", () => {
const normalized = sanitizeServerUrl(twilioServerInput.value);
- twilioServerInput.value = normalized;
- persistValue(twilioStorageKeys.server, normalized);
+ if (normalized) {
+ twilioServerInput.value = normalized;
+ persistValue(twilioStorageKeys.server, normalized);
+ } else {
+ twilioServerInput.value = "";
+ persistValue(twilioStorageKeys.server, "");
+ }
});
}
@@ -161,26 +166,32 @@ document.addEventListener("DOMContentLoaded", () => {
if (!twilioCallBtn || !twilioServerInput || !twilioPhoneInput) return;
const rawServerUrl = sanitizeServerUrl(twilioServerInput.value);
- if (!rawServerUrl) {
- updateTwilioStatus("Enter the full HTTPS URL of your voice bridge server.", "error");
- twilioServerInput.focus();
- if (window.showToast) window.showToast("Voice bridge URL is required.");
- return;
- }
+ const usingBuiltInBridge = !rawServerUrl;
+ let serverBaseUrl = rawServerUrl;
- let parsedUrl;
- try {
- parsedUrl = new URL(rawServerUrl);
- } catch (err) {
- updateTwilioStatus("The voice bridge URL is not valid. Double-check the format.", "error");
- if (window.showToast) window.showToast("Provide a valid HTTPS URL for the voice bridge.");
- return;
- }
+ if (usingBuiltInBridge) {
+ const origin = (window.location && window.location.origin) ? window.location.origin.trim() : "";
+ if (!origin || !origin.startsWith("https://")) {
+ updateTwilioStatus("Built-in voice bridge is only available on HTTPS deployments. Provide your own server URL while developing locally.", "error");
+ if (window.showToast) window.showToast("Built-in voice bridge requires HTTPS hosting.");
+ return;
+ }
+ serverBaseUrl = `${origin.replace(/\/$/, "")}/_functions`;
+ } else {
+ let parsedUrl;
+ try {
+ parsedUrl = new URL(rawServerUrl);
+ } catch (err) {
+ updateTwilioStatus("The voice bridge URL is not valid. Double-check the format.", "error");
+ if (window.showToast) window.showToast("Provide a valid HTTPS URL for the voice bridge.");
+ return;
+ }
- if (parsedUrl.protocol !== "https:" && parsedUrl.hostname !== "localhost") {
- updateTwilioStatus("Use an HTTPS URL so Twilio can reach your server.", "error");
- if (window.showToast) window.showToast("HTTPS is required unless you are testing on localhost.");
- return;
+ if (parsedUrl.protocol !== "https:" && parsedUrl.hostname !== "localhost") {
+ updateTwilioStatus("Use an HTTPS URL so Twilio can reach your server.", "error");
+ if (window.showToast) window.showToast("HTTPS is required unless you are testing on localhost.");
+ return;
+ }
}
const phoneNumber = (twilioPhoneInput.value || "").trim();
@@ -194,13 +205,14 @@ document.addEventListener("DOMContentLoaded", () => {
const initialPrompt = twilioPromptInput ? twilioPromptInput.value.trim() : "";
const voice = twilioVoiceSelect ? twilioVoiceSelect.value : "nova";
- persistValue(twilioStorageKeys.server, rawServerUrl);
+ persistValue(twilioStorageKeys.server, usingBuiltInBridge ? "" : serverBaseUrl);
persistValue(twilioStorageKeys.phone, phoneNumber);
persistValue(twilioStorageKeys.prompt, initialPrompt);
persistValue(twilioStorageKeys.voice, voice);
- const endpoint = `${rawServerUrl}/api/start-call`;
- updateTwilioStatus("Contacting the voice bridge…", "pending");
+ const normalizedBase = serverBaseUrl.replace(/\/$/, "");
+ const endpoint = `${normalizedBase}/api/start-call`;
+ updateTwilioStatus(usingBuiltInBridge ? "Contacting the built-in voice bridge…" : "Contacting the voice bridge…", "pending");
twilioCallBtn.disabled = true;
try {