Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

AI Chatbot Backend

This guide builds a production-ready AI chatbot backend that stores conversation history, authenticates users, streams LLM responses, and runs custom tools — using four Reactor capabilities working together.

What you’ll build:

  • User authentication with JWT
  • Conversation and message storage with RLS
  • Streaming chat endpoint via a WASM function
  • LLM routing through the Gateway capability

Prerequisites: Running Reactor server, Node.js 18+, basic TypeScript knowledge.


Create migrations for conversations and messages:

-- migrations/001_chatbot.sql
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
title TEXT NOT NULL DEFAULT 'New conversation',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX messages_conversation_idx ON messages(conversation_id, created_at);
-- RLS: users see only their own conversations
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY conversations_owner ON conversations
FOR ALL
USING (user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid)
WITH CHECK (user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid);
CREATE POLICY messages_via_conversation ON messages
FOR ALL
USING (
conversation_id IN (
SELECT id FROM conversations
WHERE user_id = (current_setting('request.jwt.claims', true)::json->>'sub')::uuid
)
);

Deploy migrations:

Terminal window
reactor deploy

Add AI provider credentials to Reactor.toml:

[ai]
openrouter_api_key = "vault:ai/openrouter_key"
default_alias = "fast"
# Or use Bedrock:
# aws_access_key_id = "vault:ai/aws_key"
# aws_secret_access_key = "vault:ai/aws_secret"
# aws_bedrock_region = "us-east-1"

Store the API key in vault or env:

Terminal window
export REACTOR_AI__OPENROUTER_API_KEY="sk-or-v1-..."

Create a streaming chat handler that loads history, calls the gateway, and saves the response:

functions/chat/index.ts
import { ReactorClient } from "@reactor/client";
interface ChatRequest {
conversation_id: string;
message: string;
}
export default async function handler(req: Request): Promise<Response> {
const reactor = new ReactorClient({
baseUrl: Deno.env.get("REACTOR_URL")!,
token: req.headers.get("Authorization")?.replace("Bearer ", "")!,
});
const { conversation_id, message } = await req.json() as ChatRequest;
// Save user message
await reactor.data.from("messages").insert({
conversation_id,
role: "user",
content: message,
});
// Load conversation history
const { data: history } = await reactor.data
.from("messages")
.select("role, content")
.eq("conversation_id", conversation_id)
.order("created_at", { ascending: true })
.limit(50);
// Stream from gateway
const stream = await reactor.ai.chat.completions.create({
model: "fast",
messages: [
{ role: "system", content: "You are a helpful assistant." },
...(history ?? []),
],
stream: true,
});
let fullResponse = "";
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? "";
fullResponse += text;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
}
// Save assistant message
await reactor.data.from("messages").insert({
conversation_id,
role: "assistant",
content: fullResponse,
});
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}

Deploy the function:

Terminal window
reactor functions deploy chat

src/lib/chat.ts
import { createClient } from "@reactor/client";
const reactor = createClient({
url: "https://api.myapp.com",
});
// Sign in
const { session } = await reactor.auth.signInWithPassword({
email: "user@example.com",
password: "secure-password",
});
// Create conversation
const { data: conversation } = await reactor.data
.from("conversations")
.insert({ title: "Help with React" })
.select()
.single();
// Stream chat
async function* streamChat(message: string) {
const response = await fetch(
`${reactor.url}/fn/v1/chat`,
{
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
conversation_id: conversation.id,
message,
}),
}
);
const reader = response.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split("\n")) {
if (line.startsWith("data: ") && line !== "data: [DONE]") {
yield JSON.parse(line.slice(6)).text;
}
}
}
}
// Usage in React
for await (const chunk of streamChat("Explain useEffect")) {
setResponse((prev) => prev + chunk);
}

Extend the chat function to support tool use — querying your data API mid-conversation:

const tools = [
{
type: "function",
function: {
name: "search_products",
description: "Search the product catalog",
parameters: {
type: "object",
properties: {
query: { type: "string" },
},
required: ["query"],
},
},
},
];
// In the gateway call:
const response = await reactor.ai.chat.completions.create({
model: "power",
messages: history,
tools,
});
// If the model requests a tool call:
if (response.choices[0].message.tool_calls) {
const call = response.choices[0].message.tool_calls[0];
const results = await reactor.data
.from("products")
.select("*")
.ilike("name", `%${JSON.parse(call.function.arguments).query}%`);
// Feed results back to the model for final response
}

Protect the chat endpoint with a job that tracks usage:

// functions/chat/index.ts — add at the top of handler
const MAX_MESSAGES_PER_HOUR = 60;
const hourAgo = new Date(Date.now() - 3600_000).toISOString();
const { count } = await reactor.data
.from("messages")
.select("*", { count: "exact", head: true })
.eq("role", "user")
.gte("created_at", hourAgo);
if (count && count >= MAX_MESSAGES_PER_HOUR) {
return new Response(JSON.stringify({ error: "Rate limit exceeded" }), {
status: 429,
});
}

CapabilityRole in chatbot
AuthUser sign-up, JWT for RLS
DataConversations, messages, RLS isolation
FunctionsStreaming chat handler
GatewayLLM routing (OpenRouter, Bedrock, etc.)