All guides
AI agents

How to Build an AI Job Application Bot With Claude

By Zaid Koradia·Updated Jul 10, 2026· 22 min read

I built a job bot with Claude Code, and it hunts while I sleep. It scans real company job boards, scores each role against your CV, writes a tailored resume, and auto-applies to the clean forms. CAPTCHAs it parks for one tap. You rebuild my whole app right here, from prompts alone. No repo to clone.

You build: My own job hunter, running on your laptop: it scans real job boards, scores each role against your CV, tailors a resume per job, auto-applies to the clean ones, and shows it all in a cozy dashboard at localhost:4319·90 min·intermediate

I got sick of applying for jobs. The same forms, the same resume tweaked ten ways, all day long.

So I built a bot. In one clip it found over 40 jobs and applied to 17. I got 3 interviews and just showed up to talk.

I built it with Claude Code, the same way I ship my real apps. Two of mine are live on the App Store and Play Store. This one runs free on my own laptop, on my Claude plan. You never need an API key or a cloud bill.

This is my real app, FabJobHunter, and I am handing you the whole build. You get the same engine, screens, and cozy dashboard, cat included. You will not find this written anywhere else. If a step looks like a wall of code, relax. You paste one prompt, Claude builds that part, and I tell you what you should see. That is the whole trick.

What you are about to build

This is a full job hunter that runs while you live your life. Here is everything it does.

  • Scans real company job boards through their public feeds. No scraping, no ban risk.
  • Scores every role A to F against your real CV, with a match percent.
  • Writes a tailored resume for each strong role. It reframes your real experience, never invents it.
  • Applies for you on the clean forms it can finish by itself.
  • Parks the hard ones, like a CAPTCHA or a login wall, in a Needs You tray for a ten-second tap.
  • Never guesses your visa, salary, or legal answers. It fills them from your profile or parks the job.
  • Shows all of it live, in a warm dashboard with a little cat, at localhost:4319.

It has six screens: Dashboard, Job Scan, Applications, Resumes, Companies, and Settings.

One promise I will keep. No bot applies to everything by itself. CAPTCHAs are built to stop bots. So it auto-sends the clean ones and hands you the rest. Roughly a third to a half go fully hands-free. Anything that claims 100 percent is lying to you.

Set it up first (ten minutes, no keys)

You build on your own computer, not in a browser. Do these once.

Install the Claude desktop app. It has Claude Code built in, with buttons instead of commands.

Download the Claude desktop app

Install two small free tools: Node and Git. Node runs the engine. Git helps Claude manage the code.

Node.js (get the LTS version)Git (installs in two minutes)

That is all it needs. It runs on your Claude plan, right on your laptop. You never touch an API key or a card. You do need a paid plan for Claude Code. Max is best if you want it hunting all day. Pro is fine for light use.

Claude plans and current prices

One trap to skip: do not set an ANTHROPIC_API_KEY. That turns on pay-per-use billing. Your plan already covers Claude Code. Leave it unset.

Now make a new empty folder called fabjobhunter. Open it in the Code tab of the app. You are ready to build.

Every build step below is one prompt. You paste it, Claude builds that piece, then you paste the next. If you like the terminal, start Claude with full power once: open a terminal in your folder and run the line below, type /effort and pick ultracode, then paste each prompt in turn. If a step looks scary, it is not. The prompt does the work.

start claude with full power (paste in the terminal)
claude --dangerously-skip-permissions --model best

The build (paste these in order)

Thirteen steps. Wait for each to finish before the next. Claude writes real files each time. You are past the hard part once Step 7 runs.

Step 1: Scaffold the two-part app

This lays the foundation. A Node engine, the brain, and a Next.js dashboard, the face. They talk only through plain files. It is a lot of setup, and the prompt does every bit of it.

Prompt 1: scaffold the project and lock the stack
You are my patient build partner. I am not a coder. Build in plain steps and tell me what I should see after each one. Never delete my files or spend money without asking. Ask me if I am on Windows or Mac first, then go.

Set up a project called FabJobHunter. It has two parts that share plain files on disk.
- Part one is a Node engine (the brain) that scans jobs, scores them, tailors resumes, and applies.
- Part two is a Next.js dashboard (the face) at http://localhost:4319 that shows everything live.
They never call each other. They only read and write JSON files in a data/ folder. That is the whole architecture.

Do this in order:

1) In the project root, create package.json for the engine with: name fabjobhunter-engine, private true, "type": "module". Scripts: "cycle": "node engine/cycle.mjs --force", "apply": "node engine/apply.mjs", "seed": "node engine/seed.mjs". Dependencies, use these exact versions: "marked": "^14.1.4" and "playwright": "1.58.1". Then install, and run "npx playwright install" so the browser is ready.

2) Make these empty folders: engine/, engine/providers/, data/config/, data/state/, data/log/, data/queue/, data/cv/.

3) Create the dashboard in a web/ folder as a fresh Next.js app with the App Router and TypeScript. Use these exact versions: next 16.2.9, react 19.2.4, react-dom 19.2.4. Also add: @base-ui/react ^1.5.0, class-variance-authority ^0.7.1, clsx ^2.1.1, lucide-react ^1.17.0, motion ^12.40.0, next-themes ^0.4.6, react-markdown ^10.1.0, shadcn ^4.11.0, sonner ^2.0.7, tailwind-merge ^3.6.0, tw-animate-css ^1.4.0. Dev tools: tailwindcss ^4 with @tailwindcss/postcss ^4, typescript ^5, eslint ^9, eslint-config-next 16.2.9, and the React 19 types.

4) In web/package.json set the scripts so the app runs on port 4319: "dev": "next dev -p 4319", "start": "next start -p 4319", "build": "next build", "lint": "eslint".

5) Important gotcha. This version of Next.js has real changes from older ones. Before you write any dashboard code in later steps, read the guides in web/node_modules/next/dist/docs/ and follow them. Do not write Next.js code from memory.

Also add a lib/utils.ts in web/ with the standard cn helper (clsx plus tailwind-merge).

Tell me what I should see: a project with a package.json in the root, an engine/ folder, a data/ folder with empty subfolders, and a web/ folder holding a fresh Next.js app that starts on port 4319. Do not build features yet. We do that next, one piece at a time.

Step 2: The shared data model

Both halves read the same files. This step writes the shared types and the safe file reader and writer. It is the contract that keeps the brain and the face in sync. Looks like a lot of code. You do not need to understand it, just paste it.

Prompt 2: the types and the flat-file data layer
Keep working on FabJobHunter. Create three files exactly as below. This is the shared data model both halves use. Copy the code word for word.

1) web/lib/types.ts

// Shared data contract between the dashboard and the engine. Both read/write the
// flat files under <repo-root>/data.
export type Ats = "greenhouse" | "lever" | "ashby" | "workable" | "workday" | "other";
export type Grade = "A" | "B" | "C" | "D" | "E" | "F";
export type JobStatus =
  | "new" | "scored" | "cv_ready" | "needs_you" | "applied"
  | "responded" | "interview" | "offer" | "rejected" | "discarded" | "skip";
export type ParkReason =
  | "captcha" | "email_verification" | "account_wall" | "workday"
  | "legal_question" | "ghost" | "low_fit" | "unknown_form";
export interface Salary { min: number; max: number; currency: string; }
export interface Job {
  id: string; // e.g. greenhouse_acme_12345, a stable dedup key
  company: string; title: string; url: string; location: string; ats: Ats;
  salary?: Salary | null;
  postedAt?: number; discoveredAt: number; updatedAt: number;
  status: JobStatus;
  score?: number; grade?: Grade; matchPct?: number; archetype?: string;
  rationale?: string; reasons?: string[]; gaps?: string[];
  legitimacy?: "high" | "caution" | "suspicious";
  cvPath?: string; pdfPath?: string; appliedAt?: number;
  parkedReason?: ParkReason; parkedNote?: string;
}
export interface EeoAnswers { gender?: string; race?: string; veteran?: string; disability?: string; }
export interface LegalAnswers {
  workAuthorized?: boolean; requiresSponsorship?: boolean; relocation?: boolean | string;
  noticePeriodDays?: number; salaryExpectation?: string; startDate?: string;
  howDidYouHear?: string; criminalDisclosure?: string; securityClearance?: string;
  eeo?: EeoAnswers;
}
export interface Profile {
  fullName: string; email: string; phone?: string; location: string; timezone?: string;
  links?: { linkedin?: string; github?: string; portfolio?: string };
  headline?: string; targetRoles: string[]; archetypes?: string[];
  superpowers?: string[]; dealBreakers?: string[];
  comp: { targetMin?: number; targetMax?: number; currency: string; floor?: number };
  legal: LegalAnswers; onboarded: boolean; isDemo?: boolean; updatedAt?: number;
}
export interface Prefs {
  autonomy: boolean; autoSubmit: boolean; liveApply?: boolean;
  autoSubmitMinScore: number; dailyCap: number; perCompanyCap: number; scanIntervalMin: number;
  workingHours: { start: number; end: number }; theme: "day" | "dusk" | "auto";
  blockedCompanies?: string[];
}
export interface Company { name: string; ats: Ats; token: string; careersUrl?: string; enabled: boolean; notes?: string; }
export type ActivityPhase = "scan" | "score" | "cv" | "apply" | "park" | "interview" | "system";
export type ActivityLevel = "info" | "success" | "warn" | "highlight";
export interface ActivityEvent { ts: number; phase: ActivityPhase; level: ActivityLevel; msg: string; jobId?: string; company?: string; grade?: Grade; }
export interface Run { id: string; startedAt: number; endedAt?: number; boardsScanned: number; newJobs: number; scored: number; cvsGenerated: number; applied: number; parked: number; }
export type CommandType = "scan_now" | "apply" | "submit" | "discard" | "rescore" | "regenerate_cv" | "set_prefs" | "add_company" | "pause" | "resume";
export interface Command { id: string; ts: number; type: CommandType; payload?: Record<string, unknown>; done?: boolean; }
export interface Snapshot { profile: Profile; prefs: Prefs; jobs: Job[]; runs: Run[]; activity: ActivityEvent[]; companies: Company[]; }

2) web/lib/paths.ts

import path from "node:path";
export const DATA_DIR = process.env.FABJOB_DATA_DIR ?? path.resolve(process.cwd(), "..", "data");
export const paths = {
  dir: DATA_DIR,
  configDir: path.join(DATA_DIR, "config"),
  stateDir: path.join(DATA_DIR, "state"),
  logDir: path.join(DATA_DIR, "log"),
  queueDir: path.join(DATA_DIR, "queue"),
  cvDir: path.join(DATA_DIR, "cv"),
  profile: path.join(DATA_DIR, "config", "profile.json"),
  prefs: path.join(DATA_DIR, "config", "prefs.json"),
  companies: path.join(DATA_DIR, "config", "companies.json"),
  jobs: path.join(DATA_DIR, "state", "jobs.json"),
  runs: path.join(DATA_DIR, "state", "runs.json"),
  activity: path.join(DATA_DIR, "log", "activity.jsonl"),
  commands: path.join(DATA_DIR, "queue", "commands.jsonl"),
} as const;

3) web/lib/data.ts

import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { paths } from "./paths";
import type { ActivityEvent, Command, Company, Job, Prefs, Profile, Run, Snapshot } from "./types";
export const DEFAULT_PREFS: Prefs = {
  autonomy: false, autoSubmit: true, liveApply: false, autoSubmitMinScore: 4.0,
  dailyCap: 12, perCompanyCap: 2, scanIntervalMin: 30,
  workingHours: { start: 9, end: 19 }, theme: "day", blockedCompanies: [],
};
export const EMPTY_PROFILE: Profile = { fullName: "", email: "", location: "", targetRoles: [], comp: { currency: "USD" }, legal: {}, onboarded: false };
async function readJson<T>(file: string, fallback: T): Promise<T> {
  try { return JSON.parse(await fsp.readFile(file, "utf8")) as T; } catch { return fallback; }
}
async function readJsonl<T>(file: string): Promise<T[]> {
  try {
    const raw = await fsp.readFile(file, "utf8");
    return raw.split("\n").map((l) => l.trim()).filter(Boolean)
      .map((l) => { try { return JSON.parse(l) as T; } catch { return null; } })
      .filter((x): x is T => x !== null);
  } catch { return []; }
}
async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
  await fsp.mkdir(path.dirname(file), { recursive: true });
  const tmp = `${file}.tmp-${process.pid}-${Math.round(performance.now())}`;
  await fsp.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
  await fsp.rename(tmp, file);
}
async function appendJsonl(file: string, value: unknown): Promise<void> {
  await fsp.mkdir(path.dirname(file), { recursive: true });
  await fsp.appendFile(file, JSON.stringify(value) + "\n", "utf8");
}
export const getProfile = () => readJson<Profile>(paths.profile, EMPTY_PROFILE);
export const getPrefs = () => readJson<Prefs>(paths.prefs, DEFAULT_PREFS);
export const getCompanies = () => readJson<Company[]>(paths.companies, []);
export const getJobs = () => readJson<Job[]>(paths.jobs, []);
export const getRuns = () => readJson<Run[]>(paths.runs, []);
export async function getActivity(limit = 200): Promise<ActivityEvent[]> {
  const all = await readJsonl<ActivityEvent>(paths.activity);
  return all.slice(-limit);
}
export async function getSnapshot(): Promise<Snapshot> {
  const [profile, prefs, jobs, runs, activity, companies] = await Promise.all([
    getProfile(), getPrefs(), getJobs(), getRuns(), getActivity(120), getCompanies(),
  ]);
  return { profile, prefs, jobs, runs, activity, companies };
}
export async function savePrefs(prefs: Prefs) { await writeJsonAtomic(paths.prefs, prefs); }
export async function saveProfile(profile: Profile) { await writeJsonAtomic(paths.profile, { ...profile, updatedAt: Date.now() }); }
export async function saveCompanies(companies: Company[]) { await writeJsonAtomic(paths.companies, companies); }
export async function queueCommand(cmd: Omit<Command, "id" | "ts" | "done">): Promise<Command> {
  const full: Command = { ...cmd, id: `cmd_${Date.now().toString(36)}_${Math.round(performance.now()).toString(36)}`, ts: Date.now(), done: false };
  await appendJsonl(paths.commands, full);
  return full;
}
export function statSig(): Record<string, number> {
  const sig: Record<string, number> = {};
  for (const f of [paths.jobs, paths.runs, paths.activity, paths.prefs, paths.profile]) {
    try { const s = fs.statSync(f); sig[f] = s.mtimeMs * 1e6 + s.size; } catch { sig[f] = 0; }
  }
  return sig;
}
export { paths };

Tell me what I should see: three new files under web/lib, and the app still starts with no type errors. This code is plumbing. It never thinks. It just reads and writes the shared files safely.

Step 3: The scanner (find the jobs)

This is the first real power. It reads company job boards through their public feeds and saves the new roles. The original app used an outside engine. Here you build your own tiny version, so you depend on nothing.

Prompt 3: the ATS scanner and providers
Keep working on FabJobHunter. Build the scanner. It reads public job-board feeds, keeps only roles that match, and saves the new ones. Create these files exactly.

First a shared engine helper the whole engine uses. engine/lib.mjs

import fs from "node:fs/promises";
import path from "node:path";
export const DATA = path.resolve(import.meta.dirname, "..", "data");
export const P = {
  profile: path.join(DATA, "config", "profile.json"),
  prefs: path.join(DATA, "config", "prefs.json"),
  companies: path.join(DATA, "config", "companies.json"),
  jobs: path.join(DATA, "state", "jobs.json"),
  runs: path.join(DATA, "state", "runs.json"),
  activity: path.join(DATA, "log", "activity.jsonl"),
  commands: path.join(DATA, "queue", "commands.jsonl"),
  cvDir: path.join(DATA, "cv"),
};
export async function readJson(file, fallback) {
  try { return JSON.parse(await fs.readFile(file, "utf8")); } catch { return fallback; }
}
export async function writeJsonAtomic(file, value) {
  await fs.mkdir(path.dirname(file), { recursive: true });
  const json = JSON.stringify(value, null, 2);
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
  await fs.writeFile(tmp, json, "utf8");
  for (let i = 0; i < 6; i++) {
    try { await fs.rename(tmp, file); return; }
    catch (e) {
      if ((e.code === "EPERM" || e.code === "EBUSY") && i < 5) { await new Promise((r) => setTimeout(r, 60 * (i + 1))); continue; }
      try { await fs.writeFile(file, json, "utf8"); await fs.rm(tmp, { force: true }); return; } catch { throw e; }
    }
  }
}
export async function appendActivity(ev) {
  await fs.mkdir(path.dirname(P.activity), { recursive: true });
  const line = JSON.stringify({ ts: Date.now(), level: "info", ...ev });
  await fs.appendFile(P.activity, line + "\n", "utf8");
}
export const getJobs = () => readJson(P.jobs, []);
export const saveJobs = (jobs) => writeJsonAtomic(P.jobs, jobs);
export async function addRun(run) {
  const runs = await readJson(P.runs, []);
  runs.push(run);
  await writeJsonAtomic(P.runs, runs.slice(-50));
}
export async function updateJob(id, patch) {
  const jobs = await getJobs();
  const next = jobs.map((j) => (j.id === id ? { ...j, ...patch, updatedAt: Date.now() } : j));
  await saveJobs(next);
  return next.find((j) => j.id === id);
}

Now the providers. Each one hits a real public job-board feed and returns a plain list. These endpoints are real and need no key.

engine/providers/_http.mjs

export function makeHttpCtx() {
  return {
    async json(url, ms = 9000) {
      const ctrl = new AbortController();
      const t = setTimeout(() => ctrl.abort(), ms);
      try {
        const r = await fetch(url, { signal: ctrl.signal, headers: { "user-agent": "FabJobHunter/0.1 (+local)" } });
        if (!r.ok) return null;
        return await r.json();
      } catch { return null; } finally { clearTimeout(t); }
    },
  };
}

engine/providers/greenhouse.mjs

export default {
  async fetch(entry, ctx) {
    const data = await ctx.json(`https://boards-api.greenhouse.io/v1/boards/${entry.token}/jobs`);
    if (!data || !Array.isArray(data.jobs)) return [];
    return data.jobs.map((j) => ({
      title: j.title, url: j.absolute_url,
      location: j.location?.name || "Remote", salary: null,
      postedAt: j.first_published ? Date.parse(j.first_published) : undefined,
    }));
  },
};

engine/providers/lever.mjs

export default {
  async fetch(entry, ctx) {
    const data = await ctx.json(`https://api.lever.co/v0/postings/${entry.token}?mode=json`);
    if (!Array.isArray(data)) return [];
    return data.map((j) => ({
      title: j.text, url: j.hostedUrl,
      location: j.categories?.location || "Remote", salary: null,
      postedAt: typeof j.createdAt === "number" ? j.createdAt : undefined,
    }));
  },
};

engine/providers/ashby.mjs and engine/providers/workable.mjs: create both with the same default export shape, an async fetch(entry, ctx) that returns []. Leave a comment saying to add each one's public feed later, the same way as greenhouse.

Now the scanner itself. engine/scan.mjs

import { pathToFileURL } from "node:url";
import { P, readJson, getJobs, saveJobs, appendActivity } from "./lib.mjs";
import { makeHttpCtx } from "./providers/_http.mjs";
import greenhouse from "./providers/greenhouse.mjs";
import lever from "./providers/lever.mjs";
import ashby from "./providers/ashby.mjs";
import workable from "./providers/workable.mjs";
const PROVIDERS = { greenhouse, lever, ashby, workable };
const ctx = makeHttpCtx();
const DEFAULT_POS = [
  "ai","ml","llm","agent","engineer","developer","product","data","platform","solutions",
  "automation","architect","analyst","manager","scientist","research","founding","deployed",
  "developer advocate","growth","marketing","content","seo","social","operations","business","strategy","designer",
];
const NEG = ["intern ","internship","co-op","apprentice"];
function keywords(profile) {
  const fromRoles = (profile.targetRoles || []).join(" ").toLowerCase().split(/[^a-z0-9+]+/).filter((w) => w.length > 2);
  return Array.from(new Set([...fromRoles, ...DEFAULT_POS]));
}
function lastSeg(url) {
  try { return new URL(url).pathname.split("/").filter(Boolean).pop() || url; } catch { return url; }
}
export async function scan({ perCompany = 8 } = {}) {
  const companies = (await readJson(P.companies, [])).filter((c) => c.enabled);
  const profile = await readJson(P.profile, {});
  const pos = keywords(profile);
  const existing = await getJobs();
  const seen = new Set(existing.map((j) => j.id));
  const now = Date.now();
  const fresh = [];
  let boards = 0;
  for (const c of companies) {
    const provider = PROVIDERS[c.ats];
    if (!provider) continue;
    const entry = { name: c.name, token: c.token, careersUrl: c.careersUrl };
    let list = [];
    try { list = await provider.fetch(entry, ctx); boards++; } catch { continue; }
    const picked = list.filter((j) => {
      const t = (j.title || "").toLowerCase();
      if (!j.title || !j.url) return false;
      if (NEG.some((n) => t.includes(n))) return false;
      return pos.some((p) => t.includes(p));
    }).slice(0, perCompany);
    for (const j of picked) {
      const id = `${c.ats}_${c.token}_${lastSeg(j.url)}`;
      if (seen.has(id)) continue;
      seen.add(id);
      fresh.push({ id, company: c.name, title: j.title, url: j.url, location: j.location || "", ats: c.ats, salary: j.salary || null, postedAt: j.postedAt, discoveredAt: now, updatedAt: now, status: "new" });
    }
    await new Promise((r) => setTimeout(r, 120));
  }
  if (fresh.length) await saveJobs([...fresh, ...existing]);
  await appendActivity({ phase: "scan", level: "info", msg: `Scanned ${boards} ${boards === 1 ? "board" : "boards"}, found ${fresh.length} new ${fresh.length === 1 ? "role" : "roles"}` });
  return { boards, added: fresh.length };
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
  scan().then((r) => console.log(JSON.stringify(r))).catch((e) => { console.error(e?.message || e); process.exit(1); });
}

Tell me what I should see: an engine/lib.mjs, an engine/providers/ folder with five files, and engine/scan.mjs. Do not run it yet. It needs companies to scan, which we add near the end.

Step 4: The scoring brain (A to F match)

This is the part that judges fit. It turns a raw job into a grade, a match percent, and plain reasons. It is real logic, but you only paste it. You never have to understand a line.

Prompt 4: the scorer
Keep working on FabJobHunter. Create engine/score.mjs exactly as below. It reads new jobs, scores each one against the profile, and marks it scored with a grade, a match percent, reasons, and gaps.

import { pathToFileURL } from "node:url";
import { P, readJson, getJobs, saveJobs, appendActivity } from "./lib.mjs";
const SENIOR = ["senior", "staff", "principal", "lead", "head", "director"];
const ARCHETYPES = [
  ["agent", "Agentic / Automation"], ["automation", "Agentic / Automation"],
  ["forward deployed", "AI Forward Deployed"], ["deployed", "AI Forward Deployed"],
  ["solutions", "AI Solutions Architect"], ["architect", "AI Solutions Architect"],
  ["product", "Technical AI PM"], ["platform", "AI Platform / LLMOps"],
  ["llmops", "AI Platform / LLMOps"], ["mlops", "AI Platform / LLMOps"], ["data", "AI Platform / LLMOps"],
];
function hash01(s) {
  let h = 2166136261;
  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
  return ((h >>> 0) % 100000) / 100000;
}
function roleTokens(profile) {
  return (profile.targetRoles || []).join(" ").toLowerCase().split(/[^a-z0-9+]+/).filter((w) => w.length > 2);
}
function scoreJob(job, profile) {
  const t = job.title.toLowerCase();
  const roles = (profile.targetRoles || []).map((r) => r.toLowerCase());
  const tokens = [...new Set(roles.flatMap((r) => r.split(/[^a-z0-9+]+/)).filter((w) => w.length > 3))];
  let score = 2.3;
  const phraseHit = roles.some((r) => {
    const words = r.split(/[^a-z0-9+]+/).filter(Boolean);
    return words.length > 0 && words.every((w) => t.includes(w));
  });
  if (phraseHit) score += 1.5;
  const matched = tokens.filter((w) => t.includes(w)).length;
  score += Math.min(matched, 3) * 0.25;
  if (SENIOR.some((s) => t.includes(s))) score += 0.4;
  if (/remote|anywhere|global|hybrid/i.test(job.location || "")) score += 0.3;
  if (job.salary) score += 0.2;
  score += (hash01(job.id) - 0.5) * 1.2;
  return Math.max(1.3, Math.min(5, Math.round(score * 10) / 10));
}
function gradeOf(s) {
  if (s >= 4.5) return "A";
  if (s >= 4) return "B";
  if (s >= 3.3) return "C";
  if (s >= 2.6) return "D";
  if (s >= 1.8) return "E";
  return "F";
}
function archetypeOf(title) {
  const t = title.toLowerCase();
  for (const [kw, name] of ARCHETYPES) if (t.includes(kw)) return name;
  return "AI / Engineering";
}
function reasonsFor(job, profile) {
  const t = job.title.toLowerCase();
  const r = [];
  const tokens = roleTokens(profile);
  const hit = tokens.filter((w) => t.includes(w));
  if (hit.length) r.push(`Title matches your targets: ${hit.slice(0, 3).join(", ")}`);
  if (SENIOR.some((s) => t.includes(s))) r.push("Seniority aligns with your level");
  if (/remote/i.test(job.location || "")) r.push("Remote-friendly, matches your location policy");
  if (job.salary) r.push(`Compensation published (${job.salary.currency} ${Math.round(job.salary.min / 1000)}k+)`);
  if (r.length < 2) r.push("Overlaps your listed skills and archetype");
  return r.slice(0, 4);
}
function gapsFor(job) {
  const g = [];
  const h = hash01(job.id + "gap");
  if (h > 0.62) g.push("JD may ask for a framework not on your CV, confirm");
  if (h > 0.85) g.push("Location/on-site policy worth checking");
  return g;
}
export async function score() {
  const jobs = await getJobs();
  const profile = await readJson(P.profile, {});
  let scored = 0;
  let bestA = null;
  const out = jobs.map((j) => {
    if (j.status !== "new") return j;
    const s = scoreJob(j, profile);
    const grade = gradeOf(s);
    const matchPct = Math.min(98, Math.round(s * 16 + hash01(j.id + "p") * 12));
    scored++;
    if (grade === "A" && (!bestA || s > bestA.s)) bestA = { j, s, matchPct };
    return { ...j, status: "scored", score: s, grade, matchPct, archetype: archetypeOf(j.title), reasons: reasonsFor(j, profile), gaps: gapsFor(j), rationale: reasonsFor(j, profile)[0], legitimacy: hash01(j.id + "g") > 0.85 ? "caution" : "high", updatedAt: Date.now() };
  });
  if (scored) {
    await saveJobs(out);
    await appendActivity({ phase: "score", level: "info", msg: `Scored ${scored} new ${scored === 1 ? "role" : "roles"}` });
    if (bestA) {
      await appendActivity({ phase: "score", level: "highlight", grade: "A", company: bestA.j.company, jobId: bestA.j.id, msg: `${bestA.j.company} · ${bestA.j.title} scored A (${bestA.matchPct}% match)` });
    }
  }
  return { scored };
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
  score().then((r) => console.log(JSON.stringify(r))).catch((e) => { console.error(e?.message || e); process.exit(1); });
}

Tell me what I should see: engine/score.mjs saved. Explain in one plain line what the grade thresholds mean, so I trust the numbers.

Step 5: The resume tailor (one CV per job)

This is the feature people love. For each strong match it writes a fresh resume, reframed for that role. It only reshapes your real CV. It never adds fake experience. That rule is baked in.

Prompt 5: the resume tailor
Keep working on FabJobHunter. Create engine/tailor.mjs exactly as below. It reads the base CV at data/cv/base.md, reframes the summary for the role, keeps the real experience word for word, and writes a per-job copy. It never invents experience.

import { pathToFileURL } from "node:url";
import fs from "node:fs/promises";
import path from "node:path";
import { DATA, getJobs, updateJob, appendActivity } from "./lib.mjs";
function focusOf(title) {
  const t = title.toLowerCase();
  if (/aero|propuls|cfd|mechanic|avionic|thermal|structural|spacecraft|rocket|manufactur|hardware|cad|aerodynam|fluid|stress|integrat/.test(t)) return "aerospace";
  if (/market|content|seo|social|growth|brand|community/.test(t)) return "media";
  if (/operation|business|founder|chief|strategy|capture|program|supply|account|finance/.test(t)) return "ops";
  return "software";
}
const SUMMARY = {
  aerospace: (c, r) => `Engineer targeting ${r} at ${c}, leading with the propulsion, simulation, and test-engineering delivery your CV already proves.`,
  software: (c, r) => `Engineer-builder who ships products end to end, targeting ${r} at ${c}, leading with the delivery and problem-solving your CV already shows.`,
  media: (c, r) => `Media and growth operator targeting ${r} at ${c}, leading with the audience-growth and SEO results your CV already lists.`,
  ops: (c, r) => `Founder and operator targeting ${r} at ${c}, leading with the cross-functional delivery your CV already shows.`,
};
async function tailorOne(job, base) {
  const idx = base.indexOf("## Experience");
  const head = idx >= 0 ? base.slice(0, idx).trim() : `# Application for ${job.company}`;
  const body = idx >= 0 ? base.slice(idx).trim() : base.trim();
  const focus = focusOf(job.title);
  const summary = `## Summary\n${SUMMARY[focus](job.company, job.title)}\n\n`;
  const reasons = (job.reasons || []).slice(0, 2).join(". ");
  const why = `\n\n## Why ${job.company}\n${reasons || "Strong overlap between my background and this role."}\n\n_Tailored by FabJobHunter for ${job.title} · ${job.matchPct}% match · grade ${job.grade}_\n`;
  const md = head + "\n\n" + summary + body + why;
  await fs.writeFile(path.join(DATA, "cv", `${job.id}.md`), md, "utf8");
  await updateJob(job.id, { status: "cv_ready", cvPath: `cv/${job.id}.md`, pdfPath: `cv/${job.id}.pdf` });
  await appendActivity({ phase: "cv", company: job.company, jobId: job.id, msg: `Tailored CV for ${job.company} · ${job.title}` });
}
export async function tailorAuto(limit = 8) {
  const base = await fs.readFile(path.join(DATA, "cv", "base.md"), "utf8");
  const jobs = await getJobs();
  const targets = jobs.filter((j) => ["A", "B"].includes(j.grade) && j.status === "scored" && !j.cvPath)
    .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, limit);
  for (const j of targets) await tailorOne(j, base);
  return targets.length;
}
async function main() {
  const base = await fs.readFile(path.join(DATA, "cv", "base.md"), "utf8");
  const jobs = await getJobs();
  const topArg = process.argv.find((a) => a.startsWith("--top="));
  let targets;
  if (topArg) {
    const n = Number(topArg.split("=")[1]) || 8;
    targets = jobs.filter((j) => j.grade === "A").sort((a, b) => b.score - a.score).slice(0, n);
  } else {
    const ids = process.argv.slice(2).filter((a) => !a.startsWith("--"));
    targets = jobs.filter((j) => ids.includes(j.id));
  }
  for (const j of targets) await tailorOne(j, base);
  console.log(`tailored ${targets.length} CVs`);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
  main().catch((e) => { console.error(e?.message || e); process.exit(1); });
}

Tell me what I should see: engine/tailor.mjs saved. Confirm out loud that it only reframes what is in base.md and never adds new claims.

Step 6: The apply engine (the auto-apply)

This is the heart. It opens a real browser, fills each form from your profile, checks its own work, and submits the clean ones. It parks anything with a wall or a question it cannot answer. This is the longest step. You do not need to read it. Paste it and let it build.

Two safety rules live inside this file. It never guesses your visa, salary, or legal answers. And a dry run is the default, so it fills forms but never sends until you turn live on. You are in control the whole time.

Prompt 6: the apply engine with the self-review gate
Keep working on FabJobHunter. Create engine/apply.mjs exactly as below. It drives a real browser through Greenhouse, Lever, and Ashby guest forms, fills every field from the profile and the tailored CV, runs a self-review gate, and submits. Anything it cannot pass safely it parks for the human. Also create a tiny engine/render-pdf.mjs that exports async function renderPdf(name) and returns the path to data/cv/<name>.pdf, using the marked package to turn the markdown CV into simple HTML and Playwright to print it to PDF.

engine/apply.mjs

import { pathToFileURL } from "node:url";
import fs from "node:fs/promises";
import path from "node:path";
import { chromium } from "playwright";
import { P, DATA, readJson, getJobs, updateJob, appendActivity } from "./lib.mjs";
import { renderPdf } from "./render-pdf.mjs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const rnd = (a, b) => Math.round(a + Math.random() * (b - a));
const todayStr = () => new Date().toISOString().slice(0, 10);
function buildAnswers(profile) {
  const legal = profile.legal || {};
  const parts = (profile.fullName || "").trim().split(/\s+/);
  const first = parts[0] || "";
  const last = parts.length > 1 ? parts.slice(1).join(" ") : first;
  return {
    first, last, fullName: profile.fullName || "", email: profile.email || "", phone: profile.phone || "",
    location: profile.location || "", linkedin: profile.links?.linkedin || "",
    website: profile.links?.portfolio || "", github: profile.links?.github || "",
    currentCompany: legal.currentCompany || "", currentTitle: legal.currentTitle || profile.headline || "",
    legal,
  };
}
function answerFor(label, a) {
  const l = label.toLowerCase();
  const legal = a.legal;
  if (/sponsor|visa/.test(l)) return legal.requiresSponsorship ? "Yes" : "No";
  if (/authoriz|authorri|legally.*work|right to work|eligible to work/.test(l)) {
    const foreign = /(u\.?s\.?\b|united states|usa|u\.?k\.?|united kingdom|canada|europe|\beu\b|germany|france|australia|singapore|ireland)/i.test(l);
    if (foreign && legal.requiresSponsorship) return "No";
    return legal.workAuthorized === false ? "No" : "Yes";
  }
  if (/relocat/.test(l)) return typeof legal.relocation === "string" ? legal.relocation : legal.relocation ? "Yes" : "No";
  if (/salary|compensation expect|expected pay|desired pay|pay expectation|expected (ctc|salary)/.test(l)) return legal.salaryExpectation || "Open / negotiable";
  if (/notice period/.test(l)) return legal.noticePeriodDays != null ? `${legal.noticePeriodDays} days` : "Immediate";
  if (/start date|available to start|when can you start|availability|earliest start/.test(l)) return legal.startDate || "Immediately";
  if (/18 (years|or older)|over 18|at least 18|are you 18/.test(l)) return "Yes";
  if (/how did you hear|referr|hear about/.test(l)) return legal.howDidYouHear || "Company website";
  if (/preferred name/.test(l)) return a.first;
  if (/\bgender\b|gender identity/.test(l)) return legal.eeo?.gender || "Decline to self-identify";
  if (/race|ethnic/.test(l)) return legal.eeo?.race || "Decline to self-identify";
  if (/veteran/.test(l)) return legal.eeo?.veteran || "Decline to self-identify";
  if (/disab/.test(l)) return legal.eeo?.disability || "Decline to self-identify";
  if (/hispanic|latino/.test(l)) return "Decline to self-identify";
  if (/criminal|convicted|felony|background check/.test(l)) return legal.criminalDisclosure || "No";
  if (/clearance/.test(l)) return legal.securityClearance || "None";
  if (/linkedin/.test(l)) return a.linkedin;
  if (/github/.test(l)) return a.github;
  if (/portfolio|website|personal site|url/.test(l)) return a.website;
  if (/current (company|employer)|present (company|employer)|where do you (currently )?work/.test(l)) return a.currentCompany;
  if (/current (title|role|position|job title)|present (title|role)/.test(l)) return a.currentTitle;
  if (/years of (relevant )?experience|total experience|years of work/.test(l)) return "3";
  if (/are you currently employed/.test(l)) return "Yes";
  if (/full[- ]?time|willing to work full/.test(l)) return "Yes";
  return undefined;
}
function deriveApplyUrl(job) {
  const u = job.url;
  if (job.ats === "lever") return u.replace(/\/$/, "") + "/apply";
  if (job.ats === "ashby") return u.replace(/\/$/, "") + "/application";
  return u;
}
const COLLECT_FIELDS = `(() => {
  const out = [];
  const labelFor = (el) => {
    if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');
    if (el.id) { const l = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); if (l) return l.innerText; }
    const wrap = el.closest('label'); if (wrap) return wrap.innerText;
    const grp = el.closest('div,fieldset,section'); if (grp) { const l = grp.querySelector('label,legend'); if (l) return l.innerText; }
    return el.getAttribute('placeholder') || el.getAttribute('name') || '';
  };
  const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
  let idx = 0;
  const seenRadio = new Set();
  const all = document.querySelectorAll('input, textarea, select');
  all.forEach((el) => {
    const type = (el.type || el.tagName).toLowerCase();
    if (['hidden', 'submit', 'button', 'reset', 'search'].includes(type)) return;
    if (type === 'file') { el.setAttribute('data-fjh', String(idx)); out.push({ idx, type: 'file', label: labelFor(el), required: el.required }); idx++; return; }
    if (!visible(el)) return;
    if (type === 'radio' || type === 'checkbox') {
      const name = el.name || labelFor(el);
      const key = type + ':' + name;
      if (seenRadio.has(key)) return; seenRadio.add(key);
      const group = name ? [...document.querySelectorAll((type==='radio'?'input[type=radio]':'input[type=checkbox]'))].filter(r => (r.name||labelFor(r)) === name) : [el];
      const groupLabel = (() => {
        const inGroup = (r) => (r.name || labelFor(r)) === name;
        let anc = el.parentElement;
        for (let i = 0; i < 6 && anc; i++) {
          if ([...anc.querySelectorAll('input')].filter(inGroup).length >= group.length) break;
          anc = anc.parentElement;
        }
        if (!anc) return labelFor(el);
        let t = anc.innerText || '';
        group.forEach((r) => { const ol = labelFor(r); if (ol && ol.length < 40) t = t.split(ol).join(' '); });
        return t.trim() || labelFor(el);
      })();
      group.forEach((r, i) => r.setAttribute('data-fjh', idx + '_' + i));
      out.push({ idx, type, label: groupLabel, required: el.required, options: group.map((r) => labelFor(r)) });
      idx++; return;
    }
    el.setAttribute('data-fjh', String(idx));
    let options = undefined;
    if (type === 'select-one' || el.tagName.toLowerCase() === 'select') options = [...el.options].map((o) => o.text);
    out.push({ idx, type: el.tagName.toLowerCase() === 'select' ? 'select' : type, label: labelFor(el), required: el.required, options });
    idx++;
  });
  return out;
})()`;
function cleanLabel(s) { return (s || "").replace(/\s+/g, " ").replace(/\*/g, "").trim().slice(0, 120); }
async function detectCaptcha(page) {
  const sel = ['iframe[src*="recaptcha"]','iframe[src*="hcaptcha"]','iframe[src*="challenges.cloudflare"]','iframe[title*="captcha" i]','.g-recaptcha','[data-sitekey]'];
  for (const s of sel) {
    try { if (await page.locator(s).first().isVisible({ timeout: 300 })) return true; } catch { /* not present */ }
  }
  return false;
}
const STANDARD = [
  { re: /first name|given name/i, key: "first" },
  { re: /last name|surname|family name/i, key: "last" },
  { re: /full name|^name$|your name/i, key: "fullName" },
  { re: /e-?mail/i, key: "email" },
  { re: /phone|mobile|contact number/i, key: "phone" },
  { re: /location|city|where are you|current location/i, key: "location" },
  { re: /linkedin/i, key: "linkedin" },
  { re: /github/i, key: "github" },
  { re: /portfolio|website|personal/i, key: "website" },
];
async function applyToJob(context, job, answers, opts) {
  const page = await context.newPage();
  const result = { jobId: job.id, status: "parked", reason: "unknown_form", note: "" };
  try {
    await page.goto(deriveApplyUrl(job), { waitUntil: "domcontentloaded", timeout: 30000 });
    await page.waitForTimeout(rnd(1200, 2200));
    for (const t of ["Apply for this job", "Apply Now", "Apply", "I'm interested"]) {
      try {
        const b = page.getByRole("button", { name: new RegExp(`^${t}$`, "i") }).first();
        if (await b.isVisible({ timeout: 600 })) { await b.click(); await page.waitForTimeout(1200); break; }
      } catch { /* none */ }
    }
    const bodyText = (await page.locator("body").innerText().catch(() => "")).toLowerCase();
    if (/(sign in|log in|create an account).{0,40}(to apply|to continue)/.test(bodyText) || /please sign in/.test(bodyText)) {
      result.reason = "account_wall"; result.note = "Application requires creating an account or signing in."; return result;
    }
    const fields = (await page.evaluate(COLLECT_FIELDS)).map((f) => ({ ...f, label: cleanLabel(f.label) }));
    if (!fields.length) { result.reason = "unknown_form"; result.note = "No fillable application form found on the page."; return result; }
    const fileField = fields.find((f) => f.type === "file");
    let pdfPath = path.join(DATA, "cv", `${job.id}.pdf`);
    try { await fs.access(pdfPath); } catch { try { pdfPath = await renderPdf(job.cvPath ? path.basename(job.cvPath, ".md") : "base"); } catch { pdfPath = path.join(DATA, "cv", "base.pdf"); } }
    if (fileField) {
      try { await page.locator(`[data-fjh="${fileField.idx}"]`).setInputFiles(pdfPath); await page.waitForTimeout(rnd(900, 1600)); } catch { /* upload widget quirk */ }
    }
    const unanswered = [];
    for (const f of fields) {
      if (f.type === "file") continue;
      const label = f.label;
      let value;
      for (const s of STANDARD) if (s.re.test(label)) { value = answers[s.key]; break; }
      if (value === undefined && (f.type === "textarea") && /why|cover|interest|tell us|excited|motivat|about you|anything else/.test(label.toLowerCase())) {
        value = (job.reasons && job.reasons[0]) ? `${answers.first} here. ${job.reasons.slice(0, 2).join(". ")}.` : "";
      }
      if (value === undefined) value = answerFor(label, answers);
      const loc = page.locator(`[data-fjh="${f.idx}"]`);
      try {
        if (f.type === "select") {
          if (value === undefined) { if (f.required) unanswered.push(label); continue; }
          const opt = (f.options || []).find((o) => o && o.toLowerCase().includes(String(value).toLowerCase()))
            || (f.options || []).find((o) => /decline|prefer not/i.test(o) && /decline/i.test(String(value)));
          if (opt) await loc.selectOption({ label: opt });
          else if (f.required) unanswered.push(label);
        } else if (f.type === "radio" || f.type === "checkbox") {
          if (value === undefined) { if (f.required) unanswered.push(label); continue; }
          const i = (f.options || []).findIndex((o) => o && o.toLowerCase().includes(String(value).toLowerCase()));
          if (i >= 0) await page.locator(`[data-fjh="${f.idx}_${i}"]`).check().catch(() => {});
          else if (f.required) unanswered.push(label);
        } else {
          if (value === undefined || value === "") { if (f.required) unanswered.push(label); continue; }
          await loc.fill(String(value));
        }
        await page.waitForTimeout(rnd(150, 450));
      } catch { if (f.required) unanswered.push(label); }
    }
    if (await detectCaptcha(page)) {
      await page.screenshot({ path: path.join(DATA, "cv", `${job.id}.apply.png`) }).catch(() => {});
      result.reason = "captcha"; result.note = "A CAPTCHA blocked the submit, finish this one yourself."; return result;
    }
    if (unanswered.length) {
      result.reason = "legal_question"; result.note = `Needs your answer: ${unanswered.slice(0, 3).join("; ")}`; return result;
    }
    await page.screenshot({ path: path.join(DATA, "cv", `${job.id}.apply.png`) }).catch(() => {});
    if (opts.dryRun) { result.status = "dry"; result.reason = ""; result.note = "Dry run, filled but not submitted."; return result; }
    let submitted = false;
    for (const t of ["Submit application", "Submit Application", "Submit", "Send application", "Apply"]) {
      try {
        const b = page.getByRole("button", { name: new RegExp(`^${t}`, "i") }).first();
        if (await b.isVisible({ timeout: 600 })) { await b.scrollIntoViewIfNeeded().catch(() => {}); await b.click(); submitted = true; break; }
      } catch { /* try next */ }
    }
    if (!submitted) { result.reason = "unknown_form"; result.note = "Could not find the submit button."; return result; }
    await page.waitForTimeout(rnd(3500, 5500));
    await page.screenshot({ path: path.join(DATA, "cv", `${job.id}.submitted.png`), fullPage: true }).catch(() => {});
    if (await detectCaptcha(page)) { result.reason = "captcha"; result.note = "CAPTCHA appeared at submit."; return result; }
    const url = page.url().toLowerCase();
    const after = (await page.locator("body").innerText().catch(() => "")).toLowerCase();
    const success =
      /thank you|thanks for applying|application (was )?(received|submitted|complete)|we('| ha)ve received your application|successfully (applied|submitted)|your application (has been|was) (sent|submitted|received)|application sent/.test(after) ||
      /success|confirmation|submitted|thank|applied/.test(url);
    if (success) { result.status = "applied"; result.reason = ""; return result; }
    const stillHasSubmit = await page.getByRole("button", { name: /^submit|send application/i }).first().isVisible({ timeout: 800 }).catch(() => false);
    const errText = /required|please (complete|fill|provide|enter)|must be|is invalid|fix the/.test(after);
    if (!stillHasSubmit && !errText) { result.status = "applied"; result.reason = ""; result.note = "Submitted (form cleared)."; return result; }
    if (errText) { result.reason = "legal_question"; result.note = "Form rejected with a validation error, needs your attention."; return result; }
    result.reason = "unknown_form"; result.note = "Submitted, confirmation unclear, please verify."; return result;
  } catch (e) {
    result.reason = "unknown_form"; result.note = String(e?.message || e).slice(0, 140); return result;
  } finally { await page.close().catch(() => {}); }
}
export async function apply(opts = {}) {
  const profile = await readJson(P.profile, {});
  const prefs = await readJson(P.prefs, {});
  const jobs = await getJobs();
  const dryRun = opts.dryRun ?? !prefs.liveApply;
  const threshold = prefs.autoSubmitMinScore ?? 4.0;
  const dailyCap = opts.limit ?? prefs.dailyCap ?? 12;
  const perCompanyCap = prefs.perCompanyCap ?? 2;
  if (profile.isDemo) return { skipped: true, reason: "demo profile, not applying for real" };
  const appliedToday = jobs.filter((j) => j.appliedAt && new Date(j.appliedAt).toISOString().slice(0, 10) === todayStr()).length;
  let budget = Math.max(0, dailyCap - (dryRun ? 0 : appliedToday));
  const perCompany = {};
  for (const j of jobs) if (j.status === "applied") perCompany[j.company] = (perCompany[j.company] || 0) + 1;
  const cleanRank = (j) => (j.ats === "ashby" || j.ats === "lever" ? 0 : j.ats === "greenhouse" ? 1 : 2);
  const candidates = jobs.filter((j) => ["cv_ready", "scored"].includes(j.status) && (j.score ?? 0) >= threshold)
    .sort((a, b) => cleanRank(a) - cleanRank(b) || (b.score ?? 0) - (a.score ?? 0));
  const filtered = opts.company ? candidates.filter((j) => j.company.toLowerCase().includes(opts.company.toLowerCase())) : candidates;
  const answers = buildAnswers(profile);
  const browser = await chromium.launch({ headless: !opts.headed });
  const context = await browser.newContext({
    userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    viewport: { width: 1280, height: 900 }, locale: "en-US",
  });
  const summary = { tried: 0, applied: 0, parked: 0, dry: 0, blocked: 0 };
  await appendActivity({ phase: "apply", level: "info", msg: dryRun ? "Apply (dry run): preparing applications" : "Apply: sending applications" });
  const maxTry = opts.limit ?? (dryRun ? 6 : filtered.length);
  for (const job of filtered) {
    if (summary.tried >= maxTry) break;
    if (!dryRun && budget <= 0) break;
    if ((perCompany[job.company] || 0) >= perCompanyCap) continue;
    summary.tried++;
    const r = await applyToJob(context, job, answers, { dryRun });
    if (r.status === "applied") {
      await updateJob(job.id, { status: "applied", appliedAt: Date.now() });
      await appendActivity({ phase: "apply", level: "success", company: job.company, jobId: job.id, msg: `Applied to ${job.company} · ${job.title}` });
      perCompany[job.company] = (perCompany[job.company] || 0) + 1;
      summary.applied++; budget--;
    } else if (r.status === "dry") {
      await updateJob(job.id, { status: "cv_ready" });
      await appendActivity({ phase: "apply", level: "info", company: job.company, jobId: job.id, msg: `Prepared (dry run) ${job.company} · ${job.title}` });
      summary.dry++;
    } else {
      await updateJob(job.id, { status: "needs_you", parkedReason: r.reason, parkedNote: r.note });
      await appendActivity({ phase: "park", level: "warn", company: job.company, jobId: job.id, msg: `Parked ${job.company}: ${r.note || r.reason}` });
      summary.parked++;
      if (r.reason === "captcha") summary.blocked++;
    }
    if (summary.blocked >= 4) { await appendActivity({ phase: "apply", level: "warn", msg: "Too many CAPTCHA walls in a row, pausing this run" }); break; }
    await sleep(opts.fast ? rnd(1500, 3000) : rnd(90000, 240000));
  }
  await context.close().catch(() => {});
  await browser.close().catch(() => {});
  await appendActivity({ phase: "apply", level: "success", msg: `Apply run done · ${summary.applied} sent, ${summary.dry} prepared, ${summary.parked} parked` });
  return summary;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
  const args = process.argv.slice(2);
  const limitArg = args.find((a) => a.startsWith("--limit="));
  const companyArg = args.find((a) => a.startsWith("--company="));
  const opts = { dryRun: !args.includes("--live"), headed: args.includes("--headed"), fast: args.includes("--fast") || !args.includes("--live"), limit: limitArg ? Number(limitArg.split("=")[1]) : undefined, company: companyArg ? companyArg.split("=")[1] : undefined };
  apply(opts).then((s) => console.log(JSON.stringify(s))).catch((e) => { console.error(e?.message || e); process.exit(1); });
}

Tell me what I should see: engine/apply.mjs and engine/render-pdf.mjs saved. Then say back to me, in plain words, the two safety rules: it never guesses legal answers, and it defaults to a dry run that never submits until live is on.

Step 7: Wire the engine into one cycle

This ties it together. One cycle drains your dashboard clicks, scans, scores, tailors, and kicks off applying. Stop and look. Once this saves, the whole brain is done. The hard part is behind you.

Prompt 7: the cycle runner and command queue
Keep working on FabJobHunter. Create two files exactly as below.

engine/commands.mjs (drains the dashboard's queued clicks)

import { pathToFileURL } from "node:url";
import fs from "node:fs/promises";
import { P, getJobs, saveJobs, appendActivity } from "./lib.mjs";
export async function processCommands() {
  let raw = "";
  try { raw = await fs.readFile(P.commands, "utf8"); } catch { return { processed: 0, scanRequested: false }; }
  const cmds = raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
  const pending = cmds.filter((c) => !c.done);
  if (!pending.length) return { processed: 0, scanRequested: false };
  let jobs = await getJobs();
  let scanRequested = false;
  for (const c of pending) {
    const jobId = c.payload?.jobId;
    if (c.type === "discard" && jobId) {
      jobs = jobs.map((j) => (j.id === jobId ? { ...j, status: "discarded", updatedAt: Date.now() } : j));
    } else if (c.type === "submit" && jobId) {
      const job = jobs.find((j) => j.id === jobId);
      jobs = jobs.map((j) => j.id === jobId ? { ...j, status: "applied", appliedAt: Date.now(), updatedAt: Date.now() } : j);
      await appendActivity({ phase: "apply", level: "success", company: job?.company, jobId, msg: `Marked applied: ${job?.company ?? ""}` });
    } else if (c.type === "scan_now") { scanRequested = true; }
    else if (c.type === "pause") { scanRequested = false; }
    c.done = true;
  }
  await saveJobs(jobs);
  await fs.writeFile(P.commands, cmds.map((c) => JSON.stringify(c)).join("\n") + "\n", "utf8");
  return { processed: pending.length, scanRequested };
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
  processCommands().then((r) => console.log(JSON.stringify(r)));
}

engine/cycle.mjs (one full cycle: commands, scan, score, tailor, record, apply)

import { pathToFileURL } from "node:url";
import path from "node:path";
import { spawn } from "node:child_process";
import { P, readJson, addRun, appendActivity, getJobs } from "./lib.mjs";
import { scan } from "./scan.mjs";
import { score } from "./score.mjs";
import { tailorAuto } from "./tailor.mjs";
import { processCommands } from "./commands.mjs";
export async function cycle({ force = false } = {}) {
  const cmd = await processCommands();
  const prefs = await readJson(P.prefs, { autonomy: false });
  if (!prefs.autonomy && !force && !cmd.scanRequested) {
    return { skipped: true, reason: "autonomy is off", commands: cmd.processed };
  }
  const startedAt = Date.now();
  await appendActivity({ phase: "system", msg: "Hunt cycle started" });
  const s = await scan();
  const sc = await score();
  const profile = await readJson(P.profile, {});
  if (prefs.autonomy && !profile.isDemo) {
    try { await tailorAuto(8); } catch { /* tailoring is best-effort */ }
  }
  const jobs = await getJobs();
  const cvs = jobs.filter((j) => j.cvPath).length;
  const applied = jobs.filter((j) => ["applied", "interview", "offer"].includes(j.status)).length;
  const parked = jobs.filter((j) => j.status === "needs_you").length;
  const run = { id: `run_${startedAt.toString(36)}`, startedAt, endedAt: Date.now(), boardsScanned: s.boards, newJobs: s.added, scored: sc.scored, cvsGenerated: cvs, applied, parked };
  await addRun(run);
  await appendActivity({ phase: "system", level: "success", msg: `Cycle done · ${s.added} new · ${sc.scored} scored` });
  if (prefs.autonomy && prefs.autoSubmit && !profile.isDemo) {
    try {
      const applyScript = path.join(import.meta.dirname, "apply.mjs");
      const args = prefs.liveApply ? ["--live"] : [];
      spawn(process.execPath, [applyScript, ...args], { detached: true, stdio: "ignore", cwd: path.resolve(import.meta.dirname, "..") }).unref();
      await appendActivity({ phase: "apply", level: "info", msg: prefs.liveApply ? "Starting live applications" : "Preparing applications (dry run)" });
    } catch { /* apply runner failed to spawn */ }
  }
  return { ...s, ...sc };
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
  const force = process.argv.includes("--force");
  cycle({ force }).then((r) => console.log(JSON.stringify(r))).catch((e) => { console.error(e?.message || e); process.exit(1); });
}

Also create engine/mark.mjs with a small command-line helper: node engine/mark.mjs cv <jobId> sets that job to cv_ready, node engine/mark.mjs applied <jobId> sets it to applied with appliedAt now, node engine/mark.mjs park <jobId> <reason> "<note>" sets it to needs_you with that reason and note. Use updateJob from lib.mjs.

Tell me what I should see: engine/cycle.mjs, engine/commands.mjs, and engine/mark.mjs saved. The whole engine is now done. Say that back to me so I know the brain is finished.

Step 8: The look (Sunlit Study design)

Now the face. This step sets the exact colors, fonts, and glass style. This one file makes the whole app feel warm and cozy instead of flat. Paste the colors word for word so it matches mine.

Prompt 8: the design tokens and fonts
Keep working on FabJobHunter, in the web/ app. Build the design system. It is a cozy lo-fi-anime theme called Sunlit Study: warm cream paper, soft sky blue, sage green, low saturation, never pure black or white, glass cards, big rounded corners, soft warm shadows.

1) web/app/fonts.ts, using next/font/google:
- display = Zen_Maru_Gothic, weights 500, 700, 900, subset latin, variable --font-display, a rounded warm face for headings.
- sans = Plus_Jakarta_Sans, subset latin, variable --font-sans, the body and UI font.
- hand = Klee_One, weights 400, 600, subset latin, variable --font-hand, a pencil accent.
Set preload false on the two Japanese-capable faces (display and hand) and give them safe fallbacks.

2) web/app/globals.css. Start with:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@custom-variant dusk (&:is(.dusk *));
Then map every token below into an @theme inline block (as --color-... and --radius-... and --shadow-... variables), and set headings to font-display in a base layer, body to font-sans on bg-background text-foreground.

Use these exact DAY values in :root:
--background: #fbf6ec; --foreground: #4a4137;
--card: #fffdf7; --card-foreground: #4a4137; --popover: #fffdf7; --popover-foreground: #4a4137;
--primary: #7faecd; --primary-foreground: #ffffff;
--secondary: #f3ebda; --secondary-foreground: #5a5040;
--muted: #f3ebda; --muted-foreground: #8a7e6c;
--accent: #9cb58a; --accent-foreground: #2e3a24; --destructive: #cf8f8f;
--border: #e7dcc6; --input: #e7dcc6; --ring: #8fb8d6; --radius: 1.2rem;
--rose: #e6a8a0; --sage: #9cb58a; --sage-ink: #5f7a4e; --sky: #8fb8d6; --warm-glow: #f5d9a8;
--grade-a: #6fae84; --grade-b: #8fbb73; --grade-c: #d7b766; --grade-d: #dca06a; --grade-e: #d68f7e; --grade-f: #c47e7e;
--sky-top: #b6dcf0; --sky-bottom: #ecf4f0; --sun: #f7e6b8;
--sidebar: #fffcf4; --sidebar-border: #ebe1cd;
--shadow-soft: 0 2px 8px rgba(122,102,70,0.08), 0 14px 34px rgba(150,122,80,0.1);
--shadow-card: 0 1px 2px rgba(122,102,70,0.05), 0 10px 26px rgba(150,122,80,0.09);
--glass-bg: rgba(255,253,247,0.62); --glass-border: rgba(255,255,255,0.55);

Add a cooler .dusk variant that overrides the same names with slightly cooler blue-grey values (background #e9edf2, foreground #3c4350, card #f6f8fb, primary #87b3d8, and so on), so a day/dusk toggle works.

Add two component classes in an @layer components block:
.glass { background: var(--glass-bg); border: 1px solid var(--glass-border); backdrop-filter: blur(16px); box-shadow: var(--shadow-card); }
.glass-strong { background: color-mix(in srgb, var(--card) 86%, transparent); border: 1px solid var(--glass-border); backdrop-filter: blur(20px); box-shadow: var(--shadow-card); }
Add a .cozy-scroll utility (thin scrollbars) and a .text-balance utility.

Add gentle keyframes named fjh-breathe, fjh-blink, fjh-tail, fjh-ear, fjh-cloud, fjh-mote, fjh-float, fjh-sun, fjh-wave, fjh-rise (small transforms, for the cat and the background). Respect prefers-reduced-motion by nearly disabling animations. Radii: give a scale from --radius-sm (0.5x) up to --radius-4xl (2.6x) off --radius.

Wire the fonts into web/app/layout.tsx: put the three font variables on the html element, keep font-sans on the body, and set the page title to "@FabRichhhhhh Job Hunter Bot".

Tell me what I should see: the app builds, the background is warm cream, headings use a rounded font, and a soft card style is ready to use. Then we place the actual cards and screens on top of this.

Step 9: The frame (nav, scene, and the cat)

This builds the shell around every page: the sidebar, the top bar, the soft background scene, and the little cat mascot. This is what makes it feel alive.

Prompt 9: app shell, cozy scene, and mascot
Keep working on the web/ app. Build the frame. Read web/node_modules/next/dist/docs/ first for this version of Next.js.

1) A base set of small shadcn-style parts in web/components/ui: button, card, badge, input, label, switch, slider, dialog, sheet, tabs, tooltip, select, textarea, separator, scroll-area, sonner (toaster), skeleton, avatar, progress, and a cn helper import from lib/utils. Build them on @base-ui/react and Tailwind, in the Sunlit Study palette. Add a GlassCard component that renders a div with the glass-strong class and rounded-3xl. Add a GradeChip component: a small rounded pill showing a letter A to F, colored from var(--grade-a) through var(--grade-f), with sizes sm, md, lg and a solid option. Add a NumberTicker that animates a number up on mount using motion.

2) web/components/scene/cozy-scene.tsx, a pure CSS and SVG backdrop with aria-hidden, fixed inset-0 at -z-10:
- a sky gradient wash from the top using --sky-top fading into --background,
- a warm sun glow blob top-right using --sun with the fjh-sun animation,
- a cooler glow bottom-left using --sky,
- three slow drifting SVG clouds using the fjh-cloud animation,
- two potted plant SVGs on the bottom corners (pot in --secondary, leaves in --sage) with a gentle fjh-float,
- a handful of tiny warm dust motes drifting up with fjh-mote,
- a soft vignette. Keep it low-contrast so text stays readable.

3) web/components/scene/mascot.tsx, a cute cream cat drawn as inline SVG (viewBox 0 0 100 116), with: a soft floor shadow, a tail that sways with fjh-tail, a body that breathes with fjh-breathe, two front paws, a round head with two ears (rose inner ears), cheeks, eyes that either blink with fjh-blink or show happy closed arcs when mood is happy or sleepy, a nose and mouth, whiskers, and a tiny sage sprout on the head. Props: size, mood ("idle" | "happy" | "sleepy" | "working"). When happy, add a small waving paw with fjh-wave. Randomize the blink delay after mount so it is not identical every instance.

4) web/components/app-shell.tsx, a client component wrapping every page:
- A left sidebar, 256px wide, glass-strong, rounded-[26px], hidden on mobile. At the top a brand row: a rounded tile holding the Mascot, then "@FabRichhhhhh" in the display font with "Job Hunter Bot" under it. Then a nav with six links, each a lucide icon plus label: Dashboard (LayoutDashboard, "/"), Job Scan (Radar, "/scan"), Applications (Layers, "/applications"), Resumes (FileText, "/resumes"), Companies (Building2, "/companies"), Settings (Settings, "/settings"). The active link gets a soft sky tint. Show a small count badge on Job Scan (new jobs), Applications (needs-you jobs, in rose), and Companies (enabled count). At the bottom an agent-status box: a pulsing dot when autonomy is on, the words "Agent active" or "Agent paused", and a line like "Scanning, next scan in 30m".
- A sticky top bar in a glass pill: a status chip, then on the right an Autonomous or Paused toggle, a "Run hunt" button with a Play icon (it POSTs to /api/run-cycle and shows a toast), and a day/dusk toggle (Sun/Moon) that toggles a "dusk" class on the html element and saves the choice to localStorage.
- A mobile bottom nav with the same six icons.
- Read all live data from a useSnapshot hook (built next step). When autonomy is on, set an interval that POSTs /api/run-cycle every prefs.scanIntervalMin minutes.

Tell me what I should see: a warm page with a cozy sidebar, a cat in the corner of the brand, a top bar with Run hunt and a day/dusk switch, and a soft animated background. The six links should route to empty pages for now.

Step 10: The Dashboard screen

This is the home screen from the video. The welcome, the three big numbers, the live feed, and the Needs You tray. It looks fancy, but it is one prompt. It reads everything live from the files.

Prompt 10: the dashboard and its cards
Keep working on the web/ app. First build the live layer, then the dashboard.

Live layer:
- web/lib/use-live.ts: a useLive(initial) hook that starts from the server snapshot, then opens an EventSource on /api/stream and updates state on "snapshot" and "update" events. Add a useNow(30s) hook for relative times. Add sendCommand(type, payload) that POSTs /api/command, and savePrefs(patch) that POSTs /api/prefs.
- web/components/live-provider.tsx: a LiveProvider that runs useLive and exposes it through a useSnapshot() context hook.
- web/lib/format.ts: timeAgo(ts), clockTime(ts), a GRADE_COLOR map, a STATUS_LABEL map, a PARK_REASON_LABEL map (captcha to "CAPTCHA wall", workday to "Workday maze", account_wall to "Account required", legal_question to "Needs a legal answer", email_verification to "Email code wall", ghost to "Possible ghost job", unknown_form to "Unusual form"), a money(n, currency) helper, and a KANBAN_COLUMNS array: New (new), Scored (scored), CV Ready (cv_ready), Needs You (needs_you), Applied (applied + responded), Interview (interview + offer), each with an accent color.
- web/app/layout.tsx: make it an async server component that reads getSnapshot(), renders CozyScene, wraps children in LiveProvider then AppShell, and mounts a bottom-right Toaster. Set export const dynamic = "force-dynamic".

Dashboard, web/app/page.tsx (a client component using useSnapshot):
- A glass hero card. A greeting by time of day, then a big heading "Welcome back, {first name or there}". A line: "Claude is hunting across {N} companies for you. {X} applications sent" and, if any are parked, ", {K} waiting for your tap" in rose. On the right, the Mascot (mood happy when nothing is parked, idle otherwise) and a large solid GradeChip of today's average fit with the label "today's fit".
- A row of three StatCards, each a glass card with an icon tile, a big NumberTicker value, a label, and a small hint:
  1. Radar icon, "Jobs scanned today" = jobs discovered in the last 24 hours (fall back to total), accent --sky, hint "{boards} boards swept".
  2. Send icon, "Applications sent" = jobs in applied, responded, interview, or offer, accent --grade-b, hint "{K} need your tap" or "all caught up".
  3. CalendarCheck icon, "Interviews" = jobs in interview or offer, accent --grade-a.
- A two-thirds column with an ActiveHunt card (a Radar title, a progress bar of scored-of-total, and the top 4 companies by best score, each with a GradeChip, title, a small match-percent bar, and the percent) and a "Live activity" glass card holding an ActivityFeed.
- A one-third column with a TopMatches card (top 5 by score, each linking out to the job url, with a GradeChip and match percent) and, if any jobs are parked, a rose-tinted "Needs you" card listing them with an Open link and a "Mark applied" button that calls sendCommand("submit", { jobId }).
- ActivityFeed: newest first, each row an icon by phase (scan Radar, score Sparkles, cv FileText, apply Send, park TriangleAlert, interview CalendarCheck, system Cog), tone by level (info sky, success sage, warn rose, highlight grade-a), the message, and a relative time. Animate new rows in with motion, and ring the newest one.

Tell me what I should see: a real dashboard that matches a warm welcome, three big animated numbers, a live feed, top matches, and a Needs You tray if anything is parked. It should update on its own within a second when the files change.

Step 11: The other five screens

Now the Job Scan, Applications, Resumes, Companies, and Settings pages. Five screens sounds like a lot, but each one is short. The prompt spells out the exact copy and layout, so they match mine.

Prompt 11: scan, applications, resumes, companies, settings
Keep working on the web/ app. Build these five pages, each a client component using useSnapshot, each wrapped in a small PageHeader (a display-font title, a muted subtitle, and an optional action button). Match the copy exactly.

1) web/app/scan/page.tsx, title "Job Scan", subtitle "Claude sweeps public ATS boards directly. No scraping, no ban risk, zero token cost." A "Scan now" button that calls sendCommand("scan_now"). Three glass stat cards: "Boards swept", "Roles found", "New this cycle". Then two columns: a "Sources" list of enabled companies (a sage dot, the name, the ATS label, and how many roles each found) and a "Latest finds" grid of the 18 newest jobs as JobCards, with a "last sweep {time} ago" line.

2) web/app/applications/page.tsx, title "Applications", subtitle "Every role the hunt found, moving from discovery to interview." A horizontal kanban board using KANBAN_COLUMNS. Each column is 280px wide with a header (a colored dot, the label, a count) and a scrollable stack of JobCards for the jobs in that column's statuses, sorted by score. Show "nothing here yet" in empty columns.

3) A JobCard component, web/components/job-card.tsx: a glass button that opens a right-side Sheet. The card shows a GradeChip, the title, "{company} · {location}", the match percent, and small pills for the archetype and any park reason. The Sheet (JobDetail) shows the grade, title, meta pills (match percent, archetype, salary if any, status), a legitimacy banner, a "Why this fits" list of reasons with sage check rows, a "Watch-outs" list of gaps with rose rows, a park note if parked, an "Open posting" link, a "View tailored CV" link to /resumes?job={id} if a CV exists, and "Mark applied" plus "Not for me" buttons that call sendCommand("submit"...) and sendCommand("discard"...).

4) web/app/resumes/page.tsx, title "Resumes", subtitle "Your base CV, plus a version Claude tailored to each strong match, ATS-tuned and never fabricated." A left list of buttons: "Base CV" plus one per tailored job (company name, role, GradeChip). A right glass panel with a meta strip (GradeChip or file icon, "Tailored for {company}" or "Base CV", "{role} · {match}% match", an "ATS-tuned" badge) and the CV rendered from markdown via react-markdown, fetched from /api/cv?id={id or base}. Read the selected id from the ?job= query on load.

5) web/app/companies/page.tsx, title "Companies", subtitle "Claude checks these boards every cycle. {enabled} of {total} active." An "Add company" dialog (name, an ATS choice of Greenhouse, Lever, or Ashby, and a board token) that POSTs to /api/companies. A grid of company cards, each with a colored initial tile (tint by ATS: greenhouse sage, lever sky, ashby rose), the name, the ATS label, a Switch to enable or pause it (POSTs the toggle), a "{n} roles found" line, and a careers link.

6) web/app/settings/page.tsx, title "Settings", subtitle "The control room. How autonomous Claude is, and the truthful answers it fills from, never guesses." Sections in glass cards:
- Autonomy: three toggles ("Let Claude hunt on its own", "Auto-submit the clean ones", "Really send applications (live)" with a clear warning that off means a dry run that never submits). Sliders for "Only auto-submit at or above" (3 to 5, shows a grade), "Daily application cap" (1 to 30), "Per-company cap" (1 to 5), and a segmented "Scan every" (15m, 30m, 1h, 2h). Each writes with savePrefs.
- Your profile: full name, email, location, headline, target roles (comma separated), salary floor, target max. A Save button POSTs /api/profile with isDemo false and onboarded true.
- Truthful answers: work authorized (yes/no), needs sponsorship (yes/no), notice period days, relocation, salary expectation, start date, how did you hear, criminal disclosure, and four EEO dropdowns (gender, race, veteran, disability) that all default to "Decline to self-identify". A note: "Claude fills these into forms verbatim and never invents them. If a form asks something not covered here, it parks that job for you."
- Base CV: a big textarea to paste your CV, saved via POST /api/cv with id "base". Note that it tailors a copy per role and never edits your original.
- Dataset: a Real hunt vs Demo toggle that POSTs /api/snapshot to switch what the dashboard shows (for filming).

Tell me what I should see: five working screens that match those titles and subtitles, a kanban board, a resume viewer, a company grid with toggles, and a full settings page.

Step 12: Make it live (the wiring)

This connects the buttons to the engine and streams the files into the screen. It is the last big piece, and the prompt handles it all. After this, the dashboard updates by itself as the bot works.

Prompt 12: SSE stream and API routes
Keep working on the web/ app. Build the API routes. Every route sets export const dynamic = "force-dynamic" and export const runtime = "nodejs". Read the App Router route-handler docs in web/node_modules/next/dist/docs/ first.

1) web/app/api/stream/route.ts, a Server-Sent Events stream. It sends the current snapshot once, then every 1 second it checks statSig() (the mtime and size of the flat files) and, when anything changed, sends a fresh "update" snapshot. Send a comment ping every 15 seconds to keep the connection alive, and clean up the timers on abort. Core shape:

import { getSnapshot, statSig } from "@/lib/data";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export async function GET(req: Request) {
  const encoder = new TextEncoder();
  let poll, beat, lastSig = "";
  const cleanup = () => { if (poll) clearInterval(poll); if (beat) clearInterval(beat); };
  const stream = new ReadableStream({
    async start(controller) {
      const send = (event, data) => { try { controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); } catch { cleanup(); } };
      lastSig = JSON.stringify(statSig());
      send("snapshot", await getSnapshot());
      poll = setInterval(async () => { try { const sig = JSON.stringify(statSig()); if (sig !== lastSig) { lastSig = sig; send("update", await getSnapshot()); } } catch {} }, 1000);
      beat = setInterval(() => { try { controller.enqueue(encoder.encode(`: ping\n\n`)); } catch { cleanup(); } }, 15000);
    },
    cancel() { cleanup(); },
  });
  req.signal.addEventListener("abort", cleanup);
  return new Response(stream, { headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no" } });
}

2) web/app/api/run-cycle/route.ts, a POST that runs one real cycle in the background so the "Run hunt" button and the auto interval both work without a terminal. Use a time-based lock file at data/.cycle-lock: if it was touched under 6 minutes ago, return busy. Otherwise write the lock and spawn node engine/cycle.mjs --force detached from the repo root, then return started.

3) The rest, small POST and GET handlers over web/lib/data:
- /api/command POST: validate the type against the allowed command list, then queueCommand(type, payload).
- /api/prefs POST: merge the patch into current prefs and savePrefs.
- /api/profile POST: saveProfile the body.
- /api/companies POST: handle { add } (append a company), { toggle: { name, token, enabled } } (flip enabled), then saveCompanies.
- /api/cv GET ?id= returns the markdown at data/cv/{id}.md (or base.md) as text; POST { id, markdown } writes it.
- /api/snapshot POST { name } runs the demo seeder for "demo" or clears to the real profile for "real".
- /api/state GET returns getSnapshot() as JSON.

Also create a Claude Code command at .claude/commands/hunt-cycle.md that tells this session to run one cycle: run node engine/cycle.mjs, then read the newly scored A and B jobs and refine their reasons and score with real CV-vs-JD judgement, tailor a CV for strong fits, and apply with the self-review gate, but if profile.isDemo is true never really submit. One cycle, then stop.

Tell me what I should see: the "Run hunt" button starts a cycle, and the dashboard updates on its own within a second when the engine writes a file. That is the live loop working.

Step 13: Seed real jobs and run it

Last step. This fills the dashboard with real jobs from real boards so you can look around, then starts everything. This is the moment it comes alive.

Prompt 13: the seeder and the run commands
Keep working on FabJobHunter. Build the demo seeder, then run everything.

Create engine/seed.mjs. It fetches REAL current postings from public feeds, scores them with a simple keyword heuristic, spreads them across the pipeline (a few interviews, some applied, a few parked, some cv_ready, the rest scored), writes a demo profile flagged isDemo true, writes prefs, companies, jobs, runs, an activity log, and a base CV, all into data/. Use these real boards:

Greenhouse tokens: Anthropic (anthropic), Vercel (vercel), Airtable (airtable), Temporal (temporal), Arize AI (arizeai), Glean (gleanwork), Speechmatics (speechmatics), PlanetScale (planetscale), Hightouch (hightouch), Runway (runwayml), Wayve (wayve), Stability AI (stabilityai). Fetch each at https://boards-api.greenhouse.io/v1/boards/<token>/jobs and read data.jobs (title, absolute_url, location.name, first_published).
Lever tokens: Mistral AI (mistral), Spotify (spotify). Fetch each at https://api.lever.co/v0/postings/<token>?mode=json (text, hostedUrl, categories.location, createdAt).
Keep only titles matching AI, ML, agent, platform, solutions, automation, product, or similar; drop intern, junior, android, ios, embedded. Give the demo profile placeholder details (fullName "Your Name", email "you@example.com", location "Remote / Worldwide", target roles like "AI Engineer", "Solutions Architect", "Automation Engineer"), comp in USD, legal workAuthorized true and requiresSponsorship false, isDemo true. Write a simple base CV at data/cv/base.md with a "## Summary" and a "## Experience" heading so the tailor has something to reshape. The companies list is those same Greenhouse and Lever entries, all enabled.

Then run these, in order, and tell me what I should see after each:
1) npm install   (installs the engine)
2) npx playwright install   (gets the browser for applying)
3) npm --prefix web install   (installs the dashboard)
4) node engine/seed.mjs   (fills data/ with real demo jobs)
5) npm --prefix web run dev   (starts the dashboard)
Then I open http://localhost:4319 and I should see a full dashboard: real companies, scored roles, a live feed, and a Needs You tray.

Do not turn on real applying yet. The demo profile blocks real submits on purpose. Confirm that back to me.

How you actually use it, day to day

The build is done. Here is your new normal. It is simple.

Open the dashboard at localhost:4319. Go to Settings. Paste your real CV in the Base CV box. Fill your profile and your target roles.

Fill the Truthful answers next. Work authorization, sponsorship, notice period, salary. The bot only fills these from what you type. It never guesses. If a form asks something you did not answer, it parks that job for you.

Now set the dials. Turn on autonomy. Set your daily cap and your minimum score. Leave live sending off at first, so it does a dry run. Watch it fill forms without sending. When you trust it, turn live on.

In your Claude Code session, run one cycle any time with /hunt-cycle. Or let it run on its own with the loop below. Then go live your life and check the feed later.

let it hunt on a timer (paste in the terminal)
/loop 30m /hunt-cycle

You are not job hunting anymore. You are running a hunter. It finds the roles, writes the resume, and applies to the clean ones. You wake up, tap the Needs You tray, and go to interviews. Same Claude those tools rent to you, minus the markup.

If it breaks

  • The dashboard will not open on 4319: another app may hold the port. Close it, or run npm --prefix web run dev again and use the port it prints.
  • No jobs show up: your companies may be off, or a board token is wrong. Check the Companies page and make sure a few are enabled.
  • Almost everything gets parked: that is normal. CAPTCHAs and login walls block bots on purpose. A third to a half going hands-free is a good day. Tap the rest.
  • It applied to nothing: autonomy may be off, or you are still on the demo profile, or live sending is off. Demo and dry run both block real submits by design.
  • Your bill looks high: make sure ANTHROPIC_API_KEY is not set, and never run it headless. It stays free by running inside your interactive Claude session.
  • The browser step fails: run npx playwright install once so the browser is ready.
  • The tailored resume looks generic: add your real CV in Settings. It can only reshape what you give it. It never invents experience.
Do I need to know how to code?
No. You paste each prompt in order and Claude builds that part. If a step looks hard, that is the point. The prompt does the work for you.
Is this safe for my accounts?
It applies on company career sites, never on LinkedIn. It paces like a human, caps how many it sends, and stops at the first wall. That is on purpose, to keep your accounts safe.
Does it lie on my resume or forms?
No. Tailoring only reshapes your real CV. It never adds fake experience. And it never guesses your visa, salary, or legal answers. It fills them from your profile or parks the job.
Will it really apply to everything by itself?
No, and nothing can. CAPTCHAs are built to block bots. It auto-sends the clean forms, which is roughly a third to a half, and hands you the rest for a quick tap.
Do I need API keys or a cloud account?
No. It runs fully on your own laptop, on your Claude plan. No API key, no server, no card. Nothing leaves your machine except reads of public job pages.
How much does it cost to run?
Just your Claude plan. Max is best if you want it hunting all day. Pro works for light use. There is no extra per-apply cost.
Why does it skip LinkedIn?
On purpose. It applies through company job boards, which is safer and does not risk your LinkedIn account. The reel title is a joke. It replaces the LinkedIn grind, it does not touch your login.
This still feels too advanced. What do I do?
Install the Claude desktop app, make the folder, and paste my prompts one by one. Each one builds a piece of my app and tells you what you should see. You are never left guessing.

This free guide is the what. If you want the how, done with you, step by step, that is my Claude AI Agents course. You build agents that do real work while you sleep, like this one. Want everything, agents, apps, and automations in one place? That is All-Access. And if you want me to build your first agent with you, live, I take a few 1:1 people each month.

Want the full system?

Claude AI Agents

See the course
The King tier

All-Access. Every course, free, forever.

Own every course we have, plus every single future course we ever make. One payment. Yours for life. Never pay for a Claude course again.

  • Every course, today and forever
  • Every future course free, the day it drops
  • What you own keeps growing, you never pay again
  • Lifetime access and updates
Save 79%

Years of courses, worth $1,417 today and climbing

$299

Lock it for life. Every future course is free, so you never pay more, ever.

Get All-Access
The inner circle

1:1 with Zaid. Direct access while you build.

The highest level. A direct line to me and a live call every week. I help you learn and build your own project, step by step. You do the work, I guide you the whole way.

  • A live call with me every week
  • A direct line to me, I reply on weekdays
  • A room of other serious builders
  • I follow you back and reply on Instagram

from

$1,499

Only 2 of 10 seats left this month.

Apply for 1:1