Open-source job application tracker with LLM-powered CV matching.
Paste a job description and your CV. Get a structured fit score, matched skills, gaps, and a tailored recommendation. Track applications through their lifecycle. Sign in with email + password (with verification) or Google / GitHub OAuth. Every user gets an enforced daily LLM token budget.
- CV PDF upload — drop a PDF resume on the application detail page; the backend extracts text with
pypdfand populates the CV field. 5MB limit. Scanned-only PDFs are rejected. - LLM match analysis — paste/upload CV → structured fit score, strengths, gaps, interview questions. Results cached per
(model, job_description, cv_text)hash; re-run with?force=true. - Suggested LinkedIn profiles — single button per application queries the Brave Search API for
site:linkedin.com/in/hits matching the role + company. Results are cached on the row (JSONB column); "Refresh" re-fetches and keeps the previous list if the new fetch fails. - Status tracking —
saved→applied→interviewing→offer/rejected/withdrawn. - Authentication — Auth.js (NextAuth v5) with credentials (Resend-verified email + bcrypt) and optional Google / GitHub OAuth. Backend trusts a 15-minute HS256 bearer JWT minted by the web client.
- Per-user daily token budget — every
/matchcall deducts from a daily cap tracked intoken_usage; over-cap returns429 DAILY_TOKEN_LIMIT.
| Layer | Platform |
|---|---|
| Backend runtime | Railway — Docker image, auto-injected DATABASE_URL |
| Database | Railway Postgres (PostgreSQL 16, JSONB) |
| Frontend runtime | Cloudflare Workers via @opennextjs/cloudflare |
| LLM gateway | OpenRouter (default model openai/gpt-4o-mini) |
| Transactional email | Resend (verify + password-reset) |
| Observability | Pydantic Logfire (SDK + MCP) |
- Language: Python 3.12
- Framework: FastAPI (async)
- ORM: SQLModel on SQLAlchemy 2 async
- Migrations: Alembic (real migration files; no metadata auto-create)
- DB drivers:
asyncpg(runtime),psycopg[binary](migrations) - Auth: own credentials store with
bcrypthashes, verify/reset tokens viaverification_tokenstable, OAuth upsert endpoint - Bearer: HS256 JWT (
PyJWT) minted by the web client and verified by every request - Email: Resend SDK
- LLM client:
pydantic-aiwithOpenAIModel+OpenAIProviderpointed at OpenRouter - Caching: SHA256 hash of
(model, job_description, cv_text)againstApplication.analysis_hash; bypass with?force=true - Budget: per-user daily token cap in
token_usage; over-cap returns429 DAILY_TOKEN_LIMIT - Tracing: Logfire instrumentation for FastAPI, SQLAlchemy, httpx, pydantic-ai
- Lint / typecheck / test:
ruff,pyright,pytest - Container:
python:3.12-slim,boot.shrunsalembic upgrade headthenuvicorn
- Framework: Next.js 16 (App Router)
- Runtime: React 19
- Auth: Auth.js v5 (NextAuth) with Credentials + Google + GitHub providers, JWT session strategy
- Token mint:
joseHS256 — every session carries a 15-minute bearer that the FastAPI backend validates - Styling: Tailwind CSS v4
- Language: TypeScript (strict)
- Deploy adapter:
@opennextjs/cloudflare→ Cloudflare Workers - Theme: High-Contrast Minimalist (Deep Black
#09090B, White#FFFFFF, Slate#64748B, sharp edges, 1px borders, no shadows) - Typography: Geist Sans (UI) / Geist Mono (LLM output, metrics)
| Table | Purpose |
|---|---|
users |
id (UUID), email (unique), password_hash (nullable for OAuth), email_verified, name, image |
verification_tokens |
identifier (verify:<email> or reset:<email>) + token + expires |
token_usage |
user_id, day (date), tokens_in, tokens_out, cost_usd — unique on (user_id, day) |
applications |
id, user_id (FK), company, title, description, applied_at, status, analysis (JSONB), analysis_hash, suggested_profiles (JSONB), suggested_profiles_updated_at, timestamps |
Status enum: saved, applied, interviewing, offer, rejected, withdrawn.
CV text is never persisted. Only the SHA256 hash and the structured analysis are stored.
All /applications/* and /auth/oauth-upsert require the relevant credential. /auth/* (except oauth-upsert) and /healthz are public.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/auth/register |
— | Create unverified user, send verify email |
POST |
/auth/verify |
— | Consume verify token, mark email verified |
POST |
/auth/login |
— | Validate password + verified status (called by Auth.js) |
POST |
/auth/forgot |
— | Send reset email (always 200) |
POST |
/auth/reset |
— | Consume reset token, set new password |
POST |
/auth/oauth-upsert |
X-Auth-Secret |
Find-or-create user from OAuth profile |
GET |
/applications |
Bearer | List own applications |
POST |
/applications |
Bearer | Create |
GET |
/applications/{id} |
Bearer | Detail (own only) |
PATCH |
/applications/{id} |
Bearer | Partial update |
DELETE |
/applications/{id} |
Bearer | Remove |
POST |
/applications/{id}/match?force=false |
Bearer | Run LLM match. Returns cached unless force=true. Deducts from daily budget. |
POST |
/applications/{id}/suggested-profiles?refresh=false |
Bearer | Cached LinkedIn profile hits via Brave Search. refresh=true re-fetches; falls back to cached on error. |
POST |
/cv/parse-pdf (multipart) |
Bearer | Extract plain text from a PDF resume. |
GET |
/healthz |
— | Liveness |
| Key | Required | Default |
|---|---|---|
DATABASE_URL |
yes | injected by Railway |
OPENROUTER_API_KEY |
yes | — |
OPENROUTER_MODEL |
no | openai/gpt-4o-mini |
OPENROUTER_BASE_URL |
no | https://openrouter.ai/api/v1 |
BACKEND_JWT_SECRET |
yes | shared with the Worker; HS256 secret for bearer JWTs |
AUTH_SHARED_SECRET |
yes (if OAuth) | shared with the Worker; required on /auth/oauth-upsert |
DAILY_TOKEN_BUDGET |
no | 50000 |
BRAVE_API_KEY |
no | enables /applications/{id}/suggested-profiles; empty hides the feature |
BRAVE_SEARCH_URL |
no | https://api.search.brave.com/res/v1/web/search |
CV_PDF_MAX_BYTES |
no | 5000000 |
RESEND_API_KEY |
yes | for verify/reset emails |
EMAIL_FROM |
yes | verified Resend sender |
FRONTEND_BASE_URL |
yes | origin used in mailed links |
CORS_ORIGINS |
no | http://localhost:3000 |
LOGFIRE_TOKEN |
no | — |
LOGFIRE_SEND_TO_LOGFIRE |
no | if-token-present |
APP_ENV |
no | development |
| Key | Required | Where | Notes |
|---|---|---|---|
NEXT_PUBLIC_API_URL |
yes | build-time | backend base URL |
NEXT_PUBLIC_AUTH_GOOGLE_ENABLED |
no | build-time | 1 to show Google button |
NEXT_PUBLIC_AUTH_GITHUB_ENABLED |
no | build-time | 1 to show GitHub button |
AUTH_SECRET |
yes | wrangler secret | Auth.js session encryption |
BACKEND_JWT_SECRET |
yes | wrangler secret | same value as backend |
AUTH_SHARED_SECRET |
yes (if OAuth) | wrangler secret | same value as backend |
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
no | wrangler secret | Google OAuth |
AUTH_GITHUB_ID / AUTH_GITHUB_SECRET |
no | wrangler secret | GitHub OAuth |
# Backend
cd backend
uv venv --python 3.12 && source .venv/bin/activate
uv pip install -e ".[dev]"
cp .env.example .env # fill in secrets
alembic upgrade head
uvicorn app.main:app --reload
# Seed demo data (optional)
python -m scripts.seed --reset
# Frontend
cd web-client
npm install
cp .env.example .env.local # fill in secrets
npm run dev- Backend: push
backend/to Railway. Attach the Postgres plugin (auto-injectsDATABASE_URL). SetOPENROUTER_API_KEY,BACKEND_JWT_SECRET,AUTH_SHARED_SECRET,RESEND_API_KEY,EMAIL_FROM,FRONTEND_BASE_URL,CORS_ORIGINS. Railway builds the Dockerfile and runsboot.sh(alembic + uvicorn). - Frontend: see
web-client/DEPLOY.md. Set build-time vars in.env.productionand runtime secrets viawrangler secret put, thennpm run deploy.
This project is licensed under the GPLv3 License.