# PRD — Build your own job board

A spec you can drop into a Claude Code project to build a personal job search board: a
filtered feed from four job APIs, a pipeline for tracking applications, and a CV tailored to
one posting from your own master CV.

It is written to be executed rather than read. Paste it into a repo as `PRD.md`, tell Claude
Code to start at Phase 0, and answer the fill-in block below first.

Everything factual in here was measured against the live APIs, not taken from their
documentation. Where the two disagreed — and they disagreed constantly — the measurement is
what is written down. The section at the end lists what each measurement cost to obtain.

---

## 0. Fill this in before Claude starts

Claude should ask for these if they are blank, and must not guess them.

```
ROLE            e.g. "Data Engineer". The exact words that appear in job titles you want.
VARIANTS        e.g. "Senior Data Engineer", "Staff Data Engineer". Optional.
MARKETS         Countries, as ISO-2 codes: de, gb, us, nl…  See §2 for what each source covers.
REMOTE          Do you want remote only, on-site only, or both?
MASTER CV       Path to a markdown CV. Required only for Phase 4.
CURRENCY        The currency to normalise salaries into, e.g. EUR, USD, GBP.
```

Two of these are load-bearing in ways that are not obvious.

**MARKETS is not a preference, it is a constraint.** Source coverage varies enormously by
country and the differences are not documented anywhere reliable. §2 lists what was measured.

**ROLE should be the words that appear in a job *title*.** Not a description of what you want.
The single biggest relevance win in this whole build was switching one API from full-text
search to title-only search — 4–20% relevant results became 100%.

---

## 1. What you are building

Three layers. Each is useful alone, and each roughly doubles the work of the one before.

**The feed.** Saved searches fan out across four job APIs, four different payload shapes
normalise into one row, duplicates collapse, and a budget stops you burning a month of free
quota in an afternoon.

**The pipeline.** Promote a listing to an application and move it saved → applied →
interviewing → offer/rejected. The application freezes a copy of the listing, so it survives
the posting being taken down.

**The tailored CV.** One button rewrites your master CV against a specific posting, and a
ledger tells you exactly what the model added, so you can decide whether you can defend it.

### Stack

Deliberately boring, and none of it is essential — it is what the reference build used.

- **Backend:** Node + TypeScript + Express, SQLite via `better-sqlite3`.
- **Frontend:** React + Vite + TypeScript.
- **AI:** the Anthropic API, called directly over HTTP. Only Phase 4 needs it.
- **No job-API SDKs.** All four sources are plain `fetch` against documented HTTP endpoints.

### What it costs

Nothing to run, if you stay inside the free tiers, which are generous enough for one person's
job search. Budget roughly **$0–2 for Phase 0** (a handful of API calls to measure each
source) and **a few cents per tailored CV** in Phase 4.

Credentials you will need, all free to obtain:

| Key | Source | Needed for |
|---|---|---|
| `ADZUNA_APP_ID` + `ADZUNA_API_KEY` | developer.adzuna.com | Breadth. **Both** — the key alone does not authenticate. |
| `SERPAPI_KEY` | serpapi.com | Google Jobs: remote filtering and the widest country reach |
| `THEIRSTACK_API_KEY` | theirstack.com | Structured remote + full descriptions |
| `ANTHROPIC_API_KEY` | console.anthropic.com | Phase 4 only |

Arbeitnow needs no key at all. **You can build and use Phases 1–3 with zero credentials** if
you start with Arbeitnow alone — worth knowing if you want something running tonight.

---

## 2. The four sources, as measured

Each was measured against a real search, not read about. **Capability differs per source, and
that difference has to survive into the data model and the UI** — it is the single idea this
document most wants you to take away.

| Source | Remote signal | Description | Coverage | Meter |
|---|---|---|---|---|
| **Arbeitnow** | real boolean | full (~6,000 chars) | Germany-centric, carries some other EU | **none — unmetered, keyless** |
| **Adzuna** | **none at all** | **hard-capped at 500 chars** | 13 countries | 1,000 **requests**/month |
| **SerpAPI** (Google Jobs) | real filter (`ltype=1`) | full | widest, but see below | 250 **searches**/month, 10 rows each |
| **TheirStack** | real boolean + `seniority` | full (~4,900 chars) | 195 countries | 200 **rows**/month |

### What you must know before relying on any of them

**Adzuna truncates every description at 500 characters.** Not "sometimes thin" — measured
across 650 listings: median 500, max 500, 99% ending mid-sentence. It also exposes no remote
field whatsoever. Those two compound: remote status, when a posting mentions it, lives in the
body text you never receive. No amount of parsing recovers it.

**Adzuna's `title_only` parameter is the difference between usable and useless.** A plain
`what=` query returned 4–20% relevant results because it matches each word separately —
"product" and "manager" anywhere in the posting. `title_only=` returned **100%** in every
market tested. Use it.

**Adzuna does not consistently annualise salaries and exposes no period field.** Measured in
one sample: `gb 58,895` is annual, `sg 1,080–6,480` is monthly, `ch 50–65` is hourly, `au
180–240` is daily. There is no flag telling you which. §4 says what to do about it.

**SerpAPI's `hl` parameter must match the market's language, or that market returns ZERO** —
not fewer results, zero, reported as an error string. An English query with `hl=en` against
Berlin returns nothing; the same English query with `hl=de` returns ten. This cost seven
wasted searches to find, and a wrong `hl` in config makes an entire market look permanently
dead. **Set `hl` per market and never guess it.**

**Google Jobs serves a narrower country list than Google Search.** `gl=pl` is documented and
valid for Search, and the Jobs engine rejects it outright with "Unsupported `pl` country".
Do not assume the general `gl` documentation applies to this endpoint. Test each market once.

**TheirStack bills per row returned, not per request.** 200 free credits is 200 job rows a
month, against Adzuna's ~50,000. That makes it hostile to a broad poll — a re-poll that finds
nothing new still pays for every row it re-reads. It works well bounded tight with
`posted_at_max_age_days`, where successive runs barely overlap. One request covers every
market at once, because `job_country_code_or` takes a list.

**Sources that look right and are not.** Three "free remote job board" APIs were tested and
discarded: two cap their free API at roughly 20 rows regardless of the `limit` you send, and
one returned no matching roles at all and ships mojibake in its own payload. If you add a
source, measure it before you build on it.

### If your market is not covered here

These four were measured for European and Asian markets. **If you are hunting in a country
none of them serve well, the sources are wrong for you but the method is not.** Run Phase 0
against whatever APIs serve your market and answer the same questions — the gate list in
Phase 0 is deliberately source-independent.

---

## 3. Design rules

These came out of things going wrong. Each one is cheap to honour up front and expensive to
retrofit.

**Unknown is never a reason to hide something.** Adzuna rows have no remote signal, so
`is_remote` is nullable and **null passes every filter**, labelled as unsure. If "remote only"
excluded unknowns it would silently delete your widest source. The expensive error in a job
board is a role you would have applied for being removed before you saw it — and unlike a news
feed, you never find out it happened. Strict exclusion stays available as an explicit opt-in.

**Capability is a property of the source, not the job.** A confident "Remote" badge on a
TheirStack row and a dashed "Remote?" on an Adzuna row are both honest and mean different
things. Show which source a row came from, always.

**Never render a number you cannot justify.** Given Adzuna's inconsistent salary periods, a
monthly Singapore salary read as annual stores as roughly €1,400/year and then vanishes behind
any minimum-salary filter — wrong, silent, and in the direction that loses jobs. So: record
the period as *unknown*, and **decline the conversion** when the result is implausibly small
for an annual figure. Show the original number in its own currency instead. Same rule for a
currency your FX source does not publish: keep the original, never render zero.

**Salary is optional and mostly absent.** 76% of measured listings stated no pay at all. It
belongs inline where present and absent where not — never a column of em-dashes. Any
minimum-salary filter must **include unstated by default**, or it deletes most of the feed.

**Sort on when *you* first saw a listing, not on the date the API claims.** Aggregators
frequently return an indexing date, and reposted roles reset it, so "2 hours ago" routinely
describes a month-old listing. Store both; sort on yours; display theirs as a claim.

This makes **dedup load-bearing**: a listing that fails dedup gets a fresh first-seen date on
every poll and floats to the top forever. Dedup on normalised company + title, deliberately
*not* including location, so the same remote role listed in three countries collapses into
one. If you hunt in German-speaking markets, also fold the gender-inclusive title forms
(`(m/w/d)`, `Manager:in`, `Mitarbeiter*innen`) or the same job will store twice.

**Budgets are monthly, per source, and the units differ.** A monthly allowance spent in one
afternoon is exactly what a per-day ceiling fails to catch. Keep a monthly ledger per source,
derive a daily pace from what remains, and refuse loudly rather than under-fetching silently.
**State the unit in the UI** — "6/1000 calls", "17/250 searches", "10/200 rows" — because
showing three different meters as one number is a quiet lie.

**Where a source reports its own usage, sync to it exactly.** Your counter only knows what
your app spent, not what you spent from a script or another machine. An early version of the
reference build only ever raised the local count toward the provider's figure, reasoning that
a falling ledger might let a burst through. That was wrong: a request rejected as malformed is
counted locally and not billed by the provider, so the local count inflated permanently until
it would refuse spending that was actually available.

**A failed fetch must never be cached as a result.** If one source is down, retry it next poll
and keep what the others returned. Never freeze "unavailable" for the rest of the day.

---

## 4. Data model

SQLite; adapt freely. The comments are the parts that matter.

```sql
-- A saved search. Fans out across sources and markets at fetch time.
CREATE TABLE job_searches (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  keywords TEXT NOT NULL,          -- matched against the job TITLE, not full text
  exclude_keywords TEXT,
  markets TEXT NOT NULL,           -- JSON array of ISO-2 codes
  remote_only INTEGER NOT NULL DEFAULT 0,
  max_days_old INTEGER,
  salary_min INTEGER,
  enabled INTEGER NOT NULL DEFAULT 1,
  created_at TEXT NOT NULL
);

-- An ACCUMULATING store, not a cache that gets replaced. A listing you have seen is a
-- settled fact.
CREATE TABLE job_listings (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  dedup_key TEXT NOT NULL UNIQUE,  -- normalised company + title; NOT location
  source TEXT NOT NULL,
  external_id TEXT,
  url TEXT NOT NULL,
  title TEXT NOT NULL,
  company TEXT,
  location TEXT,
  market TEXT,
  -- NULL = this SOURCE cannot know, which is not the same as false.
  -- Must pass filters. See §3.
  is_remote INTEGER,
  contract_type TEXT,
  salary_min REAL, salary_max REAL, salary_currency TEXT,
  salary_period TEXT,              -- NULL when the source does not say. Do not assume annual.
  salary_is_predicted INTEGER NOT NULL DEFAULT 0,
  -- Converted figures carry the rate and its date: a conversion without its rate goes
  -- stale invisibly. All NULL when conversion was declined — never 0.
  salary_min_normalised REAL, salary_max_normalised REAL,
  fx_rate REAL, fx_rate_date TEXT,
  description TEXT,
  description_truncated INTEGER NOT NULL DEFAULT 0,
  posted_at TEXT,                  -- the source's claim. Displayed, not trusted.
  first_seen_at TEXT NOT NULL,     -- yours. What the board sorts on.
  seen_at TEXT,                    -- NULL = never shown to you ("new since last visit")
  dismissed_at TEXT
);

-- A listing routinely matches several searches at once, so this is a join, not a column
-- on the listing. A single search_id would silently hide matching jobs when filtering.
CREATE TABLE job_listing_searches (
  listing_id INTEGER NOT NULL,
  search_id INTEGER NOT NULL,
  matched_at TEXT NOT NULL,
  PRIMARY KEY (listing_id, search_id)
);

CREATE TABLE job_blocked_companies (company TEXT PRIMARY KEY, blocked_at TEXT NOT NULL);

-- Monthly ledger. NOTE the units differ per source: requests, searches, or ROWS.
CREATE TABLE job_api_budget (
  source TEXT NOT NULL,
  month TEXT NOT NULL,             -- 'YYYY-MM'
  units INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (source, month)
);

-- Promoted from a listing, carrying a FROZEN copy. Postings get delisted; an application
-- you actually made must not degrade when its source row goes away.
-- listing_id is deliberately NOT a foreign key.
CREATE TABLE job_applications (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  listing_id INTEGER,
  status TEXT NOT NULL,            -- saved|applied|interviewing|offer|rejected|withdrawn
  title TEXT NOT NULL,
  company TEXT,
  url TEXT,
  snapshot_json TEXT NOT NULL,
  notes TEXT,
  slug TEXT NOT NULL UNIQUE,       -- addresses the folder tailored CVs are written to
  created_at TEXT NOT NULL
);

-- The transition log. "Stalled" and cycle time are DERIVED from this and never stored:
-- a stored 'stalled' flag is a row whose truth depends on when you last looked at it.
CREATE TABLE job_status_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  application_id INTEGER NOT NULL,
  status TEXT NOT NULL,
  occurred_at TEXT NOT NULL
);
```

---

## 5. Phases

Each ends with a **stop for review**. Do not let Claude run them together — the gates exist
because the reference build's most valuable findings came from stopping and looking.

### Phase 0 — Measure the sources. No schema, no UI, no committed code.

A throwaway script per source, run against **your** role and **your** markets. Cache every raw
response to disk so re-analysis costs nothing.

Answer each with its consequence, not just its value:

1. **Which markets does this source actually serve?** Enumerate against the live API.
2. **Can it filter or report remote work?** If not, remote-ness is unknowable for that
   source's rows — not false.
3. **Are descriptions complete?** Measure the length distribution. A truncated description
   means Phase 4 has nothing to work from.
4. **What fraction of results are genuinely your role?** If low, look for a title-only
   parameter before writing anything off.
5. **How much salary data exists, is it stated or estimated, and in what period?**
6. **What is the real quota, and in what unit** — requests, searches or rows?
7. **How much do results overlap between sources?** Determines how hard dedup must work.

**Gate:** questions 1, 2 and 3 can each independently kill a source. Write the findings down
before building anything. **STOP.**

### Phase 1 — Fetch pipeline. No UI beyond a debug route.

One thin client per source doing HTTP only. One normalisation layer turning every payload into
the row shape in §4. Dedup, upsert, and the monthly budget ledger.

Make the upsert an **upgrade**: when the same job arrives later from a better source, replace
the truncated description and the unknown remote flag, but **never touch `first_seen_at`** —
refreshing it would float old listings to the top on every poll.

**Acceptance:** poll twice. The second poll must insert **zero** rows. A listing matching two
searches links to both. Exhausting a budget refuses with a stated reason rather than silently
fetching nothing. **STOP.**

### Phase 2 — The board.

Saved-search CRUD, a filter bar, listings sorted by `first_seen_at`. New-since-last-visit,
dismiss, block-company, and **manual add by URL** — the last one matters more than it sounds,
because it is how anything your sources miss still gets tracked.

**Acceptance:** dismissing survives a refetch; a manually added job is indistinguishable
downstream from a fetched one; the remote filter's default keeps unknowns. **STOP.**

### Phase 3 — Pipeline.

Promote to an application, freezing `snapshot_json`. Kanban across the statuses. Every
transition writes an event. Stalled applications surface a nudge, **derived** from the log.

Only `applied` and `interviewing` can stall — an offer or a rejection is settled, and nudging
about it is nagging about the past.

**Acceptance:** delete a listing row by hand; its application must still render completely.
Cycle time between two statuses is derivable from the event log alone. **STOP.**

### Phase 4 — Tailored CV.

Read a master CV from a markdown file. One explicit button per application calls the model with
the master plus the posting, writes the result to a per-application folder, and never
overwrites a previous generation.

**Strip HTML from the posting before the model sees it.** Two of the four sources return
markup, and it wastes input budget and invites the model to mirror `<strong>` as emphasis.

**The contract, which is the whole feature.** Decide what the model may add. The reference
build settled on: it *may* reorder, re-word, omit, and add qualitative responsibilities that
plainly follow from work already in the CV. It may **never** introduce an employer, job title,
date, credential, named tool — or **any number the master does not contain**. Numbers may be
reordered or dropped, never created. A fabricated metric is the most checkable claim on the
page and the one an interviewer will ask you to walk through.

**Additions must not lead:** never open the summary, never be a role's first bullet.

**Build a ledger, not a gate.** Extract the factual atoms from the output and compare against
the master's. Report two lists — *violations* (anything in the never-add tier) and *additions*
(everything else not traceable to the master) — so you can read what the model put in your
mouth before you send it.

**One warning that cost a real failure to learn.** Sentence similarity alone is not enough. A
clause welded onto an otherwise-verbatim sentence scores ~0.94 similar and passes silently:

> "…across enterprise, startups and high-traffic consumer platforms**, including
> operations-heavy and marketplace domains**"

**Similarity measures what was kept; it cannot see what was appended.** No threshold fixes
this — the missed case scored *higher* than the caught one. Compare residual vocabulary as
well: find each claim's closest match in the master, and flag it when it introduces new content
words. On the run that exposed this, the ledger went from reporting 1 addition to 5, and all 5
were real.

Output markdown, render it with a print stylesheet, and use the browser's print-to-PDF. No PDF
dependency required.

**Acceptance, in three parts:** violations fire against a deliberately doctored master, so you
know the check works rather than merely never having complained; additions are enumerated
(verify by reading a real generation — an addition the ledger *misses* is worse than no ledger,
because it reads as an all-clear); no generated summary opens on an added line. **STOP.**

---

## 6. What this deliberately does not do

- **No LLM filtering of the feed.** Deterministic filters only. If something is missing you
  can read the rule that removed it; a model's judgement is unauditable, and silently dropping
  a role you would have applied for is the expensive failure.
- **No cover letters.** Nothing stops you adding it; the CV contract above transfers directly.
- **No scheduler.** Polling stays a button. The budget ledger is the only thing between a
  curious click and a month's quota, and it should be proven before anything automates it.
- **No scraping.** Every source here is a documented API. Job boards block scrapers, break
  constantly, and mostly forbid it in their terms.

---

## 7. What the findings cost

For calibration, since the temptation is to skip Phase 0.

The measurements in §2 came from about **40 API calls across four providers**, all inside free
tiers, plus one tailored CV generation of a few cents. Roughly two hours.

They caught, before any of it reached the data model: a source whose descriptions are 500
characters and would have silently starved the CV feature; a parameter that moved relevance
from 4% to 100%; a locale parameter that made entire markets look dead; three "free" APIs that
return twenty rows; and a salary field that means four different things depending on the
country.

Every one of those would have been a confusing bug weeks later instead.

Four further bugs in the reference build were found by **looking at rendered output**, not by
running tests: a mislabelled market, a crash from a type that promised a field the data did not
have, a ledger that under-reported, and phantom entries from punctuation. Tests caught none of
them, because in each case the code did exactly what it said it did. Build the thing, then look
at it.

---

*Written from a working implementation. Every measurement in it was taken against the live
APIs on 2026-08-12/13 — free tiers and API behaviour change, so re-measure rather than trusting
these numbers if something looks wrong.*
