A personal AI recruiting agent. It learns your professional profile, continuously discovers jobs, scores them, tailors your resume per opportunity, prepares applications, and assists or automates submission — under your control.
Not a job board. An agent that works your job search in the background.
New here? docs/MANUAL.md is the full operating manual — setup, the daily loop, how to start the automation, what the agent refuses to do, and the known gaps.
Requires Node 20.11+ and Docker.
npm install
cp .env.example .env # then generate the secrets below
npm run infra:up # MongoDB (replica set) + Redis
npm run db:indexes # apply indexes and constraints
npm run db:seed # demo profile, preferences, career analysis
npm run dev # terminal 1 — the app
npm run worker # terminal 2 — the agent workerOpen http://localhost:3000 and sign in with the seeded account:
email: demo@hirepilot.local
password: demo-password-1234
Every seeded record is flagged isDemo: true so demo content can never be
mistaken for your own.
echo "AUTH_SECRET=$(openssl rand -base64 32)"
echo "ENCRYPTION_KEY=$(openssl rand -base64 32)"ENCRYPTION_KEY must decode to exactly 32 bytes — the app refuses to start
otherwise rather than silently using a weak key.
Set AI_PROVIDER=mock and the whole product runs end to end with deterministic
synthetic output and zero cost. It is also what the test suite uses. When a real
provider is configured but has no credentials, the app falls back to the mock
provider and says so loudly in the logs — it will not quietly present fake
analysis as real.
To use Claude, set AI_PROVIDER=claude and CLAUDE_API_KEY.
| Command | What it does |
|---|---|
npm run dev |
Next.js dev server |
npm run worker |
Agent worker process (watch mode) |
npm run build |
Production build |
npm run start |
Production server |
npm run worker:prod |
Agent worker (production) |
npm run infra:up / infra:down |
MongoDB + Redis containers |
npm run db:indexes |
Apply indexes; idempotent, safe on every deploy |
npm run db:seed |
Reset and reload demo data |
npm run db:seed:history |
Backfill ~60 applications so analytics has something to measure |
npm run db:reset |
Re-seed from scratch |
npm test |
Unit tests (no database, no network) |
npm run test:integration |
Integration tests (real MongoDB, mocked AI) |
npm run test:e2e |
Playwright end-to-end tests |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint |
The app and the worker are two processes. Without the worker running, uploads are accepted and queued but nothing analyzes them — the onboarding screen says so explicitly rather than spinning forever.
Non-default ports are used so this project never collides with an existing local MongoDB or Redis:
| Service | Port |
|---|---|
| App | 3000 |
| MongoDB | 27019 |
| Redis | 6381 |
MinIO (optional, --profile storage) |
9000 / 9001 |
Full detail in docs/ARCHITECTURE.md; how to actually run it in docs/MANUAL.md. The essentials:
Two processes, one codebase. HTTP requests validate, authorize, and enqueue. Anything that calls an LLM, a job board, or a browser runs in the worker, so it is retryable, observable, and cancellable.
Layered, dependencies pointing one way.
app/ components/ features/ ← UI
services/ ← use cases: orchestrate, authorize, enqueue
agents/ ← AI workflows, one job each
repositories/ ← the only code that talks to MongoDB
lib/ ← db, ai, queues, storage, auth, browser
MongoDB, no ORM. The official driver plus a typed repository layer. Indexes
are declared in src/lib/db/indexes.ts and are the real schema contract — they
encode the uniqueness constraints that make job dedupe and one-application-per-job
correct under concurrency.
AI behind a narrow interface. complete() for prose, extract(schema) for
typed data. Three implementations: Claude (primary), OpenAI, and a deterministic
mock. Call sites declare a task tier (fast / balanced / deep), never a
model name, so model selection is configuration.
Scoring is deterministic where it should be. Profile completeness, employability, experience totals, and match sub-scores are pure functions of structured data — reproducible and explainable. The AI supplies rationale and may adjust any single dimension by at most ±10 points, with the reason recorded and shown in the UI. It never produces the score itself.
The browser decides nothing. Playwright extracts a serializable snapshot of the page; pure functions classify fields, detect blockers, and build a fill plan; Playwright then executes that plan. Every decision is unit-testable without a browser, and the automation layer is auditable rather than opaque.
One opportunity, one document. Jobs are global, not per-user. A posting is identified by a fingerprint over its normalized company, title, and location bucket, backed by a unique index — so the same role listed on Greenhouse and Lever merges into a single opportunity, and concurrent ingestion workers converge on one document without a transaction.
This is the part that matters most, so it is enforced structurally rather than by asking the model nicely:
- Every generative prompt receives the profile as a closed set of facts plus
the user's recorded corrections, and shares one factuality contract defined in
src/prompts/registry.ts. - Outputs are schema-constrained and re-validated. Claims are tagged
known/inferred/recommendation/unknown, and the UI renders an inference visibly as an inference. - User corrections are binding. "I don't have production AWS experience" is stored, injected into every downstream prompt, and outranks anything the agent infers from your resume or GitHub.
- Fabrication is prevented structurally, then verified. The tailoring agent produces bullets only. Employers, titles, dates, degrees, institutions and certifications are copied from your profile by application code — the model has no field in which to write them, so it cannot invent one. What remains (a fabricated metric or an unevidenced technology inside a bullet) is caught by a verifier, and any violation blocks approval rather than being silently edited away.
- Skills the model suggests that aren't in your profile are dropped and reported; skills it forgets are added back, so tailoring never silently deletes your own material.
- Unanswerable application questions return needs user input, never a guess.
- Estimates (like interview probability) are labelled as estimates, never stated as fact.
- Every generated artifact records the prompt version and model that produced it.
- Bypass CAPTCHA, MFA, bot protection, authentication, or rate limits. Job sources use official APIs and public job-board feeds only; a 429 causes the run to back off, never to retry around the limit.
- Scrape LinkedIn. Profile import uses your own data export or your own pasted text — both normalize into the same model.
- Submit an application containing information it does not actually have.
- Answer questions about work eligibility, sponsorship, disability, demographics, criminal history, salary commitments, or relocation on your behalf. These are classified by rule and filtered out before the model ever sees them — not refused by prompting, which a model may or may not honour. They stop and wait for you in every automation mode, including Auto.
Automation stops, it does not push through. CAPTCHA, MFA, bot protection, login walls and rate limiting are detected and reported with a plain explanation of what to do — there is no code path in this repository that attempts to defeat any of them. Workday and LinkedIn are declared manual-only because their applications sit behind authentication.
Statistics are withheld rather than guessed. A response rate computed from three applications is noise, and showing it as a number invites real decisions on it. Rates below a minimum denominator render as an em dash with the reason; group comparisons are suppressed entirely below 10 applications per group; and every insight carries its sample size and a confidence derived from sample and effect size, not asserted. The feedback loop will say "too few to find patterns" — and will report that our own match scoring is not predicting responses, if that is what the data shows.
Insights are shown, never applied. Nothing in the feedback loop alters your preferences or rules. A system that quietly reweighted a job search off a twelve-application sample would be worse than one that stayed silent.
Rules are deterministic and fail closed. A rule fires only when every condition passes, and an unknown value never satisfies a condition — "salary is unknown" cannot pass "salary >= X", because that condition may be gating an auto-submit. A rule with no conditions never fires. Everything a rule does is written to the application's timeline naming the rule, so "why did this happen?" always has an answer on the page.
Submission passes a gate that cannot be skipped. SubmissionAuthorization
is a branded type only authorizeSubmission can produce, and it checks an
approved resume, every sensitive answer confirmed, no unanswered required
questions, the daily cap, site automatability, and that it has not already been
submitted. Submitting without those checks is a type error, not a code-review
question. Adding require my approval to any rule vetoes auto-submit entirely.
The agent never submits. prepare fills the form and screenshots it; there
is no branch from preparation to clicking submit. Submission is the person's
action, recorded afterwards. A required field the agent cannot confidently
classify becomes a blocker rather than a guess, and consent checkboxes are never
ticked on your behalf.
Credentials never enter the system. Password fields are dropped at the
snapshot boundary, before anything is logged or stored. Automation traces mask
personal data (de**@example.com) and summarize long answers by length, so an
execution log is useful for debugging without archiving what you wrote.
- Auth.js with bcrypt (cost 12) and optional GitHub OAuth; JWT sessions.
- Authorization re-checked server-side on every render and every request. Ownership is part of the database query, not an assumption.
- AES-256-GCM for stored OAuth tokens and site credentials. Credentials are never logged, never included in agent traces, and never sent to an LLM.
- Uploads validated by magic bytes, not by extension or declared MIME type.
- Zod validation at every trust boundary, including queue payloads — a worker never trusts that the producer was the version of the code it expects.
- Redis-backed rate limiting on auth, uploads, and AI-triggering endpoints.
- Structured logging with redaction defined centrally, so a credential cannot be logged by an unlucky call site.
- Secrets only via environment variables; no API key ever reaches the browser.
- CSP,
X-Frame-Options: DENY,nosniff, a restrictivePermissions-Policy, and noX-Powered-By. Framing is denied outright — a dashboard carrying automation controls is a clickjacking target. GET /api/healthreports each dependency separately (503 only when the database is down; Redis unavailable is degraded, since pages still render). It exposes no hostnames, versions, or configuration.
| Phase | Scope | Status |
|---|---|---|
| 1 | Setup, auth, database, UI shell, onboarding, profile intelligence | Complete |
| 2 | Job model, discovery, normalization, dedupe, matching, ranking | Complete |
| 3 | Resume tailoring, ATS analysis, versioning, cover letters, question generation | Complete |
| 4 | Playwright, form detection, assisted application, approval workflow | Complete |
| 5 | Automation rules, auto-apply, application tracking | Complete |
| 6 | Analytics, feedback loop, command center, hardening | Complete |
Routes for unbuilt phases exist and run real queries. Until there is data they render an empty state that explains what the feature does, rather than showing invented sample rows.