| name | job-hunt |
| description | Run a lead-search pass for Nathan's 2026 job hunt — scan Watching companies, sweep open-market channels, validate URLs, body-extract comp/remote, dedup, append to applications.ods. Use when the user says "time for another pass", "next batch", "/job-hunt", or asks to find new job leads. ALWAYS use this skill BEFORE any tracker-write work — do not improvise per-session scripts. |
| tools | Read, Bash, Edit, Agent, WebFetch |
/job-hunt
Hybrid orchestration: bin/run-pass.py does the mechanical work (scan, validate, body-extract, append); Claude handles the judgment calls (open-market sweep via sub-agents, borderline candidate triage, final preview review before authorizing the write).
Quick start
python3 .claude/skills/job-hunt/bin/run-pass.py --no-liveness
python3 .claude/skills/job-hunt/bin/run-pass.py --candidate-file /tmp/openmarket-batch-N.json --live --confirm-write
Output (still produced even on --live):
/tmp/job-hunt-dedup-pairs.txt — feed this to sub-agents as their dedup list
/tmp/job-hunt-skip-companies.txt — companies with ZERO slots (at 3-cap / saturated / excluded); feed to every sub-agent with "skip these companies entirely"
/tmp/job-hunt-autoscan-companies.txt — companies ALREADY auto-scanned every pass (Watching tab + ATS registry); feed to every sub-agent with "don't SEARCH these — the pipeline already pulls their boards; spend your budget on companies NOT on this list." These may have open slots (unlike the skip list), so it's a don't-waste-effort hint, not a blacklist.
/tmp/job-hunt-agent-targets.txt — bespoke careers sites for Agent C (pre-filtered to companies with open slots)
/tmp/job-hunt-batch.json — rows that WERE written (post-write artifact for audit)
/tmp/job-hunt-dead.json — every non-written candidate-file row with its reason (early-filter drops, cached rejects, freshness deaths, classify rejects, late-cap). This is the single audit artifact: every row submitted via --candidate-file appears in either batch.json or dead.json — no log-grepping needed.
Verdict cache. Deterministic rejects (onsite / hybrid / non-US / clearance / liveness-expired) are cached in references/.verdict_cache.tsv (21-day TTL, keyed kind|slug|jid, or URL for kind=other) and skipped at step 3b on later passes — Watching/registry boards carry dozens of postings that otherwise re-reject identically every pass (52 CoreWeave alone, each costing a body fetch + Playwright render). State-dependent rejects (late-cap) and agent-data-dependent ones are never cached. After ANY change to the classifier in lib/extract.py, run with --no-verdict-cache or delete the cache file — stale cached verdicts will otherwise mask the fix.
Don't pipe the orchestrator through tail, head, grep, or any other line-buffering filter. The live write takes 5-8 minutes (sequential Playwright liveness + ATS re-validation + UNO append), and pipes buffer all output until the pipeline exits — you lose all visibility into progress and can't tell if it's stuck on a particular URL versus making slow-but-steady progress. Run the command bare (python3 .claude/skills/job-hunt/bin/run-pass.py …) so step markers like [1/6] Loading tracker state…, [4/6] Running Playwright liveness… print as they happen. If you need to background it (recommended for the live write), use Bash run_in_background: true and Read the output file directly — line-buffered Python output will appear as it's written.
Workflow (one full pass)
-
Bootstrap: run a fast Watching scan to produce the dedup pairs file that sub-agents need.
python3 .claude/skills/job-hunt/bin/run-pass.py --no-liveness — this produces /tmp/job-hunt-dedup-pairs.txt, /tmp/job-hunt-skip-companies.txt, /tmp/job-hunt-autoscan-companies.txt, and /tmp/job-hunt-agent-targets.txt, and shows you the Watching candidate count + AT3 list. Don't worry about the preview output here; it gets overwritten by the live run.
-
Spawn open-market sub-agents in parallel. Three agents on non-overlapping channels. All get /tmp/job-hunt-dedup-pairs.txt, /tmp/job-hunt-skip-companies.txt ("skip these companies ENTIRELY — they have zero open slots; any lead there is dropped on arrival"), /tmp/job-hunt-autoscan-companies.txt ("don't SEARCH these — Watching tab + ATS registry are already auto-pulled every pass; spend your budget on companies NOT on this list; it's a don't-waste-effort hint, not a blacklist"), the OUT-OF-SCOPE list (DevEx, security-platform-only, DB-internals, frontend, pure ML research), and the gates. (2026-06-10: before the skip file existed, Agent C spent 4 of its 5 leads on at-cap MongoDB. 2026-06-15: before the autoscan file existed, Agent D re-probed Cockroach/CoreWeave/Cribl/Vantage — all already in the registry.) Channels split per references/rules.md:
- Agent A — HN "who is hiring" (Algolia) / LinkedIn / RemoteRocketship / Greenhouse-stack keyword / Crunchbase recently-funded / Nonlinear Project (
jobs.nonlinearproject.com, recently-funded-startup aggregator across ~16K live pages — filter to in-spec infra/platform/SRE) / climate vertical.
- Agent B — YC (Work at a Startup) / Kubernetes-CNCF careers / Levels.fyi / 80,000 Hours / Climatebase / RemoteOK / EchoJobs / DevOpsJobs / Wellfound / StartupJobs / HN-jobs / healthcare vertical / OSS-SaaS rotation.
- Agent C — bespoke careers sites. Feed it
/tmp/job-hunt-agent-targets.txt (tracked companies whose careers site couldn't be auto-scanned — Elastic, MongoDB, Apple, Uber, Bloomberg/Avature, etc.). It renders each site directly and surfaces in-spec roles. Also sweeps board TYPES beyond Greenhouse/Ashby/Lever (Workday, SmartRecruiters, BambooHR, Rippling, native sites). Don't restrict the open market to one or two ATS vendors.
- Roles from non-scannable sites come back as
kind=other; the pipeline writes them as Verify (never asserts Remote on unverifiable data — see lib/extract/run-pass), so Nathan can confirm. Capture a specific, resolvable posting URL (not a listings index).
Run the sweep agents on Sonnet, not Opus. The sweep is search → filter → format, not deep reasoning, and the pipeline re-enforces every hard gate in code (Phase-1 metadata, body-classify, hybrid, comp, liveness), so an agent misjudgment is caught downstream rather than written. Pass model: 'sonnet' to each Agent call — it's ~5× cheaper per token at low quality risk. (Pro-plan limits are the constraint; a single pass on Opus agents has hit the cap mid-run, batch 33.)
Token discipline — land raw results to disk, process minimally with tooling. The cost hog is tool-result size, not call count: a WebFetch/curl of a full HN thread or LinkedIn/careers page can be 50-200K tokens if it lands in the agent's context. Tell each agent to curl -s <url> -o /tmp/agent-<X>-raw/<name>.html (or .json) and then extract just the fields it needs with grep/python/jq from disk — never read the whole page into the conversation. WebFetch is still allowed and sometimes necessary (the ATS list APIs have lied before — body says hybrid when the API says remote; comp visible only in the rendered UI), but reserve it for targeted small reads, not bulk listing pages. Agents should return only the extracted candidate rows, not narration.
Per-agent BUDGET CAPS — stay under the 5-hour Pro limit (Nathan accepts fewer results per pass in exchange). Put these hard limits in every sweep-agent prompt. The 2026-06-05 pass spent ~655K agent tokens (Agent B alone 351K / 692 tool calls / 104 min) — that's the kind of run that risks the limit. Bound each agent:
- Stop at 6–8 qualifying leads. Once an agent has 6–8 solid in-spec NET-NEW leads, it STOPS searching and writes its file. Do not exhaustively enumerate a channel.
- ≤ ~6 board/page pulls. Pick the highest-yield sources first; don't
curl every board on the list. A board that doesn't yield in-spec hits in its first scan is abandoned, not paged through.
- No deep pagination. HN "who is hiring" / RemoteOK / EchoJobs: scan at most the first 1–2 pages. The marginal lead on page 5 isn't worth the tokens.
- Quality > coverage. A pass that returns 8 strong leads across 3 agents (~150–200K total) is the target, not 25 marginal ones. Missing a lead this pass is fine — it'll resurface next pass.
- Optional: drop to 2 agents. If budget is tight, run only Agent A (open market) + Agent C (bespoke targets) and skip Agent B; or merge B's highest-yield channels (YC, RemoteOK, EchoJobs) into A. Fewer agents = fewer parallel token sinks.
- Write the output file INCREMENTALLY — crash-safe. Every sweep-agent prompt MUST instruct: "Write your full JSON array to
/tmp/openmarket-<X>.json after EACH qualifying lead (overwrite the whole file each time, not just at the end)." A transient API 500 can kill a sub-agent mid-run (2026-06-08: Agents A & B both 500'd after ~45–54 tool calls with NO file written, losing all work and forcing a from-disk salvage). Incremental writes mean a crash leaves a recoverable partial file instead of nothing. This is cheap (a few extra tiny writes) and the single most important resilience rule.
Recovering a crashed sub-agent — salvage, don't re-spawn. If a sweep agent returns an API Error: 500 (or any mid-run crash), do NOT immediately re-spawn it — that repeats all its tool calls and is the exact budget blowup the caps guard against. Instead: (1) check for its incremental /tmp/openmarket-<X>.json and use whatever it flushed; (2) if that's missing/empty, the agent's raw pulls are still on disk under /tmp/agent-<X>-raw/ — mine those directly (parse the Greenhouse/Ashby/RemoteOK JSON, apply the title-accept + remote-US prefilter + dedup, emit candidate rows). Re-spawn fresh ONLY if both the partial file and the raw dir are empty. (2026-06-08: A & B salvaged from /tmp/agent-A-raw/ and /tmp/agent-B-raw/; the pipeline re-enforces every gate downstream, so mined candidates are as safe as agent-emitted ones.)
-
Collect open-market hits into a candidate JSON file (/tmp/openmarket-batch-N.json) — list of dicts with at least company, title (EXACT, verbatim from the posting), kind (gh/ashby/lever/smartrecruiters/workday/recruitee/other), and slug. Combine all three agents' files into one before passing to --candidate-file. Do NOT fold Watching-scan survivors into this file — the live run re-scans Watching itself, so they'd be double-submitted (the orchestrator's intra-batch dedup catches it now, but it's wasted motion). Cross-agent duplicates are also deduped automatically — no need to hand-dedupe the merged file.
kind is the ATS VENDOR, never the employment type. It must be exactly one of gh / ashby / lever / smartrecruiters / workday / recruitee / other. Do NOT put "full-time", "remote", "contract", etc. in kind — those are not ATS kinds and break the re-pull. Determine kind from the posting URL host: boards.greenhouse.io / job-boards.greenhouse.io → gh (slug = the path segment after the host, e.g. …greenhouse.io/ujet/… → ujet); jobs.ashbyhq.com / api.ashbyhq.com → ashby; jobs.lever.co → lever; *.myworkdayjobs.com → workday; *.smartrecruiters.com → smartrecruiters; anything else (company-native site, aggregator like a YC posting) → other (slug = careers domain). When in doubt, other is the safe fallback — it gets written as Verify, not dropped. Worked example: a posting at https://job-boards.greenhouse.io/ujet/jobs/4664987005 → {"kind":"gh","slug":"ujet","jid":"4664987005"}, NOT {"kind":"full-time","slug":"4664987005"}. (2026-06-05: Agent B emitted kind:"full-time" on all 5 rows; had to be repaired from URLs pre-write.)
- Embedded-ATS trap → use
other. Some careers sites render an ATS widget inline (you'll see Ashby/Greenhouse JSON in the page source) but expose NO public board at the obvious slug, so the re-pull 404s and every role dies "board fetch failed". If the resolvable posting URL is the company's own domain (e.g. shopify.com/careers?ashby_jid=<uuid>) rather than jobs.ashbyhq.com/<slug>/…, the kind is other (slug = careers domain), NOT ashby/gh. The URL host is authoritative — inline ATS data in the page is not. Shopify specifically is always kind:"other" (the pipeline also force-overrides it via KNOWN_BESPOKE_KIND in bin/run-pass.py, but don't rely on that — tag it right). (2026-06-08: Agent C tagged 4 Shopify roles ashby/shopify; all died "board fetch failed" until resubmitted as other.)
jid is best-effort, not required. For all re-pulled ATS kinds (gh/ashby/lever/smartrecruiters), if the agent's jid is wrong/stale/omitted the pipeline repairs it by matching title against the freshly-pulled board (validate._resolve_id_by_title, strict near-exact match) and rebuilds url+id from board truth. So tell agents NOT to spend tool calls chasing a verified numeric/uuid id — emit company+exact-title+slug and let the pipeline resolve the id. This both cuts agent tokens and recovers leads that used to die as "id-not-in-current-board" (2026-06-03: agents fabricated ids for Braze/dbt/InStride/etc.; the repair recovered the live ones and correctly dropped the fabricated). The agent MUST get slug and title right; the id is disposable.
- Workday leads: do NOT hand-build the URL. Agents reliably get the
externalPath wrong (byte-exact location segment + case-sensitive site path), producing dead links / login-bounces / title drift (batch 33). Have the agent emit only kind: "workday", slug: "tenant|dc|site" (parse from the careers host + first path segment), and jid: "<req-id>" (the trailing _R…/_JR…/_REQ… token — copyable verbatim from a snippet). The pipeline's validate.resolve_workday looks up the canonical posting via the cxs search API by req-id, rebuilds url+title+location from API truth, and runs the hybrid/comp classify on the real body. A req-id that the board can't resolve (or a tenant that 403s the API, e.g. NVIDIA) is dropped, not written.
- comp: agent-supplied comp is NOT ingested — the pipeline takes comp only from the ATS API or the fetched posting body. (2026-06-11: an agent put a band inferred from other Vultr roles in
comp; the old backfill prepended it to the body, and a $130K-top role was written as $200K T2, past the $150K gate. Nathan: inference from other roles is not a reliable signal; path removed entirely.) Agents may still mention a band in notes for human context, but it never feeds the extractor. Rows whose API and body are both comp-silent come out Unspecified.
-
Live write directly. python3 .claude/skills/job-hunt/bin/run-pass.py --candidate-file /tmp/openmarket-batch-combined.json --live --confirm-write. The full pipeline runs (re-pull ATS boards, Playwright liveness, body-extract, classify, append). Post-write summary tells you the tier distribution and any rejected rows.
Don't pre-empt with a separate dry-run unless you've changed the classifier or are debugging a regression. The pipeline is proven; an extra preview pass just doubles the runtime. Nathan can audit the post-write /tmp/job-hunt-batch.json and /tmp/job-hunt-dead.json if anything looks off.
Hard gates (one-line each — full text in references/rules.md)
- Remote-US strict + Austin-TX exception ≥$200K. Ashby
workplaceType is authoritative; isRemote: true does NOT override workplaceType: "Hybrid".
- Top-of-band ≥ $150K (full-time) OR ≥ $150/hr top-of-band (fractional / part-time). Annual tiers: T1 ≥$250K / T2 $200-249K / T3 $150-199K. Fractional tiers mirror at the rate level (T1 ≥$250/hr / T2 $200-249/hr / T3 $150-199/hr); see
references/rules.md "Fractional / part-time roles". (All comp figures here are illustrative placeholders — set them to your own targets.)
- In-specialty. SRE/Platform/Infra/Cloud/DevOps/Observability/Reliability + AI-platform/ML-platform/data-platform adjacencies.
- Senior IC+ or mgmt-track. Staff/Sr Staff/Principal/Distinguished/Fellow/Tech Lead/Founding Engineer/Director/Sr Director/Head of/VP/EM in specialty.
OUT OF SCOPE (drop on title alone)
DevEx / Developer Experience / Developer Productivity Platform · Security-platform-only (Secrets Infrastructure, Security Analytics Infrastructure) · Pure data-engineering (ETL, dbt, analytics) · Database-platform-only (DB internals) · Robotics/IoT/embedded · Pure product/backend SWE · Pure ML research · Pure frontend · Pure data science.
When uncertain ("internal tools" or "business systems" or "IT engineering" flavor): DO NOT auto-reject. Propose with the concern in Cons.
Hybrid is a disqualification, NOT a Verify
When body says hybrid-without-full-remote-option, REJECT entirely. Verify is only for genuine ambiguity (dual-mode hybrid-or-fully-remote, tiered comp with explicit remote tier, Austin-TX exception, conditional-hybrid-for-SF-residents-only).
Reject markers: "hybrid work model … anchor days" · "X days a week in office" · "return to office" · "hub-based" · "must reside within commuting distance" · Ashby workplaceType: "Hybrid" + body silent.
Override (keep as Remote): "remotely unless [location] based" (Headspace pattern — conditional hybrid only for that location; Nathan in Austin is remote-eligible).
Column conventions (enforced by lib/append.py)
| Col | Convention |
|---|
| Tier | Integer 1/2/3 via cell.setValue(int). Never "T1"/"T2"/"T3" strings. |
| Salary | Numeric currency top-of-band OR literal "Unspecified". Never text ranges. For fractional rows: hourly_rate × 1000 (so $250/hr → 250000) — keeps the column comparable at ~half-time-FTE equivalency; exact rate is in Notes. |
| Remote | "Remote" or "Verify" only. Nathan writes Office/Hybrid manually. |
| Source Channel | Always "Claude Code" for skill-written rows (full-time and fractional). Fractional rows are identified by the "Fractional: $X/hr" prefix in Notes, not the channel. |
| Date Noted | Today's date YYYY-MM-DD. Don't copy from prior batches. |
| URL | Hyperlinked via TextField.URL insertContent. Plain setString doesn't make a clickable link. |
| Status | "Not Applied" for new rows. "Excluded-hybrid" for body-confirmed hybrid-mandatory roles (anti-rediscover marker). |
lib/append.py validates every row before write; refuses malformed rows.
Pipeline (8 steps; mechanical work the orchestrator does automatically)
- Load tracker state — dedup pairs, open-counts per company, excluded list.
- Watching scan + registry sweep + (optional) open-market merge → raw candidates.
- Watching scan reads the Watching tab. Supported ATS: Greenhouse, Ashby, Lever, SmartRecruiters, Workday (
*.myworkdayjobs.com), Recruitee — see lib/scan.ATS_KINDS.
- A native careers URL that isn't a recognized ATS host is resolved (
lib/scan.resolve_board, cached in references/.board_resolve_cache.tsv): fetch the page, detect an embedded board. Bespoke/JS-only sites that can't be resolved are written to /tmp/job-hunt-agent-targets.txt for the sub-agents — a non-ATS site is NOT a reason to drop a tracked company.
- Registry sweep scans
references/ats_registry.tsv — in-spec company boards NOT on the Watching tab. Same pipeline; disable with --no-registry.
- Early filter — intra-batch (company, title) dedup, then drop tracker dups, excluded companies, hard-drop companies already at 3-cap. No per-company input limit: Phase 1 (next step) is cheap enough to evaluate unbounded boards in microseconds. Then 3b: verdict cache — postings that deterministically rejected within the last 21 days (
references/.verdict_cache.tsv) are skipped before any fetch.
- API freshness — re-pull each ATS board, confirm IDs still present, check Ashby
publishedAt != null + isListed: true. Ashby UUIDs rotate within a single session (batch 22 had 8 rotated IDs in hours).
- Phase 1 metadata filter — pure metadata, no fetches: drops Ashby/Lever
workplaceType ∈ {OnSite, Hybrid}, non-US-only locations, off-spec departments (Solution Engineering, Sales, Marketing, Customer Success, Professional Services, Global Support). On a typical batch this catches ~80% of would-be rejections before any expensive step. See lib/scan.py:OFF_SPEC_DEPARTMENT_TOKENS.
- Playwright liveness on Phase-1 survivors only. A cheap GET precheck (
lib/liveness.precheck_url) runs first per URL: a confident expired (Greenhouse ?error=true redirect, careers-root bounce, 4xx≠403, dead-body phrase) short-circuits the browser, skipping a 5-15s render; SPA shells and live URLs fall through to the full render unchanged. Greenhouse dead jobs redirect to ?error=true (curl returns 200 anyway); Ashby/Workday SPAs always return 200, so they still need browser-render + Apply-button visibility. Sequential per references/ats-quirks.md. On a wedged-browser error (e.g. wifi roam → ERR_NETWORK_CHANGED, "interrupted by another navigation"), the runner tears down chromium, sleeps with exponential backoff, recreates, and retries the same URL (per-URL cap = 3 retries; final state is "wedge-retry-exhausted" if it can't recover) — see lib/liveness.py:_WEDGE_PATTERNS.
- Body-extract + classify — comp regex (7 patterns) + remote/hybrid classifier. The body-extract step catches conditional-hybrid carve-outs that metadata can't see.
- Late cap with quality ranking — per company, rank surviving RowSpecs by
(tier asc, salary desc) and keep top available_slots(company). This is where "best of N" decisions happen, with full tier and salary information. Past failure (batch 25): the only cap was scan-time first-N, so a $410K Sr Staff role got dropped in favor of two lower-comp Reddit roles; user had to manually swap.
Caps
- 3 open per company. The cap counts every row except the dead statuses (
Rejected/Declined/Ghosted/Closed/Skip/Defer/Follow-up/Excluded-*) — it's a blacklist, so non-canonical active statuses in the tracker (Interview, Screen, Applied?) correctly occupy a slot. See lib/dedup.py:CAP_DEAD_STATUSES.
- Those dead statuses reopen the slot.
- Dedup on (company, exact-role-title); different roles at the same company are independent.
Closed is the one status that does NOT dedup-block — a req that closed and later reappears is a genuinely new opportunity worth re-surfacing. Rejected/Skip/Defer (and the rest) still dedup-block: they reflect a decision about that specific role. See lib/dedup.py:DEDUP_IGNORE_STATUSES.
Files in this skill
.claude/skills/job-hunt/
├── SKILL.md # this file
├── lib/
│ ├── extract.py # comp regex (7 patterns) + remote/hybrid classifier
│ ├── dedup.py # tracker state loader + 3-cap enforcement
│ ├── scan.py # scanner: GH/Ashby/Lever/SmartRecruiters/Workday/Recruitee + native-URL resolver
│ ├── liveness.py # Playwright SPA-render + Apply-button check
│ ├── validate.py # API freshness + liveness orchestration
│ ├── uno_io.py # UNO context manager + cell helpers
│ └── append.py # row writer with convention enforcement
├── bin/
│ └── run-pass.py # orchestrator (hybrid: script does mechanics, Claude handles judgment)
└── references/
├── rules.md # 4 hard gates + OOS list + caps + hybrid markers (full text)
├── columns.md # tracker schema + write conventions
├── ats-quirks.md # Ashby SPA, Greenhouse ?error=true, Lever 'additional', etc.
├── ats_registry.tsv # in-spec company boards auto-scanned, separate from Watching tab
└── .board_resolve_cache.tsv # cache: native careers URL → detected ATS (kind:slug)
When to deviate from the orchestrator
The script handles the common case. Deviate when:
- A candidate's body has a conditional clause ("remotely unless SF based") and the classifier returns Verify but you can confirm Nathan is in Austin → manually override to Remote with a Cons note. (The classifier already handles this for the standard pattern; deviate only for novel phrasings.)
- The Watching scan returns 0 candidates and the open-market hits are also thin (<10 net-new) → automatically run a SECOND open-market pass with a different sub-agent (different channel emphasis, different stuck-point) before reporting back. This is pre-authorized.
- A company is at 2-cap and the new role is a clearly better match than one of the existing open rows → still respect the 3-cap (one batch in, one out is Nathan's call, not the agent's).
Dependencies
python3-uno (system package, comes with apt install libreoffice)
odfpy (pip install --user odfpy)
playwright (pip install --user playwright && python3 -m playwright install chromium)
soffice must be on PATH. The orchestrator picks a free TCP port in [2240, 2299] and a per-PID profile dir to avoid collisions with other concurrent UNO sessions.