Personalized, but never dishonest
Role Reaper is built for one job hunt: mine. The personalization is the whole point. But personal doesn't mean loose. Every stage that could quietly overstate a fit or reword a fact is boxed in by code. Here's the five-stage pipeline, start to finish.
I don't apply as one person. I have three résumé variants, AI-automation, marketing/growth-ops, and analytics, because the same background reads differently to different employers. So the pipeline starts by reading all three and deriving a distinct set of search phrases for each. The rest of the run pulls jobs against every variant's terms, so an analytics role and an AI role both surface, each found on its own terms.
async function deriveTerms(variantName, resumeText) { // one call per variant — the model turns a résumé into search phrases const res = await callClaude(buildTermsPrompt(variantName, resumeText)); return JSON.parse(res); // e.g. 5 phrases tuned to THAT variant } // output: search-terms.json — { "Analytics": [...], "AI-Automation": [...], ... }
Each posting goes to the model with one job: turn description text into structured facts. The prompt forbids inference, scoring, and the word "likely." Skills are matched against a fixed list by index, so there's no paraphrasing. Temperature is 0.
// the model's only output — facts, no opinions { "matchedSkillIndexes": [0, 1, 8], "workMode": "remote", "statedSalaryMax": 135000, "yearsRequired": 3, "requiresClearance": false, "isJunk": false }
The facts hit a scoring function with fixed rules. Hard blockers return 0 (clearance, 8+ years required). Everything else adds or subtracts from a base of 50. The model never sees a number, so the same posting always scores the same.
function scoreFromFacts(facts) { if (facts.requiresClearance) return { score: 0 }; if (facts.yearsRequired >= 8) return { score: 0 }; let score = 50; // base score += skillCount * 7; // per matched skill score += { remote: 15, hybrid: 5 }[mode]; // location if (salaryMax >= 120000) score += 10; if (yearsRequired >= 5) score -= 40; // senior penalty return { score: clamp(score, 0, 100) }; }
For high-fit roles, the résumé bullets get reordered by relevance. The bullets themselves are verbatim and never touched. Each is tagged with the skills it proves, then ranked by rarity-weighted overlap with the role: a rare, defining skill (AI-assisted development) outranks a common one (SQL). An AI role leads with AI bullets; an analytics role leads with pipeline bullets.
// rarer skill => higher weight => more defining of fit const weightOf = (skill) => 1 / (counts[skill] || 1); const ranked = bullets .map((b) => { const matched = b.skills.filter(s => roleSkills.has(s)); const score = matched.reduce((sum, s) => sum + weightOf(s), 0); return { text: b.text, score }; // text is verbatim }) .sort((a, b) => b.score - a.score);
The model only writes the cover letter, and even there it's boxed in: use these ranked bullets as the only evidence, don't stretch toward the posting's vocabulary, no invented skills, everything defensible in an interview.
A job tool that shows you the same postings every run is useless. So there's a persistent memory layer keyed by each posting's stable Adzuna ID. Every job carries a status through a lifecycle, and the ones I've acted on are suppressed from future pulls, so I never re-see something I already applied to or rejected.
// status lifecycle, keyed by stable Adzuna ID new → queued → applied → interview → offer → closed → rejected // applied / rejected are SUPPRESSED — never pulled again function isSuppressed(store, job) { return SUPPRESSED_STATUSES.has(getStatus(store, job)); }
One design choice I like: the store only records jobs I've acted on. A job with no record is implicitly "new." So the memory stays small and meaningful, it's a record of decisions, not a log of everything the pipeline ever saw. Each entry also keeps its full status-transition history, so the tool can show how a given application progressed over time.