All posts
EngineeringJune 1, 202611 min read

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:

  1. Discovers postings by scraping job boards on a schedule.
  2. Scores every posting against my structured profile with Claude.
  3. Tailors a CV + cover letter to the shortlisted ones and renders them to PDF.
  4. Submits - by email, through a career-page browser bot, or via a manual approval queue for LinkedIn (automated submission there is against ToS).
  5. Ingests recruiter replies over an inbound email webhook, classifies them, and advances the application's status.
  6. Places outbound voice screening calls when a recruiter asks for one, books the interview into a calendar, and records the transcript + outcome.
  7. 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 jobs table, 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

LayerChoiceWhyTrade-off
FrameworkNext.js 16 / React 19One codebase for the dashboard and the API/webhooks/cron.App Router + RSC has sharp edges (caching, hydration).
HostingVercelZero-config deploys + built-in Cron.The 300s function limit forces all long work into bounded, resumable batches.
DataSupabase (Postgres 15)Postgres + Auth + Storage + Realtime + pgvector in one box.Vendor coupling; had to work around the JS client's silent 1000-row cap.
AIClaude (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.
EmbeddingsVoyage AICheap, high-quality similarity ranking.Another vendor + key.
EmailResendOne domain handles outbound and inbound (signed webhook).Inbound webhooks sometimes omit the body → rehydrate-from-API fallback.
BrowserStagehand + BrowserbaseReal residential IP submits career-page forms and fetches LinkedIn (which blocks datacenter IPs).Per-session cost; brittle - used only where there's no API.
VoiceVapi / Retell behind an interfaceProvider-agnostic; first configured key wins.Two adapters; per-provider webhook signing.
BackgroundVercel Cron + InngestCron is dead-simple and reliable; Inngest adds event fan-out.Two paths to keep in sync - solved with shared runners.
TestsVitest + Testing LibraryFast 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

Ask about Julian Walder

Grounded in his real work

Hi! I'm Julian Walder's assistant. Ask me anything about his work, projects, or background in AI.