Why I built it
Recruiters skim resumes. A static PDF cannot answer "has she used Kubernetes in production" or "is he open to relocating", and the candidate cannot sit online all day waiting for that chat either. The interesting part of a career story usually only shows up in conversation. It rarely makes it to the top of the funnel.
ProBot is a personal AI representative grounded only in the owner's own career data. Paste a resume, pick a model, bring your own API key, and you get a public chat URL plus a one-line embeddable widget. The bot answers from your material, captures recruiter leads, and shows who asked what.
Don't just send a resume. Send a representative.
The engineering problem underneath the product
Asking a stranger to hand over an LLM API key is a big ask. The design question became: how little trust can I require, and can I let the user choose how much to extend?
The trust gradient
I did not pick one deployment model and defend it. I shipped three and let the user choose. Read top to bottom: each one asks less of me.
| Model | Where the key lives | What the user trusts me with |
|---|---|---|
| Managed hosted | Envelope-encrypted in my database | An encrypted key, and that I don't read my own env vars |
| Embed widget | Owner's hosted endpoint | Same as managed, but the embedding site sees nothing |
| Fully self-hosted | The owner's own backend | Nothing. Analytics only, and only if they opt in |
One codebase, three deployment models, two published npm packages.
Architecture
One Next.js 14.2 App Router application. Frontend pages and /api/* routes are
co-deployed. No microservice split. For a solo project on a free tier that is
the right call: one deploy, one log stream, one place to look when something
breaks.
Surfaces
Bot wizard, knowledge uploads, analytics, lead inbox, notification bell.
The recruiter-facing surface for a managed bot.
Floating bubble on any site. The embedding site never sees the LLM key.
Chat runs inside the owner's own app; only analytics post back to api/v1.
Application (Next.js 14 App Router, serverless routes)
Each step returns a discriminated union, so the orchestrator is a flat sequence of early returns.
750-token chunks with 100 overlap, top-5 cosine retrieval, silent fallback to full context.
Anthropic, OpenAI, Azure, Gemini, Grok, Ollama - each behind its own circuit breaker.
AES-256-GCM envelope encryption server-side; Web Crypto key store in the browser.
Data and external
Every table calls enableRLS() with no policies, so the PostgREST anon role reads nothing.
Rate limiting and breaker state; falls back to in-process when unset.
The user pays their own inference bill - the zero-cost policy in one line.
- Surface
- Application
- Storage
- Third party
One Next.js deployment unit. Hover any block for the detail behind the decision.
Route groups
(auth), (dashboard), and (marketing) share layouts without appearing in
the URL. u/[username] is the public chat surface. api/v1/ is the public API
self-hosted bots post analytics back to.
Domain-by-folder
src/lib splits by domain (ai, rag, crypto, leads, ingestion), not
by layer. There is no services/ or utils/ dumping ground.
Deny-by-default data
Every table calls .enableRLS() with no policies, so Supabase's PostgREST roles
are denied outright while my own pool connects as table owner and is unaffected.
Zero-cost policy
Free tier, open source, or local. No paid APIs baked in. That is why avatars are
bytea in Postgres instead of S3 objects.
- Next.js
- TypeScript
- PostgreSQL
- RAG
- MCP
The tech stack, and why
| Layer | Choice | Why it earned its place |
|---|---|---|
| Language | TypeScript 5.6, strict + noUncheckedIndexedAccess | Indexed access returns T | undefined, so empty-array lookups get handled at every call site |
| Framework | Next.js 14.2 App Router | Single deployment unit; API routes are serverless functions |
| ORM / DB | Drizzle 0.36 over Postgres + pgvector | Lazy singleton pool, generated migrations, vector search in the same database |
| Auth | NextAuth v4 | Credentials, Google, GitHub, and magic link behind one JWT session |
| LLM clients | Anthropic, OpenAI, Azure, Gemini, Grok, Ollama | One LLMProvider.complete() interface, per-request clients |
| Embeddings | text-embedding-3-large truncated to 1536 dims | Matryoshka truncation keeps the vector column small |
| Markdown | react-markdown + remark-gfm, no rehype-raw | Raw HTML in a model reply cannot execute. XSS-safe by default |
| Crypto | Node crypto server-side, Web Crypto in the browser | Envelope encryption and the non-extractable client key store |
The key never touches my servers in plaintext
This is the part of the project I care about most. There are two storage modes.
Browser-held
The key sits in IndexedDB encrypted under a non-extractable Web Crypto
AES-256-GCM master key. Because the master key is created with
extractable: false, the browser itself refuses crypto.subtle.exportKey. The
key travels to the server in an x-llm-api-key header.
The honest limitation
This raises the bar from localStorage.getItem (plaintext in DevTools) to
needing a subtle.decrypt call. It is good against casual inspection, extension
data exports, and shoulder-surfing. It is not good against same-origin XSS,
because injected script could simply call decrypt() itself. I accepted that
residual risk because no third-party scripts are allowed on the origin, and I
wrote it down in the repo rather than pretending it away.
Managed: envelope encryption
Encrypt
Arrives in the x-llm-api-key header - never a JSON body field.
Zeroed with dek.fill(0) in a finally block before it leaves scope.
Never in the database, never in git, never in a backup.
At rest in encrypted_llm_keys
Surfaced in the dashboard so owners can see when their key was used.
Rotation re-wraps the DEK only, so the ciphertext columns stay byte-identical.
Generate a per-bot DEK
A fresh random 32-byte data-encryption key, one per bot.
Encrypt the LLM key with the DEK
AES-256-GCM, producing
{ciphertext, iv, authTag}.Wrap the DEK with the KEK
The key-encryption key is one 32-byte AES key held only in an environment variable. Never in the database, never in git, never in a backup.
Zero the bare DEK
dek.fill(0)inside afinallyblock. Node strings are garbage-collected and cannot be reliably wiped, so the code decrypts into Buffers and only materialises a string at the last moment the SDK requires one.
Rotation re-wraps DEKs rather than re-encrypting data, so it's one UPDATE per
row and the {ciphertext, iv, authTag} columns stay byte-identical. Encrypting
each key directly under a single server key would have meant decrypting and
re-encrypting every secret. Slower, and a much larger window where plaintext
exists.
| Surface | State |
|---|---|
encrypted_llm_keys in Postgres | AES-256-GCM ciphertext, wrapped DEK |
| Connection to the provider | TLS in transit |
| Server memory during a request | Plaintext, then zeroed |
| JSON request body | Never |
| Server logs | Never |
| Error responses | Never echoed |
| KEK in the database | Never (environment only) |
Enforced, not promised
A CI guard (npm run check:key-leaks) fails the build if a key can reach a log
line or an error body. Mechanical enforcement beats discipline.
The chat pipeline
~35 prompt-injection patterns checked against the normalised message.
Two-tier sliding window, per bot, overridable per record.
Raw pgvector cosine query with the query vector bound as a parameter.
Identity, rules, tone, custom instructions, response style, fallback, then context.
5 failures opens it, 30s cooldown, one half-open probe.
Output scanned for leakage; the transcript write never blocks the response.
Every stage can short-circuit. Persistence is fire-and-forget so analytics never delay a reply.
Thirteen steps, each a composable function returning a discriminated union. The orchestrator reads as a flat sequence of early returns with no nested try/catch. Prompt construction order is deliberate: identity, immutable rules, tone, the owner's custom instructions, response style, what to do when the answer isn't in context, then the retrieved context itself. Response style comes after custom instructions on purpose, so a malformed custom block cannot override the structural rules.
Retrieval chunks at 750 tokens with 100 tokens of overlap using cl100k_base,
embeds in batches of 96, and pulls the top 5 by cosine similarity. If anything in
the embedding path breaks, it falls back silently to the bot's full assembled
context. A degraded answer beats an error.
Schema decisions worth defending
- The API key is not a column on
users. That absence is the single most important line in the schema. leads.conversationIdisON DELETE SET NULL, not cascade. A captured recruiter email stays valuable to the owner after the transcript is purged for GDPR reasons. Cascade would have destroyed the lead with the log.conversationsis unique on(botId, sessionId), so concurrent tabs coalesce into one conversation instead of fragmenting the transcript.- A partial index on
read_at IS NULLkeeps the unread-notification count O(unread) rather than O(total) on every dashboard load. - A CHECK constraint locks
messages.roleto four values, so a future typo cannot quietly corrupt analytics.
Reliability
A per-provider circuit breaker opens after 5 failures, cools down for 30 seconds, then allows one half-open probe. A two-tier sliding-window rate limiter defaults to 10 requests per minute and 50 per day, overridable per bot. Both run in-process by default and switch to Upstash Redis when it's configured. The call sites don't change either way.
Design rationale
Why bring-your-own-key rather than billing users?
The zero-cost policy is a hard constraint, not an aspiration. BYO-key means the user pays their own inference bill, which removes both my cost exposure and the need to become a payments company. It also forces the interesting security work that makes the project worth talking about.
Why three deployment models instead of one good one?
Because key custody is a matter of taste, not correctness. Some users are happy with encrypted server-side storage. Others will not accept any server-side custody at all. Shipping the gradient means I never have to argue someone out of their threat model.
Why one Next.js app rather than a service split?
Team of one, free tier. A split would buy independent scaling I don't need and cost me a second deploy target, a second log stream, and cross-service tracing.
Why deterministic lead summaries instead of an LLM call?
Summarising the first few recruiter messages by truncation is free and predictable. An LLM call there would spend the owner's tokens on something they didn't ask for.
What I'd tell someone building the same thing
Write down what you did not cover
The threat model in the repo names what envelope encryption does not protect against (full infrastructure access and RCE on the running process) and points at the self-hosted package as the escape hatch. Naming the gap is what makes the rest credible.