Compoze
2025 · sole founder · sold to a client
Compoze was a company I ran on evenings and weekends alongside my job at TikTok. It built assistants that answer questions from a company's own documents, and a client bought it at the end of 2025.
Staff could ask a question and get an answer with its sources attached, so they could open the document and check it instead of searching through files by hand.

Figure 02 · User flow
An admin fills the knowledge base, then a user asks questions
- Admin
- User
- Answer
Working alone
Each engagement ran from the first customer conversation through deployment and training, and I handled all of it. Organising the knowledge base took the longest, because only the customer could decide which documents belonged in it and which should stay out. Running Compoze beside a full-time job also meant it had to keep working without constant attention.
Figure 03 · The whole system
How a question and a document move through the system
- Request
- Response
- Ingest

One codebase for every customer
Compoze ran on two stacks: a Python assistant on LlamaIndex and FastAPI with LlamaCloud doing the indexing, and the platform on this page, built on Next.js and Postgres with pgvector. Every customer shared one codebase and one database. Each customer's rows carry a tenant id and are kept apart twice, once in the app and once with Postgres row-level security. One database kept migrations and billing simple, but it meant the tenant checks had to be right everywhere. Nine models from OpenAI, Anthropic and Google sit behind one gateway, chosen per environment in config.
Finding the right passages
Documents are split into chunks of about a thousand characters, and each chunk gets a one-line header saying where in the document it came from. A question searches up to five knowledge bases at once, using both embeddings and plain keyword search, since embeddings alone miss exact words like codes and names. Anything under a 0.35 similarity score is dropped, and a reranker puts the rest in order.
Figure 05 · Query
How a question searches five knowledge bases and checks its citations
- Question
- Retrieval
- Answer
Each citation is checked against the sentence it supports, and the source links and scores let readers see how well a passage backs up the answer. When even the best passage is under the cutoff, the assistant says it can't answer instead of guessing.
How I knew it worked
I kept a fixed set of questions and expected answers from logs and testing, including questions the documents couldn't answer, where the right reply was a refusal. Search and the written answer are scored separately. The set runs in CI, so a change to the prompt, the chunking or the model fails if it does worse than the version on main.
Getting the documents in
Files come in from Lark and Google Drive, and processing runs in the background on QStash. The failure cases took more of my time than the normal path, because a job that dies halfway leaves a document that looks uploaded but never shows up in answers. So each step can be retried on its own, uploading the same file twice does nothing, and anything that keeps failing lands in a dead-letter queue with the error attached. That gave me a record of what failed and what needed retrying.
Figure 06 · Ingest
How an uploaded document is processed, and where failures go
- Document
- Failure


Safety
A synced document could contain instructions aimed at the model. I mark retrieved text as data in the prompt and limit each agent to its own tools. That limits what a planted instruction can do, but it doesn't stop the model from reading it.
Figure 09 · Chat
The answer keeps generating if the reader closes the tab
- Question
- Tokens
- Hand-off
Implementation details
- domain agents
- 4
- agent tools
- 9
- models / providers
- 9 / 3
- knowledge bases per search
- 5, in parallel
- similarity floor
- 0.35 cosine
- test specs
- 27
- api routes
- 53
- database tables
- 23
- typescript
- ~76k lines
Edge middleware replaced any client-supplied identity header with the user and tenant id from the verified JWT, so routes trusted it and skipped a database round trip. Indexes led with the tenant id, and chat creation was an atomic insert-on-conflict.
- cursor pagination
- fetch limit + 1 to learn whether a next page exists, no COUNT
- adaptive polling
- 3s while processing, 5s while pending, 30s idle, previous data kept so the table never flashes
- connector caches
- per-connector LRU with per-action TTLs for Drive and Lark, plus a sliding-window rate limit per tenant
- sse anti-buffering
- no-transform cache headers, X-Accel-Buffering off, framework compression disabled, per-route duration budgets on fluid compute
- tool surface
- only the tools the agent and the tenant connectors allow reach the model, with a tighter step budget on background runs
With no one else reviewing the code, I leaned on automated checks: typed route contracts, Zod validation on every payload and contract tests on each push. Each request is traced with tokens, model and tenant, which is what pricing was based on.
// NEXT
UI Pack
The design system behind these figures