Build a Contract Analyzer
Ship a contract Q&A tool in twenty minutes. Upload a PDF, ask questions, and get answers with quoted excerpts. The free path handles most contracts — you only pay tokens on the actual questions.
By the end of this guide you'll have a working "chat with your contract" tool: a user uploads a PDF, types a question, and gets an answer grounded in the document. Twenty minutes if you have a Next.js app running.
We'll use cencori.documents.extract() (free for text-based PDFs) and cencori.documents.query() (a few cents per question) to keep costs sensible even on 100-page contracts.
What you're building
- Upload a PDF once, ask many questions
- Each question hits
/v1/documents/query - Answers cite the relevant excerpt when possible
- The system prompt guarantees "Not found in the document" instead of hallucinated legal opinions
Prerequisites
- A Cencori project + API key (
csk_...) - Next.js app (adapt trivially to any Node backend)
npm install cencori
Step 1 — Upload once, ask many
We only want to parse the PDF once per session. Cache the extracted text server-side (or in Redis for production), then run each question against it.
// app/api/contract/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Cencori } from 'cencori';
import { setContract } from '@/lib/contract-store';
const cencori = new Cencori();
export async function POST(req: NextRequest) {
const form = await req.formData();
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'file required' }, { status: 400 });
}
const buf = Buffer.from(await file.arrayBuffer());
// One shot: parse the PDF and store the text.
const result = await cencori.documents.extract({
document: {
base64: buf.toString('base64'),
mimeType: file.type || 'application/pdf',
filename: file.name,
},
});
const contractId = crypto.randomUUID();
await setContract(contractId, {
filename: file.name,
text: result.text,
pageCount: result.pageCount,
extractMethod: result.method,
});
return NextResponse.json({
contractId,
pageCount: result.pageCount,
method: result.method, // 'pdf_text' means the parse was free
charCount: result.text.length,
});
}method: 'pdf_text' in the response means no LLM tokens were spent — the parse was free. That's the case for almost every contract exported from Word, DocuSign, PandaDoc, etc.
lib/contract-store.ts can be as simple as an in-memory Map for a demo, or Redis / DynamoDB / your database of choice for production:
// lib/contract-store.ts
const store = new Map<string, { filename: string; text: string; pageCount: number; extractMethod: string }>();
export async function setContract(id: string, data: { filename: string; text: string; pageCount: number; extractMethod: string }) {
store.set(id, data);
}
export async function getContract(id: string) {
return store.get(id);
}Step 2 — Ask questions
Each question is a small POST /v1/documents/query call. The strict system prompt guarantees no invented text.
// app/api/contract/[id]/ask/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Cencori } from 'cencori';
import { getContract } from '@/lib/contract-store';
const cencori = new Cencori();
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { question } = await req.json();
const contract = await getContract(id);
if (!contract) return NextResponse.json({ error: 'not_found' }, { status: 404 });
// Rebuild a data URL so cencori.documents.query re-parses.
// Simpler: use the text we already cached and hit chat directly.
// For a full walkthrough we'll use the composed endpoint.
const result = await cencori.documents.query({
document: {
// We stored the text, but the endpoint needs a document.
// In practice you'd cache the original bytes, not just the text.
// For this guide, keep the original in your store.
base64: contract.originalBase64, // <— add this to setContract
mimeType: 'application/pdf',
},
question,
});
return NextResponse.json({
answer: result.answer,
cost: result.cost.cencoriChargeUsd,
tokens: result.usage.totalTokens,
});
}That works but re-parses the PDF on every question. Cheaper trick: once you have the text cached, use cencori.ai.chat() for the question with a strict system prompt. You skip a round of parsing and get the same behavior:
const response = await cencori.ai.chat({
model: 'gpt-4o-mini',
temperature: 0,
messages: [
{
role: 'system',
content:
'You answer questions strictly from the provided document. ' +
'If the answer is not present, say "Not found in the document." ' +
'Do not invent details. Include short quoted excerpts when useful.',
},
{ role: 'user', content: `Document:\n\n${contract.text}\n\n---\n\nQuestion: ${question}` },
],
});Same result, one fewer round-trip on repeat questions.
Step 3 — Drop in the upload UI
Reuse <VisionUploader /> from cencori/react — it handles drag-drop, format validation (PDF is unsupported by that widget today, so pass an explicit accept and disable image validation).
For simplicity, roll a minimal uploader:
// app/contract/page.tsx
'use client';
import { useState } from 'react';
export default function ContractPage() {
const [contractId, setContractId] = useState<string | null>(null);
const [question, setQuestion] = useState('');
const [answer, setAnswer] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setLoading(true);
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/contract/upload', { method: 'POST', body: form });
const data = await res.json();
setContractId(data.contractId);
setLoading(false);
}
async function handleAsk() {
if (!contractId || !question) return;
setLoading(true);
setAnswer(null);
const res = await fetch(`/api/contract/${contractId}/ask`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
const data = await res.json();
setAnswer(data.answer);
setLoading(false);
}
return (
<div className="mx-auto max-w-2xl space-y-6 p-8">
<h1 className="text-2xl font-semibold">Contract Q&A</h1>
{!contractId && (
<label className="block cursor-pointer rounded-lg border-2 border-dashed p-8 text-center">
<input type="file" accept="application/pdf" className="hidden" onChange={handleUpload} />
{loading ? 'Parsing…' : 'Click to upload a PDF'}
</label>
)}
{contractId && (
<div className="space-y-3">
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="What is the termination clause?"
className="w-full rounded-lg border p-3"
rows={2}
/>
<button onClick={handleAsk} disabled={loading} className="rounded-lg bg-neutral-900 px-4 py-2 text-white">
{loading ? 'Thinking…' : 'Ask'}
</button>
{answer && (
<div className="rounded-lg border bg-neutral-50 p-4 whitespace-pre-wrap dark:bg-neutral-900">
{answer}
</div>
)}
</div>
)}
</div>
);
}Ship this to your app and you have a working contract Q&A tool. All the AI-magic parts are two SDK calls; everything else is basic Next.js.
Step 4 — Production notes
Cache the text. Every question shouldn't re-parse the PDF. Store the extracted text in Redis or your database keyed by contract ID.
Rate-limit per contract. One user asking 500 questions in a minute burns money. Rate-limit at the contract ID level, not per user, so a group demo doesn't get throttled.
Log costs per question. The response includes cost.cencoriChargeUsd — persist it so you can see your per-conversation margin.
Validate the schema. For structured extraction (dates, dollar amounts, party names), use cencori.ai.generateObject() with a Zod schema instead of freeform query — you'll get typed data back and Cencori will fail loudly if the model returns invalid JSON.
Handle the scanned-PDF error. If extract returns error: 'scanned_pdf_not_yet_supported', show the user a helpful message: "This PDF looks like a scan — try uploading each page as an image." Or rasterize on the client with pdf.js and send the pages to /v1/vision with the multi-image field.
Redact before you ship. Contracts contain PII, and cencori.ai.chat does not mask it. If you're logging the extracted text anywhere your team can see, redact it yourself. PII protection can block requests that contain personal data, but it blocks rather than masks — so it would reject most contracts.
Try other document types
The same pattern works for:
- Invoices — replace the system prompt with an extraction schema
- Resumes — "How many years of Python experience does this candidate have?"
- Reports — "Summarize the key findings. What are the three main risks called out?"
- Policy documents — "Does this SOC 2 report mention SSO?"
Where to go next
- Documents API reference — all three endpoints, error codes, formats
- Build a Receipt Scanner — the same pattern using Vision instead of Documents
- Embed the VisionUploader Component — the React component reference

