Build a Docs Chat Widget
Embed a grounded AI chat assistant into your documentation site. Users ask questions; the engine retrieves relevant docs chunks and responds with inline citations.
In this tutorial you'll build a fully functional chat widget that lets your users ask natural-language questions about your documentation. The assistant retrieves relevant content from your knowledge base and answers with source citations — no hallucinations, no made-up facts.
What you'll build:
- A floating chat button that opens a conversation panel
- A Next.js API route that proxies requests to LiyaEngine
- Grounded responses with citations pulled from your own docs
Prerequisites: A LiyaEngine API key (get one here), a Next.js project, and your documentation content ready to seed.
Step 1 — Seed your knowledge base
Before the chat can answer questions, it needs to know your content. LiyaEngine's RAG pipeline retrieves the most relevant chunks from your uploaded documents at query time.
// scripts/seed-docs.ts
import { PrismaClient } from "@prisma/client";
import { KnowledgeRAGService } from "@liyaengine/core";
import fs from "fs";
import path from "path";
const prisma = new PrismaClient();
const rag = new KnowledgeRAGService(prisma);
const TENANT_ID = "liya-system"; // Use your tenant ID
const DOCS_DIR = path.join(process.cwd(), "content/docs");
async function seed() {
const files = fs.readdirSync(DOCS_DIR).filter(f => f.endsWith(".mdx") || f.endsWith(".md"));
for (const file of files) {
const content = fs.readFileSync(path.join(DOCS_DIR, file), "utf-8");
const docId = file.replace(/\.(mdx|md)$/, "");
await rag.upsertChunks(TENANT_ID, [{
content_text: content,
source_type: "chat:docs",
metadata: { source_id: docId, document_id: docId },
}]);
console.log(`Seeded: ${docId}`);
}
console.log(`Done — ${files.length} files seeded.`);
}
seed().finally(() => prisma.$disconnect());Run it:
npx ts-node scripts/seed-docs.tsStep 2 — Create the API route
Add a Next.js API route that proxies chat requests to LiyaEngine, keeping your API key server-side.
Create app/api/chat/route.ts:
import { NextRequest, NextResponse } from "next/server";
const LIYA_API_BASE = process.env.LIYA_API_URL ?? "https://api.liyaengine.com";
const LIYA_API_KEY = process.env.LIYA_API_KEY ?? "";
export async function POST(req: NextRequest) {
if (!LIYA_API_KEY) {
return NextResponse.json({ error: "Chat not configured." }, { status: 503 });
}
const { message, sessionId } = await req.json();
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "message is required." }, { status: 400 });
}
const body = {
pack: "enterprise-chat",
intent: "answer-question",
input: {
message,
session_id: sessionId,
user: { id: "docs-visitor", role: "user", permissions: [] },
},
retrieval: {
sources: [], // empty = search all sources
top_k: 6,
min_relevance: 0.3,
},
guardrails: {
grounding: { action: "warn_if_ungrounded" },
content: { policy: "professional" },
},
};
const upstream = await fetch(`${LIYA_API_BASE}/v1/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${LIYA_API_KEY}`,
},
body: JSON.stringify(body),
});
const data = await upstream.json();
return NextResponse.json(data, { status: upstream.status });
}Add your API key to .env.local:
LIYA_API_KEY=liya_your_key_here
LIYA_API_URL=https://api.liyaengine.comYou can also call the LiyaEngine API directly from any language:
const res = await fetch("https://api.liyaengine.com/v1/run", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LIYA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
pack: "enterprise-chat",
intent: "answer-question",
input: { message: "How do I get an API key?", session_id: "sess-123" },
retrieval: { sources: [], top_k: 6, min_relevance: 0.3 },
}),
});
const { data } = await res.json();
console.log(data.output.content);Step 3 — Build the chat widget component
Create components/ChatWidget.tsx:
"use client";
import { useState, useRef, useEffect } from "react";
interface Message {
role: "user" | "assistant";
content: string;
sources?: string[];
}
export default function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, loading]);
async function send() {
const text = input.trim();
if (!text || loading) return;
setInput("");
setMessages(prev => [...prev, { role: "user", content: text }]);
setLoading(true);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text, sessionId }),
});
const data = await res.json();
const output = data?.data?.output ?? data?.output;
const content = output?.content ?? "Sorry, I couldn't generate a response.";
const sources: string[] = output?.sources?.map((s: any) => s.doc ?? s) ?? [];
setMessages(prev => [...prev, { role: "assistant", content, sources }]);
} catch {
setMessages(prev => [
...prev,
{ role: "assistant", content: "Something went wrong. Please try again." },
]);
} finally {
setLoading(false);
}
}
return (
<>
{/* FAB */}
<button
onClick={() => setOpen(v => !v)}
style={{
position: "fixed", bottom: 24, right: 24, zIndex: 1000,
width: 52, height: 52, borderRadius: "50%",
background: "linear-gradient(135deg, #38bdf8 0%, #6366f1 100%)",
border: "none", cursor: "pointer",
boxShadow: "0 4px 20px rgba(14,165,233,0.5)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
aria-label="Open chat"
>
{open ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5">
<path d="M18 6 6 18M6 6l12 12" strokeLinecap="round" />
</svg>
) : (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
)}
</button>
{/* Panel */}
{open && (
<div style={{
position: "fixed", bottom: 88, right: 24, zIndex: 999,
width: 380, height: 520,
background: "#0f172a",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 16,
display: "flex", flexDirection: "column",
boxShadow: "0 24px 64px rgba(0,0,0,0.6)",
overflow: "hidden",
}}>
{/* Header */}
<div style={{
padding: "16px 20px",
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)",
borderBottom: "1px solid rgba(255,255,255,0.07)",
display: "flex", alignItems: "center", gap: 10,
}}>
<div style={{
width: 8, height: 8, borderRadius: "50%",
background: "#10b981", boxShadow: "0 0 6px #10b981",
}} />
<span style={{ fontSize: 14, fontWeight: 600, color: "#ffffff" }}>
Ask the docs
</span>
</div>
{/* Messages */}
<div style={{ flexGrow: 1, overflowY: "auto", padding: "16px" }}>
{messages.length === 0 && (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div style={{ fontSize: 28, marginBottom: 12 }}>✦</div>
<div style={{ fontSize: 14, color: "rgba(240,244,255,0.5)" }}>
Ask anything about the docs
</div>
</div>
)}
{messages.map((m, i) => (
<div key={i} style={{
marginBottom: 12,
display: "flex",
justifyContent: m.role === "user" ? "flex-end" : "flex-start",
}}>
<div style={{
maxWidth: "80%",
padding: "10px 14px",
borderRadius: m.role === "user" ? "16px 16px 4px 16px" : "16px 16px 16px 4px",
background: m.role === "user"
? "linear-gradient(135deg, #38bdf8 0%, #6366f1 100%)"
: "rgba(255,255,255,0.06)",
color: m.role === "user" ? "#040810" : "rgba(240,244,255,0.85)",
fontSize: 14, lineHeight: 1.5,
}}>
{m.content}
{m.sources && m.sources.length > 0 && (
<div style={{
marginTop: 8, fontSize: 11,
color: "rgba(240,244,255,0.4)",
borderTop: "1px solid rgba(255,255,255,0.08)",
paddingTop: 6,
}}>
Sources: {m.sources.join(", ")}
</div>
)}
</div>
</div>
))}
{loading && (
<div style={{ display: "flex", gap: 5, padding: "8px 14px" }}>
{[0, 1, 2].map(i => (
<div key={i} style={{
width: 7, height: 7, borderRadius: "50%",
background: "rgba(56,189,248,0.5)",
animation: `bounce 1s ${i * 0.15}s infinite`,
}} />
))}
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div style={{
padding: "12px 16px",
borderTop: "1px solid rgba(255,255,255,0.07)",
display: "flex", gap: 8,
}}>
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && !e.shiftKey && send()}
placeholder="Ask a question..."
style={{
flexGrow: 1, padding: "9px 14px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.1)",
borderRadius: 10, color: "#f0f4ff", fontSize: 14,
outline: "none",
}}
/>
<button
onClick={send}
disabled={loading || !input.trim()}
style={{
width: 38, height: 38, borderRadius: 10,
background: input.trim() ? "linear-gradient(135deg, #38bdf8 0%, #6366f1 100%)" : "rgba(255,255,255,0.06)",
border: "none", cursor: input.trim() ? "pointer" : "default",
display: "flex", alignItems: "center", justifyContent: "center",
transition: "background 0.2s",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={input.trim() ? "#040810" : "rgba(240,244,255,0.3)"} strokeWidth="2.5">
<path d="M5 12h14M12 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
)}
<style>{`
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-6px); }
}
`}</style>
</>
);
}Step 4 — Add the widget to your layout
In your root app/layout.tsx, import and render the widget:
import ChatWidget from "@/components/ChatWidget";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<ChatWidget />
</body>
</html>
);
}The widget renders as a fixed overlay on every page — no routing changes needed.
Step 5 — Test it
Start your dev server and open any page in your docs site. Click the chat button in the bottom-right corner and ask a question about your product.
npm run devYou should see:
- The question submitted to
/api/chat - LiyaEngine retrieving chunks from your seeded knowledge base
- A grounded answer with inline source citations
Tip: Use your browser devtools Network tab to inspect the raw API response. Look for
retrieval_ms> 0 in the response — that confirms RAG retrieval is working.
What's next
- Customize the persona — pass a
personaNamein your request body to give the assistant a branded identity - Scope retrieval — pass
sources: ["your-doc-id"]to restrict answers to specific documents - Add streaming — for long answers, switch to the streaming endpoint for faster perceived response time
- Deploy — set
LIYA_API_KEYas a secret in Vercel, Railway, or your deployment platform
Ready to build?
Get your API key and start integrating in minutes.