How it’s built
How a document travels through Clivio, and a few technical choices I’m happy with.
- Application code
- 294,439 lines of TypeScript in 1,948 files, tests excluded
- Tests
- About 7,600 unit tests, 258 security-rule tests, and a nightly end-to-end run from sign-up to AI summary
- Surface
- 103 pages, 254 server actions, 57 API route handlers, 26 Cloud Functions
- Languages
- French and English, about 13,400 translated strings each
- Decisions
- 41 architecture decision records, all written down in October 2026
Counted in the repository on 19 October 2026.
The big picture
A Next.js app on Firebase App Hosting, with Google Cloud underneath and Gemini doing the reading. There’s no separate backend to look after.
A document’s journey
Six steps between a file arriving and you being able to ask about it. Click any step, or go through them in order.
How documents get in
A document can arrive by upload, by email to the user’s own @inbox.clivio.app address, by WhatsApp, through a public drop page, through the client portal, from the Android share sheet or a browser extension, or from the REST API and MCP server. Whatever the channel, the file is stored and its database record is created before any AI work starts, with storage quota reserved in the same transaction. A page refresh can’t lose a document.
Waiting in line
Each document becomes a Cloud Tasks job: three attempts, thirty seconds of backoff, ten at a time. The worker calls the processing route with a Google OIDC token, and the route checks issuer, audience and service account before claiming the document in a transaction.
Reading the document
Office files, spreadsheets and text are extracted locally behind a zip-bomb guard. PDFs and images go to Gemini 2.5 Flash, which reads them natively. Small files come back as JSON that must match a schema: 34 document types, invoice fields with net, VAT, total, company numbers and line items. Files over 5 MB or 20 pages go through the Gemini File API in a lighter text mode.
Checking the model’s work
Structured invoice data embedded in the PDF, Factur-X, ZUGFeRD or UBL, overrides the model’s reading. A deterministic layer fixes French amounts and payslip centimes, credit notes get a negative sign, the title follows the user’s naming template, and anything with an amount lands in the finance inbox.
Indexing
The text is cut into chunks of about 1,200 characters with a 150-character overlap, embedded with gemini-embedding-2 in 768 dimensions, and stored as vectors in Firestore. Indexing is idempotent: a chunk whose SHA-256 hasn’t changed is not embedded again.
Answering questions
Search runs a literal pass and a vector pass, then merges them with Reciprocal Rank Fusion. The assistant keeps eight passages, no more than three from the same document, and must cite them with numbered markers, which the interface turns into highlights in the PDF. Questions about totals, counts, deadlines and lists skip the model entirely, and the answer streams in.
- lib/uploads/ingestDocument.ts
- app/api/webhooks/sendgrid/route.ts
- app/api/webhooks/whatsapp/
- app/api/v1/documents
- app/api/mcp/route.ts
Inbound email is checked against a shared token, rate-limited per sender and recipient, deduplicated by hash, and every attachment is validated by its magic bytes.
- lib/aiTasks.ts
- functions/src/aiQueue.ts
- app/api/tasks/process-document/route.ts
- functions/src/aiWatchdog.ts
If the queue is unreachable, processing runs inline. A watchdog every fifteen minutes picks up anything stuck.
- app/actions/ai/helpers.ts
- lib/fileExtractors.ts
- lib/ai/thinking.ts
If the JSON doesn’t parse, the call is retried in text mode instead of failing.
- lib/facturx.ts
- lib/processing/postExtract.ts
- lib/documentNaming.ts
In one sprint, the Factur-X parser went from passing 5 of its 16 test files to all 16.
- lib/rag/chunking.ts
- lib/rag/embeddings.ts
- lib/rag/indexing.ts
- lib/rag/retrieval.ts
When Google retired text-embedding-004, the migration to gemini-embedding-2 was documented right in the code.
- app/api/search/hybrid/route.ts
- lib/assistant/prompt.ts
- lib/search/aggregation.ts
- app/actions/ai/unified.ts
If the vector index is missing, search falls back to literal matching instead of breaking.
A few things I got right
You don’t notice any of this when it works, which is pretty much the point.
Invoice numbers without gaps
An issued invoice takes its legal number, F-2026-0001, in the same transaction that issues it, so there are no gaps and no duplicates. After that it’s frozen: only a credit note can cancel it, and a lost PDF is regenerated byte for byte.
lib/invoicing/numbering.ts, lib/invoicing/facturx/
One figure, one calculation
Every figure in Finance, on every screen, comes from one aggregation module. One document makes one transaction, credit notes come off the totals, and a test checks that a month gives the same numbers everywhere. I got there by auditing the module the way I audit a client’s data.
lib/finance/aggregate.ts, lib/documents/payment.ts
No double billing
The queue can deliver the same job twice. So each run gets a key, built from the run and a SHA-256 of the file, and it’s saved in the same transaction as the credit debit. If a job comes back, nobody pays twice and the model isn’t called again.
lib/ai/documentRun.ts
Stuck jobs get picked up
Workers are called with a Google OIDC token and claim documents atomically. If the queue can’t be reached, processing runs inline instead, and a watchdog sweeps every fifteen minutes for anything stuck or never picked up.
functions/src/aiWatchdog.ts
The invoice’s own data wins
When a PDF carries Factur-X, ZUGFeRD or UBL data, those values override what the model read. A deterministic layer then fixes French amount formats and credit-note signs.
lib/facturx.ts, lib/processing/postExtract.ts
Totals come from the database
A question like “how much did I spend on hosting last month?” never goes to the model. Totals, counts, deadlines and lists are calculated straight from the database before any model call, so they cost no AI action and can’t be made up.
lib/search/aggregation.ts
Citations you can check
The assistant must cite numbered passages. Each marker is mapped back to its chunk, out-of-range ones are dropped, and the valid ones open the PDF at the highlighted passage.
lib/assistant/prompt.ts
Limits on the agent
The planning agent stops after eight steps and three credits. Anything that reaches the outside world, like sending an email, waits for the user to approve it. Automations follow the same rule: an action that sends or removes something waits in “To validate” for up to seven days.
lib/agent/
Uploads are checked
File types are checked by their magic bytes, archives against zip bombs, outgoing URLs against SSRF and DNS rebinding. Redacted shares are rasterised, so masked text can’t be copied back out.
lib/fileExtractors.ts, lib/redactedPdf.ts
Keeping AI costs down
Large files go through the Gemini File API instead of base64, document chats use context caching, and each task has its own thinking budget: 1,024 tokens to extract, none to classify.
lib/ai/thinking.ts, lib/ai/pricing.ts
Security
What’s in place, and what I don’t promise.
- Database and storage rules deny everything by default; each user only reaches their own data.
- API keys are stored as HMAC-SHA-256 hashes and compared in constant time; scopes, folder limits and expiry on each key.
- OAuth tokens and bank details sealed with AES-256-GCM, bound to their owner.
- Two-factor authentication, passkeys verified on the server, revocation-checked sessions, App Check.
- Shared files served through signed links minted per request, valid for one hour.
- A full security audit in July 2026 with six fixes, then a 146-point production audit in October.
Server functions run in Google Cloud’s us-central1 region, and AI calls still go through the Gemini Developer API, outside the EU. Since October the code can send them through Vertex AI in an EU region instead, with a script that checks the region before the switch and an admin report that shows where AI really runs. The switch hasn’t been made, so Clivio doesn’t promise EU-only processing. It doesn’t publish an uptime commitment either, yet.
API and MCP
Clivio has a public REST API and an MCP server, both running on the same code. With a key, a script or an AI agent can search, read and add documents, ask questions of them, and read or fill their fields. The browser extension goes through it too, with a key that can only add documents: if it leaks, it can’t read anything.
{
"mcpServers": {
"clivio": {
"type": "http",
"url": "https://dashboard.clivio.app/api/mcp",
"headers": { "Authorization": "Bearer clv_live_YOUR_KEY" }
}
}
}Hermes, Claude Desktop, Cursor or any MCP client. From docs/mcp.md.
search_documentssearch:readlist_documentsdocuments:readget_documentdocuments:readcreate_documentdocuments:writeask_documentsdocuments:readlist_field_templatesdocuments:readget_document_fieldsdocuments:readextract_document_fieldsdocuments:writeA key only sees the tools its scopes allow. Sixty requests a minute per key; keys expire after 90 days by default. My own assistant, Hermes, uses exactly this to file what people send me.