AI Chat
A chat interface where the assistant answers from the live web. Each user message fires one streaming Exa search. Exa synthesizes the results into prose and streams the answer back with citations.
Demo
Ask a question. Exa searches the web, synthesizes an answer, and streams it in with inline citations.
Try one of these:
How it works
Each turn calls exa.streamSearch with type: "auto", outputSchema: { type: "text" }, and contents: { highlights: true }. Each streamed chunk carries partial text or new citations. The client patches the assistant message as chunks arrive.
Voice and formatting are controlled by the systemPrompt and the outputSchema.description. The description sets sentence count, punctuation rules, and where to place citation markers.
For follow-up turns, the server folds the prior transcript into the system prompt and rewrites short follow-ups like “what's next” to include the previous topic, so retrieval stays on subject.
Source code
"use client";
import { useRef, useState } from "react";
import { CitationStack, type Citation } from "@/lib/citations";
import {
ExaContentAccumulator,
streamNdjson,
type ExaStreamEvent,
} from "@/lib/exa-stream";
type UserMessage = { id: string; role: "user"; content: string };
type AssistantMessage = {
id: string;
role: "assistant";
content: string;
status: "streaming" | "done" | "error";
citations: Citation[];
error?: string;
};
type Message = UserMessage | AssistantMessage;
const SUGGESTIONS = [
"Latest news on Nvidia",
"What is Stripe working on right now?",
"Recent buying signals at Anthropic",
];
export function ChatExample() {
const [messages, setMessages] = useState<Message[]>([]);
const [draft, setDraft] = useState("");
const [streaming, setStreaming] = useState(false);
const idRef = useRef(0);
function nextId(prefix: string) {
idRef.current += 1;
return `${prefix}_${idRef.current}`;
}
function patchAssistant(id: string, patch: Partial<AssistantMessage>) {
setMessages((prev) =>
prev.map((m) =>
m.id === id && m.role === "assistant" ? { ...m, ...patch } : m
)
);
}
async function send(text: string) {
const query = text.trim();
if (!query || streaming) return;
setDraft("");
const userMsg: UserMessage = { id: nextId("u"), role: "user", content: query };
const asstId = nextId("a");
const asstMsg: AssistantMessage = {
id: asstId,
role: "assistant",
content: "",
status: "streaming",
citations: [],
};
const history = messages
.filter((m) =>
m.role === "user" ? true : m.status === "done" && m.content
)
.slice(-6)
.map((m) => ({ role: m.role, content: m.content }));
setMessages((m) => [...m, userMsg, asstMsg]);
setStreaming(true);
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, history }),
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
let err = `Request failed (${res.status})`;
try {
err = JSON.parse(text).error ?? err;
} catch {}
patchAssistant(asstId, { status: "error", error: err });
return;
}
const acc = new ExaContentAccumulator();
const citationsById = new Map<string, Citation>();
await streamNdjson<ExaStreamEvent>(res, (event) => {
if (event.type === "content") {
patchAssistant(asstId, { content: acc.push(event.text) });
} else if (event.type === "citations") {
for (const c of event.citations) citationsById.set(c.id, c);
patchAssistant(asstId, {
citations: Array.from(citationsById.values()),
});
} else if (event.type === "done") {
patchAssistant(asstId, { status: "done" });
} else if (event.type === "error") {
patchAssistant(asstId, { status: "error", error: event.error });
}
});
} catch (err) {
patchAssistant(asstId, {
status: "error",
error: err instanceof Error ? err.message : String(err),
});
} finally {
setStreaming(false);
}
}
return (
<div className="border border-[#e5e5e5] bg-white overflow-hidden flex flex-col">
<div className="px-4 py-4 space-y-4 min-h-[280px] max-h-[520px] overflow-y-auto">
{messages.length === 0 ? (
<EmptyState onPick={(q) => void send(q)} />
) : (
messages.map((m) =>
m.role === "user" ? (
<UserBubble key={m.id} message={m} />
) : (
<AssistantBubble key={m.id} message={m} />
)
)
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void send(draft);
}}
className="flex items-center gap-2 border-t border-[#e5e5e5] bg-[#fafafa] px-3 py-2"
>
<input
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Ask something about a company, person, or market..."
className="flex-1 border border-[#e5e5e5] bg-white text-[13px] text-[#111827] px-3 py-2 focus:outline-none focus:border-[var(--color-brand)]"
/>
<button
type="submit"
disabled={streaming || !draft.trim()}
className="bg-[var(--color-brand)] text-white text-[13px] font-medium px-3.5 py-2 disabled:opacity-50"
>
{streaming ? "Thinking..." : "Send"}
</button>
</form>
</div>
);
}
function EmptyState({ onPick }: { onPick: (q: string) => void }) {
return (
<div className="text-[13px] text-[#6b7280]">
<p className="mb-3">Try one of these:</p>
<div className="flex flex-wrap gap-2">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => onPick(s)}
className="rounded-full border border-[#e5e5e5] bg-white px-3 py-1 text-[12px] text-[#374151] hover:border-[var(--color-brand)] hover:text-[#111827]"
>
{s}
</button>
))}
</div>
</div>
);
}
function UserBubble({ message }: { message: UserMessage }) {
return (
<div className="flex justify-end">
<div className="max-w-[80%] bg-[var(--color-brand)] text-white text-[13px] px-3 py-2 leading-[1.55]">
{message.content}
</div>
</div>
);
}
function AssistantBubble({ message }: { message: AssistantMessage }) {
const paragraphs = message.content ? message.content.split(/\n{2,}/) : [];
return (
<div className="space-y-2">
{paragraphs.length === 0 && message.status === "streaming" && (
<div className="text-[13px]">
<span className="inline-block h-3 w-24 rounded bg-[#e5e7eb] animate-pulse align-middle" />
</div>
)}
{paragraphs.map((p, i) => (
<div
key={i}
className="text-[13px] text-[#111827] leading-[1.65] whitespace-pre-line"
>
<RenderContent
content={p}
citations={message.citations}
streaming={
message.status === "streaming" && i === paragraphs.length - 1
}
/>
</div>
))}
{message.status === "error" && (
<div className="text-[12px] text-[#b91c1c]">{message.error}</div>
)}
</div>
);
}
type Segment =
| { kind: "text"; text: string }
| { kind: "citations"; indices: number[] };
function parseSegments(content: string): Segment[] {
const out: Segment[] = [];
const re = /(?:\[(\d+)\])+/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
if (m.index > last)
out.push({ kind: "text", text: content.slice(last, m.index) });
const indices = Array.from(m[0].matchAll(/\[(\d+)\]/g)).map((x) =>
parseInt(x[1], 10)
);
out.push({ kind: "citations", indices });
last = re.lastIndex;
}
if (last < content.length)
out.push({ kind: "text", text: content.slice(last) });
return out;
}
function RenderContent({
content,
citations,
streaming,
}: {
content: string;
citations: Citation[];
streaming: boolean;
}) {
return (
<>
{parseSegments(content).map((seg, i) =>
seg.kind === "text" ? (
<span key={i}>{seg.text}</span>
) : (
<CitationStack key={i} indices={seg.indices} citations={citations} />
)
)}
{streaming && (
<span className="inline-block h-3 w-1.5 ml-0.5 bg-[#9ca3af] animate-pulse align-middle" />
)}
</>
);
}