Skip to content
VishalPatil
Projects / ProBot

ProBot

Turn your resume, LinkedIn, and portfolio into a personal AI chatbot.

Stack used

  • Next.js
  • TypeScript
  • PostgreSQL
  • RAG
  • MCP

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.

ModelWhere the key livesWhat the user trusts me with
Managed hostedEnvelope-encrypted in my databaseAn encrypted key, and that I don't read my own env vars
Embed widgetOwner's hosted endpointSame as managed, but the embedding site sees nothing
Fully self-hostedThe owner's own backendNothing. 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.

System architecture

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

LayerChoiceWhy it earned its place
LanguageTypeScript 5.6, strict + noUncheckedIndexedAccessIndexed access returns T | undefined, so empty-array lookups get handled at every call site
FrameworkNext.js 14.2 App RouterSingle deployment unit; API routes are serverless functions
ORM / DBDrizzle 0.36 over Postgres + pgvectorLazy singleton pool, generated migrations, vector search in the same database
AuthNextAuth v4Credentials, Google, GitHub, and magic link behind one JWT session
LLM clientsAnthropic, OpenAI, Azure, Gemini, Grok, OllamaOne LLMProvider.complete() interface, per-request clients
Embeddingstext-embedding-3-large truncated to 1536 dimsMatryoshka truncation keeps the vector column small
Markdownreact-markdown + remark-gfm, no rehype-rawRaw HTML in a model reply cannot execute. XSS-safe by default
CryptoNode crypto server-side, Web Crypto in the browserEnvelope 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

Envelope encryption of the LLM key

Encrypt

Arrives in the x-llm-api-key header - never a JSON body field.

Next step: AES-256-GCM

Zeroed with dek.fill(0) in a finally block before it leaves scope.

Next step: wrap

Never in the database, never in git, never in a backup.

At rest in encrypted_llm_keys

ciphertext · iv · authTagthe key under the DEK
wrappedDek · dekIv · dekAuthTagthe DEK under the KEK

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.

  1. Generate a per-bot DEK

    A fresh random 32-byte data-encryption key, one per bot.

  2. Encrypt the LLM key with the DEK

    AES-256-GCM, producing {ciphertext, iv, authTag}.

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

  4. Zero the bare DEK

    dek.fill(0) inside a finally block. 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.

SurfaceState
encrypted_llm_keys in PostgresAES-256-GCM ciphertext, wrapped DEK
Connection to the providerTLS in transit
Server memory during a requestPlaintext, then zeroed
JSON request bodyNever
Server logsNever
Error responsesNever echoed
KEK in the databaseNever (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

Chat request 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.conversationId is ON 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.
  • conversations is unique on (botId, sessionId), so concurrent tabs coalesce into one conversation instead of fragmenting the transcript.
  • A partial index on read_at IS NULL keeps the unread-notification count O(unread) rather than O(total) on every dashboard load.
  • A CHECK constraint locks messages.role to 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.