Skip to content

Latest commit

 

History

History
239 lines (179 loc) · 10 KB

File metadata and controls

239 lines (179 loc) · 10 KB

JobTrack

A personal job-search system: tailor your resume to any job description, discover remote startup internships automatically, autofill application forms, and keep every application in one pipeline.

Built for one user (you). One password, one database, no multi-tenancy.


What it does

Feature How it works
1 Resume tailoring Upload your master resume once. Paste a JD, get a version rewritten for that specific role — with a before/after match score, the keywords it surfaced, the gaps you genuinely have, and a diff of every change.
2 Assisted apply A local Playwright agent opens the posting in your own browser, fills every field it recognises, attaches the tailored resume, and hands it to you to submit.
3 Application tracking Kanban pipeline across ten stages, timeline per application, automatic follow-up reminders, interview-rate stats. Agent submissions log themselves.
4 Job discovery Nightly pull from RemoteOK, Remotive, Arbeitnow, HN "Who is Hiring" and 29 startup ATS boards, then scored against your resume by an LLM against saved criteria.
5 CSV outreach Write the mail once as a template, upload a CSV of recruiters (Company, Recruiter, Email), and get one mail per row with your resume PDF attached. Flip the tailor toggle on and the model adds two or three lines about each company. Read and edit every draft, then send from your own address — throttled and capped. A live preview box shows the exact subject, body and attachment before you import anything.

The honest bit about auto-apply

LinkedIn is fill-only and always will be. LinkedIn's User Agreement §8.2 prohibits automated interaction, and their detection is good. A permanent account restriction in your final year costs far more than the seconds saved. So the agent opens the posting in your logged-in browser, fills what it can, and stops — you read it and click Submit.

Greenhouse, Lever and Ashby can be fully automated if you want. These are the employer's own application forms, with stable field names and no bot detection. Set AUTO_SUBMIT_ATS=1 in agent/.env and queue a task in AUTO mode. It ships off by default, because reading a form before submitting it is usually the right call.

There is no keyless "apply via API" for applicants on any major ATS — their public APIs are read-only for job data. Anyone claiming otherwise is driving a browser too.

The honest bit about cold outreach

Nothing sends without you reading it. Drafting and sending are separate steps. A contact has to be APPROVED — which means you opened the draft and cleared it — before the send route will touch it. There is no "import and blast" path, and that is deliberate.

Mail goes out over SMTP from your own address, not an ESP, so replies land in your inbox and the recipient sees a person. For Gmail that needs an App Password; your normal password will not authenticate.

Sends are spaced OUTREACH_SEND_GAP_MS apart (6s default) and hard-capped at OUTREACH_DAILY_CAP a day (40 default) across every campaign. Gmail's own ceiling is far higher, but volume is what gets a personal address flagged, and a burned personal address in your final year is expensive. Keep it low and keep the list short.

The mail is yours, not the model's. A campaign carries a subject and body template with {{placeholders}}{{firstName}}, {{company}}, {{role}}, {{ask}}, {{links}} — filled from each CSV row. With the tailor toggle off that is the whole mail: pure substitution, no model call, no cost, and every draft is ready the moment you import. With it on, the model writes two or three sentences about that specific company into the {{tailored}} slot and touches nothing else you wrote.

Your resume goes out as an attachment, byte for byte as you uploaded it on the Profile page. If a campaign says it attaches a resume and no file is stored, the send stops rather than quietly mailing a body that claims an attachment that is not there.

Those tailored lines are held to the same fabrication rules as the resumes: no invented skills or projects for you, and no invented facts about them either — no imagined funding rounds, no fake mutual connections, no "great speaking with you" on a first contact. If a CSV row carries nothing specific about the company, the lines talk about your own real work instead.

Where the resume tailoring will not go

The prompt forbids inventing employers, dates, degrees, metrics, and technologies. Because a prompt is not a guarantee, every generated resume also passes a deterministic fabrication check: each number and each distinctive term is matched back to your master resume, and anything ungrounded is flagged in red before you download it. Invented metrics are graded high severity — those are what sink you in an interview.

If the JD wants Kubernetes and you have never touched it, Kubernetes goes in "genuine gaps", not on your resume.


Setup

1. Database

Local development:

docker run -d --name jat-pg \
  --restart unless-stopped \
  -v jat-pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=jat -e POSTGRES_USER=jat -e POSTGRES_DB=jat \
  -p 55433:5432 postgres:16-alpine

Production: create a free Postgres at neon.tech and use its pooled connection string.

2. Environment

cp .env.example .env

Fill in:

Variable What it is
DATABASE_URL Postgres connection string
APP_PASSWORD The single password gating the whole site. Make it long.
SESSION_SECRET openssl rand -base64 32
AGENT_TOKEN openssl rand -hex 24 — the local agent uses this
OPENAI_API_KEY From platform.openai.com
OPENAI_MODEL Defaults to gpt-4.1
OPENAI_FAST_MODEL Defaults to gpt-4.1-mini, used for bulk job scoring

Optional, and only needed for CSV outreach:

Variable What it is
SMTP_HOST / SMTP_PORT smtp.gmail.com and 587 for Gmail
SMTP_USER / SMTP_PASS Your address, and a Gmail App Password — not your login password
SMTP_FROM Optional. Your Name <you@gmail.com> for a nicer From line. Defaults to SMTP_USER
OUTREACH_DAILY_CAP Hard ceiling on sends per day, all campaigns. Default 40
OUTREACH_SEND_GAP_MS Delay between sends. Default 6000

3. Install and run

npm install
npm run db:push     # create the tables
npm run db:seed     # default search criteria
npm run dev

Open http://localhost:3000, sign in with APP_PASSWORD.

4. First run, in order

  1. Profile → upload your fullest resume (PDF/DOCX) and fill in the autofill fields. Put everything in the resume — tailoring can only select from what is there.
  2. Find jobsPull new jobs. Takes ~2 minutes, brings in several thousand postings.
  3. Find jobs → adjust the search criteria, then Score against my resume.
  4. Tailor on anything shortlisted, or Queue it for the apply agent.

5. The apply agent

Browser automation cannot run on Vercel — it drives a real Chrome window on your machine.

cd agent
cp .env.example .env     # set APP_URL and AGENT_TOKEN
npm install              # also downloads Chromium
npm run login            # one-time: log into LinkedIn, session saved to agent/.auth
npm start

It polls the queue every 6 seconds and processes tasks as they arrive.


Deploying to Vercel

npm i -g vercel && vercel

Set the same environment variables in the Vercel dashboard, plus CRON_SECRET for the nightly ingest (vercel.json already schedules /api/cron/ingest for 03:00 UTC).

Then point the agent at production by setting APP_URL=https://your-app.vercel.app in agent/.env. The agent keeps running locally; only the web app is deployed.


Running costs

Only the OpenAI calls cost anything. Everything else is free tier.

Operation Model Rough cost
Parse master resume gpt-4.1 ~$0.02, once
Tailor one resume gpt-4.1 ~$0.03
Score a job posting gpt-4.1-mini ~$0.001

A heavy week — 50 tailored resumes, 500 jobs scored — lands around $2.

The keyword prefilter in src/lib/match.ts rejects obviously-irrelevant postings before they ever reach the model, which is what keeps scoring cheap.


Adding companies to watch

src/lib/sources/startups.ts holds the ATS board slugs. Take the last path segment of a company's careers URL and verify it returns jobs before adding it:

curl -s "https://api.ashbyhq.com/posting-api/job-board/SLUG" | head -c 200
curl -s "https://boards-api.greenhouse.io/v1/boards/SLUG/jobs" | head -c 200
curl -s "https://api.lever.co/v0/postings/SLUG?mode=json" | head -c 200

A wrong slug returns 404 or an empty list and silently wastes a request every night.


Layout

src/
  app/              pages and API routes
  lib/
    tailor.ts       the tailoring prompts and ground rules
    integrity.ts    deterministic fabrication check
    match.ts        LLM scoring of jobs that clear the saved searches
    ingest.ts       source orchestration
    criteria.ts     saved-search matching, shared by scoring and the jobs list
    sources/        one adapter per job source
    csv.ts          RFC 4180 parser + HR-sheet header mapping
    outreach-template.ts  the mail template, placeholders and sample preview (no db, no model)
    outreach.ts     the tailored-lines prompt and ground rules
    mailer.ts       SMTP transport, daily cap, send throttle
agent/
  src/index.mjs     poll loop
  src/fillers.mjs   per-ATS form fillers
tests/
  integrity.test.mjs
  csv.test.mjs
  template.test.mjs
  criteria.test.mjs
npx tsx tests/integrity.test.mjs   # fabrication-check tests
npx tsx tests/csv.test.mjs         # CSV parsing and contact-import tests
npx tsx tests/template.test.mjs    # outreach template filling, both toggle states
npx tsx tests/criteria.test.mjs    # saved-search matching rules

Job-Application-Tracker