Félix Boittinclivio.frFR
Clivio case study, part 3 of 5

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.

In the browser
Next.js 16 and React 19 client
Installable PWA, mobile scanner with edge detection
PDF viewer that highlights cited passages
On the server, Firebase App Hosting
254 server actions
API routes and webhooks: email, WhatsApp, Stripe
Public REST API and MCP server
Google Cloud
Firestore, including the vector index
Cloud Storage for the files
Cloud Tasks queue and 26 Cloud Functions
A private Cloud Run service that turns Office files into PDF
Called from the serverGemini 2.5 Flashgemini-embedding-2StripeSendGridUpstashSentryWhatsApp Cloud APIGA4 Measurement ProtocolQonto, Pennylane, MyUnisoft
The architecture at a glance, from the code in October 2026.

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.

In the code
  • lib/uploads/ingestDocument.ts
  • app/api/webhooks/sendgrid/route.ts
  • app/api/webhooks/whatsapp/
  • app/api/v1/documents
  • app/api/mcp/route.ts
A detail

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.

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.
What I don’t promise

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.

What an agent can do
search_documentssearch:read
list_documentsdocuments:read
get_documentdocuments:read
create_documentdocuments:write
ask_documentsdocuments:read
list_field_templatesdocuments:read
get_document_fieldsdocuments:read
extract_document_fieldsdocuments:write

A 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.