Post

Job Hunter Agent: Data and Integration

Where Job Hunter Agent's data lives and how it moves: Supabase tables and migrations, LinkedIn through MCP, Wikidata and Google News, and what a backup has to cover.

Job Hunter Agent: Data and Integration

Most of Job Hunter Agent’s data can be thrown away and regenerated by running the pipeline again. Two things can’t: my review decisions and the signed-in LinkedIn session. That asymmetry drives the retention, backup and migration choices below.

Data ownership and classification

DataOwnerClassRegenerable?
Resumes (master and derived)MePersonal, confidentialNo (keep my own copies)
Search profilesMeInternalEasy to recreate
Job postingsThe employer, public on LinkedInPublicYes, while the posting is live
Scores, tailored resumes, cover lettersMe (generated)PersonalYes, at the cost of LLM time
Applications and review notesMePersonalNo
Company research, financials, newsPublic sourcesPublicYes
Contact suggestionsThird parties’ public profile dataPersonal data of othersYes
Run history, agent resultsMeInternalNo, but low value
LinkedIn browser sessionMeSecretNo (needs an interactive sign-in)

Data retention and deletion

A daily upkeep job (03:30) deletes what’s past retention. The period is one setting, RETENTION_DAYS (180, six months; 0 keeps everything):

DataRetention
Contact suggestions (other people’s names and titles)6 months, with the model outputs that produced them
Rejected applications6 months after the rejection, with their tailored resume, letter and model outputs
The posting behind a rejected applicationKept, so the same posting isn’t found and drafted again
Approved and applied applicationsKept
Postings never drafted, run historyKept for now: small, and the run history feeds the SLOs

Deleted rows still exist in the nightly exports until those expire (at most 12 weeks, below). Each upkeep run appears in the run history with what it deleted.

Database schema and migrations

Twelve tables in Supabase Postgres: users, search_profiles, resume_templates, jobs, job_scores, optimized_resumes, cover_letters, applications, company_research, contacts, agent_results, runs. Lists are text[], structured values (resume changes, revenue series, news, run stats) are jsonb. Every table has row-level security with an own_rows policy on user_id, which the server’s service-role key bypasses; it’s there for the day the app authenticates users against Supabase directly.

Migrations live in supabase/migrations/, timestamped, all additive (add column if not exists):

MigrationAdds
…000000_initThe core tables and RLS
…010000_research_contactsCompany research cache, contacts, apply links
…020000_letter_warnings, …030000_company_slugLetter warnings; LinkedIn company slug
20261022000000_profiles_and_screeningProfile name, resume, mode, tone; jobs’ profile; screening score
20261022010000_runsRun history
20261022020000_company_factsCompany size, HQ, revenue, headcount, news
20261022030000_profile_scheduleEach profile’s cron

CI applies all of them to a fresh local Supabase and runs an integration test. In production I paste each one into the Supabase SQL editor before merging the code that needs it, because the app only holds the REST service key, not a database password.

Data validation and quality

  • LLM output: validated against Pydantic schemas (scores 0–100, at most 25 resume changes, exactly three letter paragraphs, at most three contacts, no placeholders in notes). A failed validation gets one repair retry.
  • Job pages: the header (company, title, location, posting age) is parsed deterministically; the LLM extracts it only when LinkedIn’s layout is unfamiliar. An earlier version let the model read the header and it sometimes named the wrong company.
  • Company facts: a Wikidata item is used only if its official website matches the company’s LinkedIn website, or, without a website, its label matches exactly. Otherwise: no figures.

Import and export requirements

  • Import: resumes as PDF, DOCX (including tables, read in document order), TXT or Markdown. Postings by hand: title, company, location, link and pasted description.
  • Export: each tailored resume as DOCX, Markdown or plain text; the cover letter by copy.
  • API: everything is JSON over the REST API, so a bulk export is a few curl calls.

External systems and third-party integrations

SystemHowVolume
LinkedInMCP tools search_jobs, get_job_details, get_company_profile, get_company_employees, apply info≤5 postings per keyword per run
LiteLLMOpenAI-compatible chat completions with JSON schema~5 calls per drafted job
Supabasesupabase-py (PostgREST) in a thread poolLow
Wikidatawbsearchentities, wbgetentities, with a descriptive User-Agent2–3 requests per company per week
Google NewsRSS search, last 180 days1 request per company per week
SlackIncoming webhook1 message per scheduled run
IndeedHTML scraping, off by default—

Wikidata refused the first requests with HTTP 403 until the User-Agent named the tool and a contact URL, as its policy requires.

Event and messaging contracts

There’s no message bus; the only “events” are outbound Slack messages, and their contract is plain text:

1
2
3
4
⚠️ *Job Hunter*: scheduled full run for *Cloud Architect* errors in 7 min
5 found · 3 new · 2 skipped · 3 analyzed · 2 drafted
<https://jobs.platform.home.cloudutsuk.com/ui/|4 waiting for your review>
Errors: Cloud Architect: linkedin 'Cloud Architect': TimeoutError

The status emoji, counts and error lines come from the run record, so the message and the run history never disagree.

Backup and restore strategy

WhatHow it’s protected
LinkedIn session (PVC)Velero file-system backup of the linkedin volume, nightly
Supabase dataThe app’s own nightly export (below); the free plan has no backups
ResumesMy own files (the master resume lives outside the app too)
Code and configGit

Supabase’s free plan takes no backups (daily backups start on the paid plans), and Supabase’s own docs tell free projects to export their data themselves. So the upkeep job writes every table to one gzipped JSON file on a second volume, backups, at 03:30 IST every day, and keeps the newest seven. Velero’s nightly file-system backup then copies that volume to Azure Blob storage off the host, encrypted by Kopia. Nightly copies are kept 30 days and weekly ones 12 weeks.

Why an export through the app rather than pg_dump:

  • It uses the same service key the app already has, so the cluster never holds the database password.
  • python -m hunter.maintenance restore <file> loads it back, and CI tests that round trip against a real Postgres.

The cost is that it’s a logical export: restoring needs the migrations applied first, and the RPO is a day.

Writing the export also exposed a bug that had been there all along. Supabase’s REST API returns at most 1,000 rows per request, so any list longer than that was silently cut short. Lists now page through the whole table.

Restoring the LinkedIn session from a backup is possible but LinkedIn may still ask for a fresh sign-in; the in-cluster login procedure is in the deployment runbook.

Data migration and reconciliation

There was no legacy data. The only migrations so far were schema changes on live data, and they were all additive with defaults, so existing rows stayed valid: the first profile became “Default”, existing jobs got no profile, existing research got its new facts the next time it was refreshed. When I edited the truthfulness rules I re-ran them over the two stored drafts and updated their warnings, which is the reconciliation pattern I’d reuse: recompute derived data, never hand-edit it.

Checklist

  • Retention periods confirmed (6 months) and a clean-up job scheduled
  • Supabase backup coverage confirmed (none on the free plan), with a tested export and restore
  • Velero restore of the linkedin volume tried once
  • Every migration additive and applied before its code merges
  • Contact suggestions deleted after six months
  • Data classification table reviewed when a new source is added

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