Document comparison
Build a chat assistant that compares stocks (NVIDIA, AMD, and Intel) annual reports with page-cited tables and charts.
Comparing documents is everyday work: vendor proposals, contracts, policies, annual reports. The answers are spread across long files, and each document states the same fact in a different place and in different words. Reading three 150-page reports side by side takes hours, and a summary written as paragraphs hides exactly what you wanted to compare.
A language model can read and compare, but long documents do not fit in a single request. Retrieval solves that: search each document for the passages about one criterion, and give the model only those passages, with their page numbers. Generative UI solves the presentation: the model answers with a comparison table, charts, callouts for gaps and conflicts, and the quoted evidence behind every value.
In this cookbook, you'll learn how to:
- Turn PDFs into searchable passages that keep their page numbers, using embeddings for search by meaning.
- Give the model a search function tool, so it retrieves evidence for each criterion instead of answering from memory.
- Make the model show its work: page-cited values, charts chosen from the shape of the data, callouts for missing or conflicting information, and source cards that open each quoted page.
- Customize Agent Interface, OpenUI's ready-made React chat shell with threads, streaming messages, and tool activity, with your own theme, sidebar, and pages.
- Generate answers with OpenUI Gateway, OpenUI's hosted model API that routes requests to model providers and corrects generated UI, through its OpenAI-compatible Chat Completions API.
- Send the conversation with each question, so a follow-up can add a criterion to the comparison.
What you'll build
The example compares the latest annual reports (Form 10-K) from NVIDIA, AMD, and Intel: 396 pages, published as PDFs on each company's investor relations site. They make a realistic test. The same facts, such as revenue, R&D spending, and headcount, appear in different sections and wording in each report, and NVIDIA's fiscal year ends in January while the others end in December, a conflict the comparison should point out. The same approach works for vendor proposals, contracts, or any set of PDFs.
Try this conversation:
- “Compare revenue and growth.” Get a table of page-cited values, bar charts, and a note that the fiscal years differ.
- “How has R&D spending changed over three years?” See each company's trend in a line chart.
- “Break down revenue by segment.” See every company's segments in one bar chart, with a table row per segment.
- Click a follow-up such as “Compare headcount.” Add a criterion to the comparison.
Every answer ends with Sources: a card for each quoted page, with the company, page, and quote, that opens the report at that page.
How it works
A document comparison assistant splits the work between your application and the model. Your application owns the documents and the search. The model decides what to look for in each document and how to present what it finds.
- The user asks for a comparison. Agent Interface sends the conversation to your server's chat route, which calls OpenUI Gateway with a
search_documentsfunction tool and a system prompt that describes the documents and your components. - The model searches once per criterion. For “revenue and R&D”, it calls
search_documentstwice, in parallel, with short descriptions such as “research and development expenses for the fiscal year”. Separate searches keep one criterion's passages from crowding out another's. Your server embeds each description with OpenAI, compares it with the passage embeddings it loaded from SQLite, and returns each document's closest passages with their page numbers and afoundflag. - The model compares the evidence. It writes OpenUI Lang with a table of page-cited values, charts suited to the data, callouts for conflicts or gaps, and source cards that quote each passage. Gateway validates the generated OpenUI Lang against your component library as it streams.
- Agent Interface renders it progressively. Tables and charts appear as their data arrives. The next request includes the earlier questions and answers, so a follow-up such as “compare headcount” searches for the new criterion and extends the comparison.
Why retrieve passages
The three reports contain about 220,000 words, roughly 290,000 tokens. Sending them with every question would be slow and expensive, and it exceeds many models' context windows. Retrieval sends only the passages that matter, two per document per criterion by default, so each question stays fast and inexpensive.
Passages also keep their page numbers, which makes every value checkable against the original PDF. When a document has no passage close to the question, the tool says so with found: false. When the closest passages mention the topic but do not contain the answer, the model says that instead. Either way, the answer reports a gap rather than inventing a number.
Why embeddings
Annual reports describe the same thing in different words. NVIDIA reports “revenue” and AMD “net revenue”, and one report counts “employees” where another describes its “workforce”. An embedding represents a passage's meaning as a vector, so a single query finds the matching passage in each report even when the words differ. Keyword search would find only the reports that happen to use the query's wording.
Run the example
You need Node.js 22.13 or newer and npm.
git clone https://github.com/thesysdev/openui.git
cd openui/examples/cookbooks/document-comparison
npm ciThe example needs two keys, configured privately in the example's .env.local:
THESYS_API_KEYfrom the Thesys Console, for generation.OPENAI_API_KEYfrom the OpenAI Platform, for embeddings.
Then prepare the documents and start the app:
npm run prepare:documents
npm run devThe first run downloads about 83 MB of PDFs, which later runs reuse, and embeds 1,564 passages for about a cent. Open localhost:3000 and try a starter question. The default model is openai/gpt-5.5; use OPENUI_MODEL to select another supported Gateway model.
Build it step by step
Each step covers one part of the example, in the order a request flows through it: the documents, the search tool, the components, the chat interface, and the streamed answer.
1. Prepare the documents
npm run prepare:documents downloads each report listed in documents.ts and extracts its text page by page. It then splits each page into overlapping passages, so every passage keeps the page it came from:
// Split each page into overlapping passages so every passage keeps its page number.
function passages(text: string, size = 1200, overlap = 200) {
const clean = text.replace(/\s+/g, " ").trim();
const result: string[] = [];
for (let start = 0; start < clean.length; start += size - overlap) {
result.push(clean.slice(start, start + size));
if (start + size >= clean.length) break;
}
return result.filter((passage) => passage.length > 80);
}The overlap keeps a sentence that crosses a passage boundary intact in at least one passage. The script embeds the passages with OpenAI's text-embedding-3-small in batches of 100 and stores each one in data/documents.sqlite with its document, page, text, and embedding. See prepare-documents.ts for the complete script.
2. Expose a search tool
Give Gateway a function tool named search_documents. It accepts one criterion at a time, described in plain words:
{
"query": "research and development expenses for the fiscal year",
"document_ids": [],
"passages_per_document": 2
}An empty document_ids searches every document. The tool definition lists the prepared documents' ids as an enum, so the model can only name documents that exist. Your server validates the arguments, embeds the query, and ranks each document's passages by similarity:
const [queryEmbedding] = await embed([args.query], signal);
const results = selected.map((document) => {
const url = sources.find((source) => source.id === document.id)?.url;
const matches = passages
.filter((passage) => passage.documentId === document.id)
.map((passage) => ({ ...passage, score: similarity(queryEmbedding, passage.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, args.passages_per_document);
const bestScore = matches[0]?.score ?? 0;
return {
document_id: document.id,
found: bestScore >= weakMatch,
passages: matches.map((match) => ({
page: match.page,
url: url && `${url}#page=${match.page}`,
text: match.text,
})),
};
});Results are grouped by document, so every document is represented even when one report discusses the criterion at greater length than the others. found is false when a document's best match falls below a similarity of 0.3, which usually means the report does not discuss the criterion. Each passage's url adds #page= to the PDF's address, which opens most PDF viewers at that page, so the answer can link to its evidence. Agent Interface shows each call and its passages in the Behind the scenes timeline.
The example keeps all 1,564 embeddings in memory and compares them in JavaScript, which takes a few milliseconds. For thousands of documents, store the embeddings in a vector database instead; the tool's arguments and result stay the same.
3. Choose the components
Use a selection from the built-in library that covers every part of a comparison, the chat library's follow-up suggestions, and one component of your own:
import { createLibrary } from "@openuidev/react-lang";
import { openuiChatLibrary, openuiLibrary } from "@openuidev/react-ui/genui-lib";
import { Sources } from "./components/sources";
export const library = createLibrary({
root: "Stack",
components: [
...[
"Stack",
"CardHeader",
"TextContent",
"Table",
"Col",
"BarChart",
"HorizontalBarChart",
"LineChart",
"Series",
"Callout",
"TagBlock",
].map((name) => openuiLibrary.components[name]),
Sources,
openuiChatLibrary.components.FollowUpBlock,
openuiChatLibrary.components.FollowUpItem,
],
});Table holds the page-cited values, the three chart types cover single values, trends, and breakdowns, and Callout flags gaps and conflicts.
Sources wraps the source strip that React UI already provides. defineComponent gives it a name, a schema the model fills in, and a description for the prompt:
export const Sources = defineComponent({
name: "Sources",
props: z.object({ items: z.array(CardSourceSchema) }),
description:
"A strip of source cards. Each item is { title, sourceName, url }: title is the quote, sourceName names the document and page, and url opens the page.",
component: ({ props }) => (
<CardSourceProvider sources={onePerPage(props.items ?? [])}>
<SourceStrip />
</CardSourceProvider>
),
});Each card shows the site's icon, the company and page, and the quote. onePerPage merges two quotes from the same page into one card. Generate the server specification from the same library with npm run generate, which also runs before development and builds.
4. Customize Agent Interface
Connect generation to the local chat route with fetchLLM. It sends the thread's messages in Chat Completions format and reads the route's stream with agUIAdapter():
const llm = fetchLLM({
url: "/api/chat",
streamAdapter: agUIAdapter(),
messageFormat: openAIMessageFormat,
});Chat Completions does not store conversations, so Agent Interface keeps each thread in the browser and sends its messages with every question. That is what lets a follow-up add a criterion to the comparison. Threads last until the page reloads. To keep them, pass a storage adapter to Agent Interface; see Connect a backend.
The example also customizes Agent Interface through its public props and slots:
const mode = useSystemThemeMode();
const theme = useMemo(() => ({ mode, lightTheme, darkTheme }), [mode]);
<AgentInterface
llm={llm}
componentLibrary={library}
agentName="Filing analyst"
logoUrl="/logo.svg"
theme={theme}
starters={starters}
starterVariant="long"
>
<AgentInterface.Sidebar>
<AgentInterface.SidebarHeader />
<AgentInterface.SidebarContent>
<AgentInterface.NewChatButton />
<AgentInterface.SidebarItem path="documents" icon={<FileText size={16} />}>
Documents
</AgentInterface.SidebarItem>
<AgentInterface.SidebarSeparator />
<AgentInterface.ThreadList />
</AgentInterface.SidebarContent>
</AgentInterface.Sidebar>
<AgentInterface.Route path="documents">
<DocumentLibrary />
</AgentInterface.Route>
<AgentInterface.ThreadHeader>
<span className="thread-context">Comparing NVIDIA · AMD · Intel annual reports</span>
</AgentInterface.ThreadHeader>
</AgentInterface>;- Theme. theme.ts uses
createThemeto set the accent and user-message colors for light and dark mode, anduseSystemThemeModefollows the operating system's setting. - Brand.
agentNameandlogoUrlfill the default sidebar header, which keeps the logo in the collapsed sidebar. logo.svg switches colors for dark mode with aprefers-color-schememedia query. - Sidebar. Supplying
AgentInterface.Sidebarreplaces the default sidebar, so the example composes its own header, New chat button, Documents item, and thread list. See Sidebar. - Pages. A
SidebarItemwith apathopens the matchingAgentInterface.Routein place of the chat. The Documents page lists each report with its page count and a link to the source PDF, and returns to the chat withuseNav. - Starters.
starterVariant="long"shows starters as a list with icons. See Welcome and starters.
5. Stream the answer
The chat route passes the search tool and the prompt to Gateway:
const searchTool = searchDocumentsTool(documents);
const params = {
model: process.env.OPENUI_MODEL || "openai/gpt-5.5",
messages: [
{ role: "system" as const, content: comparisonPrompt(documents) },
...conversation(body.messages),
],
tools: [searchTool],
max_completion_tokens: 6000,
};
const firstStream = await gateway.chat.completions.create(
{ ...params, stream: true },
{ signal: request.signal },
);
await runChatToolLoop({
client: gateway,
params,
firstStream,
tools: { [searchTool.function.name]: executeSearchDocuments },
emit,
signal: request.signal,
maxRounds: 4,
});The route runs the same function-tool loop as the conversational analytics cookbook, with maxRounds: 4. It runs each requested search, appends the results, and requests the next completion until the model answers, streaming AG-UI events so Agent Interface shows each search and its passages. The extra rounds let the model search again with different wording when a document returns found: false. conversation() drops the tool calls and results the browser sends back, so every quote comes from a search your server ran for the current question.
This example calls Gateway directly from a Next.js route, but the tool and prompt can also run on an agent framework such as LangGraph, the Vercel AI SDK, Mastra, or Google ADK. Agent Interface stays the same and reads the framework's stream with the matching adapter. See our LangGraph Platform, Vercel AI SDK, and Vercel Eve integrations, or the Mastra and Google ADK examples.
The prompt turns the evidence into a consistent answer:
- One search per criterion, in parallel when the question has several.
- Nothing written before the searches finish. Agent Interface shows text that arrives before a tool call as a step in Behind the scenes, and a status message written in OpenUI Lang would appear there as code.
- A table with one short, page-cited value per cell, such as “$215.9 billion (p. 37)”: one row per criterion and one column per document, or one row per part when a criterion has several, such as each company's segments.
- Charts chosen from the data, each at full width: a line chart when a report gives several years of figures, one horizontal bar chart with a bar per company and segment for a breakdown, and a bar chart for one value per document. A document without a value is left out of the chart rather than plotted as zero.
- Callouts for information that conflicts or is not directly comparable, such as different fiscal year ends, and for criteria a document does not disclose.
- Sources, one card per quoted page with the company, page, and a quote of at most 20 words, linked to that page of the PDF.
Verify it works
In the browser:
- Ask “Compare revenue and growth.” Open a card in Sources to check a value on its page. NVIDIA reports fiscal 2026 revenue of $215.9 billion, up 65%, on page 37.
- Expand Behind the scenes and confirm the model called
search_documentsonce per criterion. - Ask “How has R&D spending changed over three years?” and “Break down revenue by segment.” Confirm the first uses a line chart, and the second a single bar chart with a table row per segment.
- Ask “Compare each CEO's total compensation.” Annual reports refer readers to the proxy statement for executive pay, so the table should say “Not found” with a callout explaining why, and no chart.
- Open Documents in the sidebar and confirm it lists the three reports with their page counts.
- Switch your operating system between light and dark mode, and confirm the theme follows.
Generated layouts can vary. Values should always match a quoted passage.
Adapt it to your documents
Add your own PDFs to the example's documents/ folder and run npm run prepare:documents again; they are compared alongside the samples. To compare only your documents, remove the entries from sources in src/lib/documents.ts, then update the starters and thread header in comparison-chat.tsx.
The importer reads a PDF's text layer, so run OCR on scanned documents first. For large collections, store the embeddings in a vector database and keep the search_documents tool's contract.
This example runs locally without authentication, and its chat route accepts browser requests only from its own page. Before deploying it, add authentication and rate limits. See the example README for the implementation notes.
Documents: the Form 10-K annual reports of NVIDIA, AMD, and Intel, downloaded from each company's investor relations site during setup. They are not included in this repository.
Conversational analytics
Build a chat assistant that answers questions about Formula 1 lap times with live charts and tables.
Booking assistant
Build a chat assistant that turns a request for a stay into a prefilled form, searches live hotel prices through trivago's MCP server, and reviews the stay before booking.