Post

Job Hunter Agent: Technical Architecture

One FastAPI process with six agents, a LinkedIn browser in a sidecar, a local model behind LiteLLM and Supabase, running in one pod on the homelab platform.

Job Hunter Agent: Technical Architecture

The architecture fits in one pod because the hard parts aren’t scale. They’re trust (an LLM that wants to please will invent experience), a website that doesn’t want to be automated, and a local model that’s slower than a cloud API. So the design spends its complexity on deterministic checks and careful integration, and keeps the runtime simple: one process, one sidecar, one database.

System context and component architecture

Step through one scheduled run for one search profile:

  1. A profile's time comes: At 08:00 India time on a weekday the scheduler fires the Cloud Architect profile's job. The run is recorded as running; other profiles have their own times.
  2. Ask the sidecar: The app calls the LinkedIn MCP server on loopback port 8080 over streamable HTTP. Its client allows only read-only tools, so it can't apply or message anyone.
  3. A signed-in browser: Chromium, signed in as me, runs the search and opens up to 5 new postings per keyword. Jobs already stored are skipped before any page loads.
  4. Filter and hand over: Duplicates, excluded companies and reposts are dropped; new postings are stored with the profile that found them and passed to the agents.
  5. Research the company: The company's LinkedIn About page, then Wikidata for revenue and headcount by year and Google News for headlines. Cached for a week; figures only when published.
  6. The local model writes: Every agent call goes through LiteLLM to a local model with a JSON schema. It scores the fit, returns line edits to my resume, writes a letter and re-scores the result.
  7. Check the claims: Numbers, tools and phrases the draft adds that my resume doesn't have are flagged. Any flag blocks approval until I've looked at it.
  8. Saved for review: Drafts, scores, research and the run's counts go to Supabase over its HTTPS API. The application waits as pending review; nothing is sent.
  9. Tell me: When the run finishes, the app posts a summary to a Slack incoming webhook. A failed post never fails the run.
  10. Sign in: I open the app and Keycloak signs me in with OpenID Connect. Only members of platform-admins get in; the app checks the group too.
  11. The review queue: A signed cookie holds the session for 8 hours. The review page leads with skills required vs. mine and the shortlist confidence; drafts are collapsed.
  12. My decision: I approve or reject. After approving I apply on the employer's site myself and mark it applied. The agent never does that step.
ComponentWhat it isRuns where
Job Hunter appFastAPI service: API, review UI (static HTML/JS), pipeline, agents, schedulerapp container, port 8000
LinkedIn MCP servermcp-server-linkedin: a Chromium browser signed in as me, exposed as MCP toolslinkedin sidecar, loopback 8080 only
LiteLLM gatewayOpenAI-compatible gateway in front of the local model qwen3.5-35b-a3bHomelab ai cluster
SupabasePostgres with a REST API, free tierSupabase cloud
KeycloakOIDC provider, realm homelabHomelab core cluster
Wikidata, Google NewsPublic company facts and headlinesInternet
SlackRun reports through an incoming webhookSlack

Inside the app, the pieces are deliberately few: pipeline.py (discover, score, draft, evaluate one job, status transitions), agents/ (one base class, six agents), truthfulness.py, sources.py (LinkedIn, Indeed, company intel), company_facts.py, scheduler.py, runs.py (history), notify.py (Slack) and db/ (Supabase and an in-memory store with the same interface).

Technology and framework selection

ChoiceWhyAlternative I passed on
Python + FastAPIAsync I/O for slow LLM and browser calls; Pydantic models double as the LLM’s JSON schemasNode: fewer good PDF/DOCX libraries
Pydantic v2One model per agent output; validation catches malformed LLM answers before they’re storedHand-written JSON parsing
OpenAI SDK against LiteLLMAny OpenAI-compatible backend; structured output with json_schemaA provider-specific SDK
Local qwen3.5-35b-a3bMy resume stays home; free to runA frontier cloud model: better prose, but my data leaves the house
mcp-server-linkedinMaintained, read-only tools, real signed-in browserWriting my own Playwright scraper
SupabaseThe articles used it; Postgres + REST + row-level security on a free tierSQLite in a PVC: simpler, but no off-cluster copy
Vanilla JS UINo build step, ~800 lines, served by FastAPIReact: more structure than one user needs
APSchedulerIn-process cron, one job per profileA Kubernetes CronJob per profile: needs the sidecar’s browser session

API and integration architecture

A REST API under the same origin as the UI (OpenAPI at /docs). The groups:

GroupMain endpoints
Profiles and resumesGET/POST /profiles, PUT/DELETE /profiles/{id}, POST /resumes/upload, PUT /resumes/{id}
JobsGET /jobs (with each job’s evaluation), POST /jobs, POST /jobs/{id}/evaluate
PipelinePOST /pipeline/discover?profile_id=, POST /pipeline/draft, POST /pipeline/run, GET /runs, GET /stats, GET /schedule
ReviewGET /applications, GET /applications/{id}, POST /applications/{id}/approve (and reject, reopen, applied)
ResearchGET /research, POST /research/{id}/refresh

Integrations, each behind a small interface so tests can swap in fakes:

  • LinkedIn: MCP over streamable HTTP; the client refuses any tool not on a read-only list.
  • LLM: one structured(system, user, schema) call; JSON schema output, thinking disabled, one repair retry when validation fails.
  • Company facts: Wikidata wbsearchentities / wbgetentities, Google News RSS.
  • Slack: a POST of {"text": …} to an incoming webhook.

Data architecture and data models

Every row carries user_id, created_at and updated_at. The core of the model:

erDiagram
  search_profiles ||--o{ jobs : finds
  resume_templates ||--o{ job_scores : "scored with"
  jobs ||--o{ job_scores : has
  jobs ||--o| applications : "drafted as"
  applications }o--|| optimized_resumes : uses
  applications }o--|| cover_letters : uses
  resume_templates ||--o{ optimized_resumes : "tailored from"
  jobs ||--o{ contacts : suggests
  company_research }o--o{ jobs : "same company"
  runs }o--o{ search_profiles : covers
  agent_results ||--o{ job_scores : produced

Two stores I’d call out: agent_results keeps every LLM call’s input, output and error (the audit trail for “why did it write that?”), and company_research is a per-company cache keyed by the lower-cased company name. More in Data and Integration.

Identity and access architecture

  • People: Keycloak OIDC, authorization code flow with PKCE and a confidential client. The app validates the ID token, then checks membership of platform-admins itself, so a misconfigured client still can’t let others in. The session is a signed jh_session cookie, 8 hours, SameSite=Lax, Secure.
  • App to services: a LiteLLM virtual key limited to one model; a Supabase service-role key (server only, bypasses row-level security); a Slack webhook URL. All from OpenBao through External Secrets.
  • App to LinkedIn: my own signed-in browser session, stored on a persistent volume. It’s the most sensitive credential here, and it never leaves the pod.

Hosting and deployment model

One Argo CD application on the homelab platform k3d cluster, namespace job-hunter:

ObjectNotes
Deployment1 replica, Recreate (single writer of the browser profile); app + linkedin containers; an init container prepares the browser’s home folder
PVC linkedin2 GiB, holds the LinkedIn browser profile; backed up by Velero
Service + Ingressjobs.platform.home.cloudutsuk.com, Traefik, Let’s Encrypt via cert-manager DNS-01
NetworkPolicyIngress only from Traefik, only port 8000; the sidecar has no Service port
ExternalSecretkv/platform/job-hunter → env vars

The image is built by the platform’s reusable workflow, signed with cosign and pinned by digest; Kyverno refuses unsigned or unpinned images. Details in CI/CD.

Scalability and performance strategy

Scale isn’t the goal; LinkedIn’s patience is the ceiling. The numbers that matter:

  • At most 5 postings per keyword per run, past week only, sorted by date.
  • One run per profile per weekday, profiles 4 hours apart, and a lock so two scheduled runs never share the browser.
  • Each draft costs about five LLM calls (score, tailor, re-score, letter, contacts) plus research once per company. With a local model that’s minutes per job, which is fine unattended.
  • Resume tailoring returns line edits, not a rewritten resume: rewriting a 28,000-character resume timed out at 180 s; edits finish well inside the 300 s timeout.

Horizontal scaling isn’t possible as built (one browser session, an in-process scheduler) and isn’t needed.

Resilience and disaster recovery

FailureBehaviour
LinkedIn errors or blocksThat keyword’s error is recorded; other keywords and profiles continue
LLM times out or returns junkOne repair retry; then that job is listed under failed and the run goes on
Company research, facts, news, contacts or apply-link failRecorded as warnings; the draft still completes
Slack is downLogged; the run is unaffected
Pod restartsBrowser session survives on the PVC; the scheduler’s jobs live in memory, so a run whose time passed while the pod was down is skipped until the next slot
Supabase unavailableRuns and the UI fail visibly; nothing is lost that isn’t regenerable

Recovery is mostly re-running: postings and drafts can be regenerated. What can’t be regenerated is my review history and the LinkedIn session. See Backup and restore.

Architectural decision records

The decisions live as MADR files in job-hunter-agent/docs/adr and are published in full on the Architecture Decision Records page:

#DecisionStatus
1Manual approval only; the agent never submits, connects or messagesAccepted
2Local LLM through LiteLLM instead of a cloud APIAccepted
3Resume tailoring as line edits applied by code, not a regenerated resumeAccepted
4Deterministic truthfulness checks block approvalAccepted
5LinkedIn through an MCP sidecar with a read-only tool allow-listAccepted
6Company figures only from published sources (Wikidata), never the modelAccepted
7One scheduled job per profile, spread through the dayAccepted
8Six-month retention, and our own nightly export because the Supabase free plan has no backupsAccepted
9Service level objectives for scheduled runs, and alerts on the platformAccepted
ADRDecisionStatus
0001Manual approval only: the agent never submits, connects or messagesAccepted
0002Local LLM through the homelab's LiteLLM gateway, not a cloud APIAccepted
0003Resume tailoring as line edits applied by code, not a regenerated resumeAccepted
0004Deterministic truthfulness checks block approvalAccepted
0005LinkedIn through an MCP sidecar with a read-only tool allow-listAccepted
0006Company figures only from published sources, never the modelAccepted
0007One scheduled job per search profile, spread through the dayAccepted
0008Six-month retention, and our own nightly export because Supabase's free plan has no backupsAccepted
0009Service level objectives for scheduled runs, and alerts on the platformAccepted

Checklist

  • System diagram matches the deployed pod (containers, ports, volumes)
  • Every external integration has a fake used in tests
  • Read-only tool allow-list reviewed when the MCP server is upgraded
  • LinkedIn volume limits written down next to the scheduler settings
  • Backup covers the browser session and Supabase data
  • The seven decisions above written as ADRs

This post is licensed under CC BY 4.0 by the author.