I Built an Autonomous Job-Hunt Engine
A solo, end-to-end agentic system that runs an entire job-search funnel - discovering postings, scoring them, tailoring applications, submitting them, handling recruiter replies, and even placing the screening phone calls - with cost, safety, and human-in-the-loop built in from the start.
A solo, end-to-end agentic system that runs my entire job-search funnel - discovering postings, scoring them, tailoring applications, submitting them, handling recruiter replies, and even placing the screening phone calls. This is how I built it and the trade-offs behind each decision.
Why I built it
Job hunting is a high-volume, low-signal funnel. You read hundreds of postings to find a handful worth applying to; each one wants a slightly different CV; recruiters reply on their own schedule across email and phone; and it's impossible to keep organised in your head.
I design conversational AI for a living, so I wanted to point that skill at my own job search - and have a real, opinionated agentic system to show for it. The brief I set myself: automate the entire funnel, keep a human in the loop only where it matters, and instrument everything so I can prove what it cost and why it decided what it decided.
That last clause shaped the whole architecture. Every expensive or outward-facing action is logged, gated, and reversible-by-inspection. That's the interesting part
- not the AI, but making an AI that takes real actions safe, observable, idempotent, and cheap.
What it does
aijobs runs the funnel I'd otherwise grind through by hand:
- Discovers postings by scraping job boards on a schedule.
- Scores every posting against my structured profile with Claude.
- Tailors a CV + cover letter to the shortlisted ones and renders them to PDF.
- Submits - by email, through a career-page browser bot, or via a manual approval queue for LinkedIn (automated submission there is against ToS).
- Ingests recruiter replies over an inbound email webhook, classifies them, and advances the application's status.
- Places outbound voice screening calls when a recruiter asks for one, books the interview into a calendar, and records the transcript + outcome.
- Tracks every stage, every token, and every dollar in one Postgres pipeline behind a live dashboard.
It's a Next.js app on Vercel with Supabase as the backbone, Claude (Sonnet + Haiku) as the reasoning layer, and a deliberately thin set of integrations behind swappable interfaces.
How I built it - in layers
I built it in layers, each usable on its own before I added the next.
- Spine - discovery & scoring. The first working slice: scrape a board,
normalise postings into a
jobstable, score each one against my profile. This set the two patterns everything else reuses - an instrumented Claude wrapper, and a strict split between pure logic (prompt building, parsing - unit-tested with no API calls) and the thin live-call runner. - Tailoring & submission. Postings that clear the shortlist threshold get a Claude rewrite of my CV + a targeted cover letter, rendered to A4 PDFs in private Storage, then submitted by the right channel.
- Embeddings & similarity. Postings and my profile are embedded (Voyage AI) so the dashboard shows a cosine-similarity readout next to the LLM score - a cheap, deterministic second opinion.
- The feedback loop. The most agentic layer: recruiter emails hit a webhook, get classified, and conservatively move the application forward. A high-confidence interview request triggers a composed outbound voice call, and the booking lands in Cal.com.
- Hardening. The long tail - multi-language calls (EN/FR/IT/DE), recruiter-contact enrichment, reschedule/cancel voice tools, end-of-call structured outputs, and a cost-reconciliation surface that diffs my ledger against the Anthropic console CSV.
Underneath all of it runs a spine of health + cost tables: a pause flag with last-tick timestamps, a durable history of every pipeline tick, and a row for every billable unit of work.
Architecture at a glance
SOURCES (self-hosted scrapers: LinkedIn · jobs.ch · JobScout24)
│ Vercel Cron
▼
INGEST upsert jobs on (source, external_id) → stage = 'discovered'
▼
SCORE Haiku bulk pass → Sonnet re-score if borderline → shortlist
▼
TAILOR Sonnet rewrites CV + cover letter → render PDFs → Storage
▼
SUBMIT email (Resend) │ career page (browser bot) │ LinkedIn (manual queue)
▼
FEEDBACK recruiter email → classify (Haiku) → advance status
│ (interview request, confidence ≥ 0.85)
▼
compose script → place call (Vapi/Retell) → classify outcome → Cal.com booking
The stack, and why
| Layer | Choice | Why | Trade-off |
|---|---|---|---|
| Framework | Next.js 16 / React 19 | One codebase for the dashboard and the API/webhooks/cron. | App Router + RSC has sharp edges (caching, hydration). |
| Hosting | Vercel | Zero-config deploys + built-in Cron. | The 300s function limit forces all long work into bounded, resumable batches. |
| Data | Supabase (Postgres 15) | Postgres + Auth + Storage + Realtime + pgvector in one box. | Vendor coupling; had to work around the JS client's silent 1000-row cap. |
| AI | Claude (Sonnet 4.6 + Haiku 4.5) | Strong tool use; tiered models let cheap Haiku do bulk work, Sonnet handle hard calls. | Token cost is the dominant expense - so the system is built around measuring it. |
| Embeddings | Voyage AI | Cheap, high-quality similarity ranking. | Another vendor + key. |
| Resend | One domain handles outbound and inbound (signed webhook). | Inbound webhooks sometimes omit the body → rehydrate-from-API fallback. | |
| Browser | Stagehand + Browserbase | Real residential IP submits career-page forms and fetches LinkedIn (which blocks datacenter IPs). | Per-session cost; brittle - used only where there's no API. |
| Voice | Vapi / Retell behind an interface | Provider-agnostic; first configured key wins. | Two adapters; per-provider webhook signing. |
| Background | Vercel Cron + Inngest | Cron is dead-simple and reliable; Inngest adds event fan-out. | Two paths to keep in sync - solved with shared runners. |
| Tests | Vitest + Testing Library | Fast unit tests; stub at the call boundary. | 48 test files cover logic, not live integrations. |
The through-line: lean on managed services for the commodity parts (auth, storage, queues, email) and spend the engineering budget on the agentic logic and its safety rails. Anything that could be load-bearing sits behind an interface so it can be swapped or degrade gracefully.
The parts I'd actually walk you through
1. Every Claude call is instrumented - cost is a first-class citizen
There's exactly one entry point for reasoning calls. It wraps the SDK so that every call - success or failure - writes a cost row. It's structurally impossible to make an LLM call that isn't logged.
// src/lib/anthropic.ts
export async function complete({ model, system, messages, maxTokens = 4096,
cacheSystem = false, ref }: CompleteArgs): Promise<Anthropic.Message> {
// Mark the system prompt cacheable — my CV facts rarely change, so repeated
// scoring passes read the prompt at ~0.1x input cost instead of full freight.
const systemBlocks = typeof system === "string" && cacheSystem
? [{ type: "text", text: system, cache_control: { type: "ephemeral" } }]
: system;
let msg: Anthropic.Message;
try {
msg = await anthropic.messages.create({ model, max_tokens: maxTokens, ... });
} catch (e) {
// The SDK threw before returning usage — usually a timeout on a long Sonnet
// generation. Anthropic still BILLS for partial output, so I log a placeholder
// row instead of silently losing the spend.
await logCost({ operation: `${ref.operation}_failed`, model, cost_usd: 0,
error_message: (e as Error).message.slice(0, 500), ... });
throw e;
}
await logCost({ operation: ref.operation, model, cost_usd: claudeCostUsd(model, msg.usage),
/* input / output / cache-read / cache-write token counts */ ... });
return msg;
}
The timeout bump and the failure-logging path came from a real bug: the SDK's
default 60s timeout was aborting long generations client-side while Anthropic
finished and billed them server-side - so money vanished from my ledger. The fix
is two-part: raise the timeout, and log a row even when the call throws. And
cacheSystem is the single biggest cost lever, because my full career facts are
identical across every job in a batch.
Trade-off: centralising every call means it has to stay generic, and the
logging write is on the hot path. In exchange I get a ledger I can reconcile to the
cent against the real invoice - which the /spend page does, token-type by
token-type.
2. Two-pass scoring - cheap by default, careful at the boundary
Bulk scoring runs on Haiku. Only postings whose score lands within ±10 of the shortlist threshold get a Sonnet re-score - because those are the only ones where a wrong call changes the outcome.
// src/lib/score-runner.ts
const haiku = parseScore(textOf(await complete({ model: HAIKU, cacheSystem: true, ... })));
await persistScore(db, job.id, haiku, HAIKU);
let final = haiku;
if (isBorderline(haiku.fit_score, threshold)) { // within ±10 of cutoff
final = parseScore(textOf(await complete({ model: SONNET, /* same prompt */ })));
await persistScore(db, job.id, final, SONNET); // both passes persisted
}
It's the latency/cost/quality triangle solved with a router: ~90% of postings never touch the expensive model. Persisting both passes lets me later measure how often Haiku and Sonnet disagreed near the boundary - a "scoring agreement" metric on the dashboard.
3. Never let the model's output crash the pipeline
LLMs return almost-JSON. My score parser never throws - it strips code fences,
tries JSON.parse, and falls back to regex field recovery when the response
was truncated mid-generation:
// src/lib/scoring.ts
export function parseScore(text: string): ScoreResult {
const stripped = text.replace(/```json|```/g, "");
try { return normalizeScore(JSON.parse(extractJsonObject(stripped))); }
catch { return recoverScore(stripped); } // pull fit_score / arrays by regex
}
I deliberately ask for fit_score first in the prompt, so the one
decision-critical number survives even a cut-off response. A small thing that
prevents a whole class of "one bad generation killed the batch" failures.
4. One interface, two voice vendors, graceful with zero
Voice is my most vendor-risky integration, so it's the most abstracted.
VoiceProvider is a plain interface; selectProvider() returns the first
configured one, or null - and with no provider set up the dashboard still works,
calls just return a clean { ok: false, error }.
// src/lib/voice-provider.ts
export function selectProvider(): VoiceProvider | null {
if (process.env.VAPI_API_KEY) return vapiProvider;
if (process.env.RETELL_API_KEY) return retellProvider;
return null; // degrade gracefully instead of throwing
}
The interface even models a costBreakdown that splits a call into what the voice
vendor bills (telephony + STT + TTS) versus what Anthropic bills directly via a
bring-your-own-key passthrough - so the LLM portion reconciles against the
Anthropic invoice rather than disappearing into a voice bill. No single voice
vendor is load-bearing, and the UI never hard-crashes on a missing key - which
matters for something meant to run unattended.
5. Self-hosted scrapers - a drop-in replacement that costs nothing
Discovery originally went through a paid scraping vendor. I replaced it with
per-source scrapers that hit each board's own public endpoint, behind the exact
same contract so nothing downstream changed - same { source, items, costUsd }
shape, just a different import. Every request records a diagnostic (status, bytes,
parsed count, error, url), so a zero-result run is debuggable from production
without re-running it.
Trade-off: I own the breakage when a board changes its markup, and LinkedIn blocks datacenter IPs - which is exactly why the browser layer exists as the fallback for that one source. In return: zero per-scrape cost and full visibility.
6. One pipeline, two execution paths - kept honest by shared runners
The pipeline can run via Vercel Cron (reliable primary) or Inngest (event fan-out). The trap with two paths is drift. My fix: both call the same inline runners. The Inngest function and the dashboard's "Process shortlist" button are thin wrappers over identical logic. Everything is shaped by Vercel's 300s limit - work runs in bounded batches with an internal deadline, and a tick killed mid-run leaves a record the next tick detects and clears. The pipeline is resumable by construction.
7. Conservative, forward-only status machine
When a recruiter reply lands, I'm deliberately cautious about how far it moves an application:
// - Always: status → 'responded' if it was 'submitted'
// - confidence ≥ 0.85 AND label === 'interview_request' → 'interviewing'
// - confidence ≥ 0.85 AND label === 'rejection'/'offer' → 'rejected'/'offer'
// - everything else stays 'responded' and is flagged needs_review
Status only ever moves forward, and anything the classifier isn't sure about is flagged for me rather than acted on. An auto-call fires only behind the same 0.85 gate. That's the human-in-the-loop principle made concrete: automate the confident cases, escalate the ambiguous ones, never move backwards.
Cost engineering - the part most AI demos skip
Because the system spends real money autonomously, I treat cost as a feature:
- Single source of truth for pricing - USD-per-1M-token rates per model, always computed off measured API usage, never an estimate.
- Per-token-type breakdown (input / cache-write / cache-read / output) matching exactly the rows the Anthropic console emits, so the ledger reconciles line-for-line.
- Reconciliation UI that ingests the console CSV and diffs it against my ledger.
- A budget monitor with daily/monthly soft + hard caps; a hard breach flips a pause flag that every cron entry checks before doing work. Alerts go to Slack / Telegram / email.
The lesson: an autonomous system that spends money needs the same financial controls a human team would - a ledger, a budget, an alert, and a kill switch.
What I'd do differently
- Two background paths (Cron + Inngest) add cognitive overhead. They're kept honest by shared runners, but one reliable path would be simpler.
- Self-hosted scrapers are a maintenance liability - boards change markup and I own the breakage. The diagnostics trail makes it tractable, but it's the most fragile part.
- App Router + RSC sharp edges cost more debugging than I expected (hydration flashes, caching). Worth it for the single-codebase win, but not free.
- Single-user by design. Row-Level Security is in place, but the whole thing assumes one candidate; multi-tenant would mean rethinking the profile-as-singleton and the global budget.
At a glance
- Frontend / API: Next.js 16, React 19, TypeScript (strict), Tailwind v4, shadcn/ui + Radix
- Data: Supabase - Postgres 15, RLS, pgvector, Storage, Realtime · 25 migrations
- AI: Claude (Sonnet 4.6 + Haiku 4.5) with prompt caching · Voyage AI embeddings
- Integrations: Resend · Stagehand + Browserbase · Vapi / Retell · Cal.com
- Background: Vercel Cron + Inngest · budget monitor + health checks
- Quality: Vitest + Testing Library · Zod validation at every edge
- Scale: ~70 lib modules · 8 dashboard surfaces · 48 test files · 25 DB migrations