/**
* hoopai-chat-api — Cloudflare Worker
* Proxies OpenRouter (Claude Haiku 4.5) with tool support.
* Secrets: OPENROUTER_API_KEY, BRAVE_SEARCH_API_KEY, SUPPORT_WEBHOOK_URL
*/
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'anthropic/claude-haiku-4-5';
const MAX_TOOL_ITERATIONS = 4;
const TOOL_DEFINITIONS = [
{
type: 'function',
function: {
name: 'web_search',
description:
'Search the web for current information. Use when the user asks about something not in your training or that may have changed recently.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'create_support_ticket',
description:
'Create a HoopAI support ticket when the user has an unresolved issue that needs human assistance.',
parameters: {
type: 'object',
properties: {
subject: { type: 'string', description: 'One-line summary of the issue' },
description: { type: 'string', description: 'Full description of the problem' },
email: { type: 'string', description: "User's email address, if they provided it" }
},
required: ['subject', 'description']
}
}
}
];
/* ── CORS ─────────────────────────────────────────────────── */
function getAllowedOrigins(env) {
return (env.ALLOWED_ORIGINS || 'https://help.hoopai.com,http://localhost:3000').split(',');
}
function corsHeaders(origin) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400'
};
}
/* ── System prompt ────────────────────────────────────────── */
function buildSystemPrompt(pageContext) {
const page = pageContext || {};
return [
'You are a helpful support assistant for HoopAI — an all-in-one CRM and marketing platform.',
'You are embedded in the HoopAI Help Center documentation site.',
page.title ? `The user is currently reading: "${page.title}" (${page.url})` : '',
'',
'Guidelines:',
'- Answer questions about HoopAI features directly and concisely.',
'- When asked about something that may have changed or is not in your training, use web_search.',
'- When a user has an unresolved issue needing human help, offer to use create_support_ticket.',
'- Never mention GoHighLevel, HighLevel, GHL, Marketing Muse, or WhoPayI.',
'- Always say "HoopAI" or "Hoop" — never the white-label vendor names.',
'- The mobile app is called LeadConnector (not HoopAI) — important for App Store searches.',
'- Be direct and concise. No filler phrases like "Great question!" or "Certainly!".'
].filter(Boolean).join('\n');
}
/* ── OpenRouter call ──────────────────────────────────────── */
async function callOpenRouter(messages, env, stream) {
const body = {
model: MODEL,
messages,
tools: TOOL_DEFINITIONS,
tool_choice: 'auto',
stream
};
const res = await fetch(OPENROUTER_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://help.hoopai.com',
'X-Title': 'HoopAI Help Center'
},
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.text();
throw new Error(`OpenRouter error ${res.status}: ${err}`);
}
return res;
}
/* ── Tool execution ───────────────────────────────────────── */
async function executeWebSearch(query, env) {
if (!env.BRAVE_SEARCH_API_KEY) {
return { error: 'Web search not configured. Answer from your training data.' };
}
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=5`;
const res = await fetch(url, {
headers: { Accept: 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': env.BRAVE_SEARCH_API_KEY }
});
if (!res.ok) return { error: `Search failed: ${res.status}` };
const data = await res.json();
const results = (data.web?.results || []).slice(0, 5).map(r => ({
title: r.title,
url: r.url,
snippet: r.description
}));
return { results };
}
async function executeCreateTicket(params, env) {
if (!env.SUPPORT_WEBHOOK_URL) {
return {
success: false,
message: 'Ticket system not configured yet. Please email support@hoopai.com directly.'
};
}
const res = await fetch(env.SUPPORT_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: params.subject, description: params.description, email: params.email || '' })
});
if (!res.ok) return { success: false, message: `Webhook error: ${res.status}` };
return { success: true, message: 'Support ticket created. The team will respond within 24 hours.' };
}
async function executeTool(name, args, env) {
if (name === 'web_search') return executeWebSearch(args.query, env);
if (name === 'create_support_ticket') return executeCreateTicket(args, env);
return { error: `Unknown tool: ${name}` };
}
/* ── Agentic loop → SSE stream ────────────────────────────── */
function sse(obj) {
return `data: ${JSON.stringify(obj)}\n\n`;
}
async function runAndStream(initialMessages, env, origin) {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const enc = new TextEncoder();
const write = (obj) => writer.write(enc.encode(sse(obj)));
(async () => {
try {
let messages = [...initialMessages];
// Agentic loop: non-streaming calls to handle tool calls
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
const res = await callOpenRouter(messages, env, false);
const data = await res.json();
const choice = data.choices?.[0];
if (!choice) throw new Error('No choice returned from OpenRouter');
if (choice.finish_reason === 'tool_calls' && choice.message?.tool_calls?.length) {
// Append assistant message with tool calls
messages.push({ role: 'assistant', content: null, tool_calls: choice.message.tool_calls });
// Execute each tool call
for (const tc of choice.message.tool_calls) {
let args = {};
try { args = JSON.parse(tc.function.arguments); } catch (_) {}
await write({ type: 'tool_start', tool: tc.function.name, input: args });
const result = await executeTool(tc.function.name, args, env);
await write({ type: 'tool_end', tool: tc.function.name, result });
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: JSON.stringify(result)
});
}
// Loop again for the assistant's next response
} else {
// No tool calls — do a streaming final response
const streamRes = await callOpenRouter(messages, env, true);
const reader = streamRes.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop(); // keep incomplete line
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (raw === '[DONE]') break;
try {
const parsed = JSON.parse(raw);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) await write({ type: 'token', content: delta });
} catch (_) {}
}
}
break;
}
}
await write({ type: 'done' });
} catch (err) {
await write({ type: 'error', message: err.message });
} finally {
writer.close();
}
})();
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
...corsHeaders(origin)
}
});
}
/* ── Main handler ─────────────────────────────────────────── */
export default {
async fetch(request, env) {
const origin = request.headers.get('Origin') || '';
const allowed = getAllowedOrigins(env);
// CORS preflight
if (request.method === 'OPTIONS') {
const isAllowed = allowed.includes(origin);
return new Response(null, {
status: 204,
headers: isAllowed ? corsHeaders(origin) : {}
});
}
// Block non-allowed origins
if (!allowed.includes(origin)) {
return new Response('Forbidden', { status: 403 });
}
const url = new URL(request.url);
if (request.method === 'POST' && url.pathname === '/chat') {
let body;
try {
body = await request.json();
} catch (_) {
return new Response('Invalid JSON', { status: 400, headers: corsHeaders(origin) });
}
const { messages = [], pageContext = {} } = body;
if (!Array.isArray(messages) || messages.length === 0) {
return new Response('messages array required', { status: 400, headers: corsHeaders(origin) });
}
const systemMsg = { role: 'system', content: buildSystemPrompt(pageContext) };
const allMessages = [systemMsg, ...messages];
return runAndStream(allMessages, env, origin);
}
return new Response('Not found', { status: 404, headers: corsHeaders(origin) });
}
};