In plain words:
- Beacon finds job postings for you, automatically, based on your own keywords.
- It checks each one and flags whether that company will actually sponsor a work visa (H-1B).
- Everything shows up in a Google Sheet — practical, right on your phone, no new app to learn.
- It runs entirely on your own computer. Private by design — nobody else ever sees your search.
| Beacon Tracking Sheet | SQLite Schema |
|---|---|
| Sample Company Data | Source: a real public Greenhouse job board |
|---|---|
(Company/job data above is from a live personal account — the Decision/My Decision columns shown are unset defaults, not personal choices. See docs/screenshots/README.md for the full redaction notes.)
The longer version: This is not a job site, a hosted service, or an app you sign up for. It's a free, open-source Python program you install and run yourself, entirely on your own computer. It discovers postings across the web, automatically screens out visa-sponsorship dead ends, scores fit against your resume, and surfaces everything in a Google Sheet you already know how to use — for pennies, because AI is only called when it's actually needed. Since it only ever runs on your own machine, nothing about your job search is ever captured, stored, or seen by anyone but you.
Tags: h1b visa-sponsorship job-search international-students opt stem-opt immigration career-tools claude-ai automation
If this saves you time, a ⭐ helps other visa holders find it too.
- H-1B holders, F-1 OPT/STEM-OPT students, and anyone else whose job search has to filter for visa sponsorship
- Anyone tired of discovering a sponsorship dead-end three paragraphs into a job description, after already spending time on it
- Job seekers in any keyword-matchable field, not just tech — the role/tech keyword lists are fully yours to redefine
- Anyone who doesn't want to spend money on AI unnecessarily
- Anyone who'd rather track their job search in their own private Google Sheet than log into yet another dashboard
- Builders curious what a real, production Claude Code build looks like end-to-end, bugs and all (see
RUNBOOK.md)
Every work visa holder should be able to find jobs suitable for them, and apply to companies that actually sponsor work visas, with real information about who has sponsored before. That's the point: less headache, less wasted time, more of your search actually going toward employers who'll consider you.
And this has to stay free for anyone to adopt without a second thought. It's already low cost today, under $1/month for typical personal use (see Savings for the real numbers) — but "low cost" still means worrying about a bill. The real goal, not built yet, is running visa and job-fit classification on open-weight models on your own laptop instead of a paid API at all: $0, permanently, so nobody has to think about AI spend to run their own job search.
If you're job-hunting on a visa (H-1B, OPT/STEM-OPT, or otherwise), you already know the real cost isn't finding job postings — it's the hours lost reading through postings that were never going to sponsor you in the first place, often three paragraphs deep in EEO boilerplate that never even mentions "visa" until the very last sentence.
Beacon was built to remove that specific waste, for close to $0:
- Never manually re-check "do they sponsor?" again. Every posting's own text is automatically screened for sponsorship language and labeled
Sponsored/No sponsor/No mention/Unclearbefore you ever open it. - You define what "relevant" means, not a hardcoded list. The role/tech keyword lists that decide what gets surfaced at all are stored in a database table, not buried in code — retarget it for your own field in the Sheet or the DB, no redeploy needed.
- AI is the last resort, not the first. See Savings below — the real numbers, not a guess.
- Runs unattended, three times a day, and lands everything in a Google Sheet — no new app to learn, no dashboard to check obsessively.
Fair question. LinkedIn Premium doesn't solve the one thing that costs visa holders the most time:
| LinkedIn Premium | Beacon | |
|---|---|---|
| Detects visa sponsorship | ❌ Not a feature — you read every posting yourself | ✅ Auto-classified, restrictions caught automatically |
| Cost | ~$30–$40/month, forever | Free software — real lifetime AI cost so far: $8.88 (see below) |
| Filter logic | Black-box "match" score | Every rule lives in a table you can read and edit |
| Privacy & tracking | Public "Open to Work" status; only sees LinkedIn's own Apply flow | Fully private, your own Sheet — tracks any job however you actually applied |
The short version: it replaces the one feature visa holders need most — "will this company even consider me" — at a fraction of a month's subscription.
Real numbers from this project's own history, not an estimate — software is free; the $8.88 is real AI usage paid directly to Anthropic, not a price for this project:
| Metric | Value |
|---|---|
| Total postings ever ingested | 137,318 |
| Actually reached your Sheet, worth your time (2.4%) | 3,355 |
| Caught as visa-restricted before you wasted time on them | 577 |
| Companies tracked | 2,674 |
| Total AI spend, ever | $8.88 |
Stays cheap because: company lookups only use free data sources (left blank if neither has an answer, never a paid fallback); resume-fit scoring only runs when you flag a job; and visa checks are mostly free text-matching — AI only steps in for the small remainder where the wording is genuinely unclear.
What that restriction language actually looks like — real phrasing caught in live postings, usually buried a few paragraphs in, not in the title:
"We are unable to sponsor H-1B, F-1 OPT, and STEM OPT extension at this time." "This role is not open to visa sponsorship." "This position requires a government security clearance; you must be a US citizen for consideration."
- Five job sources: broad keyword discovery via Adzuna, plus direct polling of Greenhouse, Lever, Ashby, and SmartRecruiters for companies you specifically track — no query-time filtering needed on the targeted side, every posting is pulled and filtered locally
- Self-onboarding companies: type a company name into a
SEEDrow on the Sheet and the pipeline guesses and verifies a real job-board slug across all four ATS platforms automatically — no manual API digging - Three-tier visa classification: regex → free keyword pre-check → Haiku, cheapest first
- Live-editable filter criteria: role/tech keyword include-lists, title excludes, seniority, location, posted-date window, company priority tier — all in a SQLite table, editable without touching code
- Fit scoring on demand, not by default: Claude scores a job against your resume only when you flag it
Go Scoreon the Sheet — never runs against the whole backlog automatically - Free company enrichment: employee count, funding stage, HQ, revenue — from two free-tier APIs only, with zero LLM fallback (a field just stays blank rather than ever costing money to fill in)
- US location resolution from messy free-text (
"US-CA-Menlo Park", bare city names, county-only strings) against public Census reference data — no geocoding API - AWS/GCP/Azure detection from posting text, with a refresh pass for postings whose source truncates the description
- Dead-link detection: postings that get pulled after you've already seen them get automatically evicted from the Sheet, not left as a broken link
- Google Sheets as the entire UI: notification, approval, and status tracking are all native Sheets features — no dashboard, no login, no separate app to check
- Runs unattended via a single locked, crash-recovering scheduler process — main pipeline 3x/day, fit-scoring and company enrichment on their own offset schedules, an approval poller every 30 minutes
- Visa fit — does this posting's own text rule out sponsorship? Only asked when free keyword/regex checks can't tell. (Posting text only, not company history — see the next section for that.)
- Job fit — does this posting match your resume? Only asked when you type
Go Scoreinto that row's My Decision cell.
A second, separate signal: real DOL government filing data on which companies have actually sponsored before — not another guess from posting text. A match means "Likely work visa sponsor," a positive historical signal, not a guarantee for any specific posting.
Manual by design (DOL blocks automated downloads): grab the quarterly .xlsx from the OFLC Performance Data page, then run python -m app.cli lca-enrich <path>. Runs alongside the Visa Flag check above, not instead of it — doesn't reduce its AI usage.
(Full details — file format, matching logic, multi-year merging, real test numbers — in job-search-app-technical-spec.md.)
Everything else, discovery, deduplication, keyword matching, location resolution, salary extraction, company enrichment, is plain code with zero AI involved.
Here's exactly how each of those two decisions gets made:
Every posting → keyword match (role/tech titles) → does it even mention "visa"/"sponsor"/"h1b"/etc?
│ │
no match → discarded, no → "No mention", $0, done
zero cost, zero AI │
yes → regex-confident phrase?
│ │
yes, $0 no → Haiku classifies
done (only the genuinely
ambiguous remainder)
Three tiers, cheapest first — a real posting only ever reaches an LLM if a human would also have to actually read the sentence carefully to decide. Everything upstream of that (does this even look like a role I want, does it even mention sponsorship at all) is free, deterministic Python.
What Haiku actually sees and returns, for that last genuinely ambiguous tier:
- Input: the job's title, its location, and up to 12,000 characters of the description (not just a short prefix — sponsorship language is legal/EEO boilerplate that's frequently near the end of a posting, and an earlier, shorter truncation really did cut off the deciding sentence in testing)
- Task: classify sponsorship specifically for this posting's own location, not sponsorship in general. A posting that says "we can sponsor visas to Germany" on a US-based role gets classified
restrictedfor the US, notsponsors, even though the word "sponsor" appears in a positive sentence right there in the text - Output: a forced-structure JSON response, exactly
{"visa_flag": "restricted" | "sponsors" | "unclear", "snippet": "..."}— never free-form text, and the snippet is a direct quote from the actual posting, not a paraphrase, so you can always see exactly which sentence drove the call - That result gets written straight to the job's row and, if it comes back
restricted, the posting is automatically evicted from your Sheet before you ever see it as a live match
The resume-fit scoring step (Claude Sonnet) never runs on its own. It's controlled entirely by a single Sheet column, My Decision, which is just a dropdown on each row. Go Score here means specifically "go score job fit" against your resume — it has nothing to do with visa fit, which is checked automatically and never needs a manual trigger:
| Value | Who sets it | What happens |
|---|---|---|
New |
App, by default | Nothing. This is every job's starting state — no AI has looked at it |
Go Score (i.e. "go score job fit") |
You | You're telling the pipeline "spend an AI call scoring this one against my resume." This is the only manual AI trigger anywhere in the pipeline — nothing happens to any other row |
AI Score Pending |
App | Claimed right before the Sonnet call, so a failure retries automatically instead of getting stuck or silently skipped |
AI Scored |
App | Done — the score is now on the row |
Manual Scored |
You | Your own judgment call, no AI involved at all |
Reject |
You | Removes the row, no AI involved |
There's no "score everything" button and no background job silently scoring your whole backlog — just this one dropdown, row by row.
flowchart TD
SCHED["Scheduler (single locked process, 3x/day + startup catch-up)"] --> SEED["Seed-via-Sheet: onboard new companies"]
SEED --> ING[Ingestion: Adzuna + Greenhouse/Lever/Ashby/SmartRecruiters]
ING --> DB[(SQLite: jobs, companies)]
DB --> FILTER[Filter Engine: keyword + location + seniority]
FILTER -->|passes| SHEET["Beacon Sheet: row added immediately"]
FILTER -->|fails| JOBLOG["Job Log Sheet: reason recorded"]
SHEET --> VISA[Visa Scanner]
VISA -->|regex, free| FLAG[Visa Flag set]
VISA -->|keyword pre-check, free| FLAG
VISA -->|genuinely ambiguous only| HAIKU[Claude Haiku]
HAIKU --> FLAG
FLAG -->|restricted| JOBLOG
SHEET -->|user flags 'Go Score'| FITSCORE[Claude Sonnet: fit score]
FITSCORE -->|below threshold| JOBLOG
FITSCORE -->|above threshold| SHEET
SHEET -.->|native Google rule| EMAIL[📧 Email notification]
SHEET -->|Decision column| POLLER[Approval Poller, every 30 min]
Full diagram with every field/table: docs/ARCHITECTURE.md
| Layer | Technology |
|---|---|
| Language | Python 3.14 |
| Scheduling | APScheduler (BlockingScheduler, single-instance file lock) |
| Database | SQLite, WAL mode |
| Tracking / UI / Notification | Google Sheets via gspread (service account auth) |
| LLM | Anthropic Claude — Haiku 4.5 (visa classification), Sonnet 5 (fit scoring) |
| Job sources | Adzuna API, Greenhouse, Lever, Ashby, SmartRecruiters (all public/free) |
| Company data | Financial Modeling Prep + StartupHub.ai + TinyFish Search (all free tier) |
| Location resolution | US Census county/place reference data (bundled, public domain) |
| HTTP | requests, with retry/backoff on rate limits and transient errors |
- Python 3.14 (or 3.12 if 3.14 wheels are unavailable for a dependency)
- A free Adzuna developer account
- A free Anthropic API key
- A free Financial Modeling Prep key and StartupHub.ai key
- Optional: a free TinyFish key — a third, best-effort industry source for companies the two above miss
- A Google Cloud service account (not OAuth) shared as an Editor on two Google Sheets you create yourself — one for active matches ("Beacon"), one for excluded jobs ("Job Log")
Don't have these yet? See the Appendix for step-by-step instructions for every single one.
git clone https://github.com/algoshank-pat/beacon.git
cd beacon
python -m venv .venv
# Windows:
.venv\Scripts\activate
# macOS/Linux:
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Open .env and fill in your own API keys and Sheet IDs
# Place your Google service-account JSON key at the path GOOGLE_SHEETS_CREDENTIALS_PATH points to
# Place your resume where fit-scoring looks for it (first match wins):
# resumes/base_resume.docx
# resumes/base_resume.md
# resumes/base_resume.txtFit-scoring needs your resume on disk to work at all. Without a file at one of those three paths, typing Go Score on a job will fail with a clear "no base resume found" error rather than silently doing nothing. This file is gitignored and never leaves your machine, same as everything else in resumes/.
python -m app.cli migrate # create the DB schema
python -m app.cli seed-filters --file seed_filters.yaml # load default filter criteria
cp seed_companies.example.yaml seed_companies.yaml # start your own target-company list
python -m app.cli seed-companies --file seed_companies.yaml
python -m app.cli pipeline # one manual end-to-end runTwo files decide what you actually see — edit these, not the code:
| File | What it controls | If you skip it |
|---|---|---|
seed_filters.yaml |
Every filter criterion below | The pipeline runs with the default keyword set checked into this repo (Solutions Architect/Presales/Integration-flavored) — edit this file to target your own field before your first real run |
seed_companies.yaml |
Specific companies to poll directly via Greenhouse/Lever/Ashby/SmartRecruiters, so you catch every one of their openings the moment it's posted | Nothing breaks if this is empty. Adzuna's broad keyword search (driven entirely by seed_filters.yaml) runs regardless and is often the majority of what you'll see — direct company tracking is a bonus layer on top, not a requirement. Add companies anytime later, including live via the SEED-row-on-the-Sheet trick (see below) |
Both are live-editable after setup too — the next scheduled run just picks up whatever's currently in the DB (seed_filters.yaml/seed_companies.yaml are only read at the moment you run seed-filters/seed-companies; ongoing changes happen by re-running those commands or editing the DB tables directly).
- On the Beacon sheet, add a new row
- Set that row's Job ID to
SEEDand Company to the name you want tracked — leave every other column blank - Wait for the next scheduled run, or trigger it yourself right away:
python -m app.cli seed-via-sheet - It guesses and verifies a real job-board slug across Greenhouse, Lever, Ashby, and SmartRecruiters, and writes the outcome into that row's Title cell — on success, the company is added and its real postings often start flowing in on that same run; on failure, the Title cell explains why
- Either way, the
SEEDrow itself is deleted automatically on the run after that — it was never a real posting, just a one-time onboarding request
Every filter criterion seed_filters.yaml actually supports today:
| Criterion | What it does |
|---|---|
role_keyword_include |
Job-title keywords you're targeting (e.g. "Solutions Architect") |
tech_keyword_include |
Skill/domain keywords (e.g. "Kafka", "iPaaS") — either list matching is enough to pass |
title_exclude |
Title-only blocklist (e.g. "Intern", "Director") |
seniority |
Only these seniority levels (e.g. mid, senior, staff) |
remote_type |
Only these work-location types (e.g. remote, hybrid) |
location_include |
Only postings resolved to one of these US states (2-letter code, e.g. TX, CA) or Remote-USA — matched against the clean location_state field (see app.location_state), not raw location text. A posting location_state couldn't resolve is passed through rather than filtered out, since a missing signal isn't evidence of a mismatch |
industries_include |
Only certain industries — a real hard filter, empty by default so it's a no-op until you populate it |
posted_within_days |
Drop anything older than this many days |
company_priority_min |
Only companies at or above this priority tier (S/A/B/C) |
employee_count_min / employee_count_max |
Only companies with a headcount in this range |
founded_after_year |
Only companies founded after this year (a rough startup-recency proxy) |
require_us_location |
Drop anything that doesn't look US-based |
require_visa_sponsorship |
Evict postings the Visa Scanner classifies restricted |
require_h1b_track_record |
Only companies with a confirmed historical H-1B track record (depends on the not-yet-built historical-data feature on the Roadmap) |
Not supported yet, genuinely missing today: filtering by salary range, and filtering by funding stage/company type (a real "startups only" filter, as opposed to the founded-year/headcount proxies above). Both are now on the Roadmap.
This is not architecturally US-only, even though it ships US-focused by default. Visa-sponsorship job hunting is a real problem everywhere, not just the US — Adzuna alone already covers the UK, Canada, India, Germany, Australia, and more. Today, though, the shipped defaults are US-only in practice: require_us_location: true is on by default in seed_filters.yaml, and the Adzuna integration (app/sources/adzuna.py) supports a country parameter but the actual pipeline call never passes it, so it's silently locked to country="us". Making country a real, configurable filter_settings value (instead of a hardcoded default a few layers down) is a small, concrete fix, now on the Roadmap.
| Download/install time | The repo itself is ~8MB (seconds to clone). pip install -r requirements.txt installs 10 lightweight dependencies — no ML/data-science stack — typically under a minute on a normal connection |
| Disk space | Starts near-empty; grows with how much you ingest. This project's own database reached ~490MB after several weeks of continuous 3x/day polling against 150+ tracked companies plus broad discovery — budget a few hundred MB to a couple GB for long-term personal use |
| Memory | The scheduler process itself runs at roughly 20-30MB RAM in practice (measured on this project's live process) — it's I/O-bound (waiting on API calls), not compute-heavy |
| CPU | Negligible — brief bursts during each poll, idle the rest of the time |
| Platform | Every CLI command (migrate, ingest, filter, pipeline, etc.) is plain cross-platform Python and runs fine on Windows, macOS, or Linux. app/scheduler.py (the persistent "run continuously" process) now also runs on all three — its single-instance lock picks msvcrt on Windows or fcntl.flock on macOS/Linux automatically (sys.platform check, no new dependency). Built directly against fcntl's documented locking semantics, not yet run live on a real Mac/Linux machine (this project's own daily use is Windows) — if you hit anything running it there, it's worth a bug report |
python -m app.schedulerRuns the main pipeline 3x/day (default 8am/1pm/6pm), fit-scoring and company enrichment on their own offset schedules, and an approval poller every 30 minutes — all inside one locked, crash-recovering process. See RUNBOOK.md for keeping it running across restarts on Windows.
beacon/
├── app/
│ ├── sources/ # Adzuna, Greenhouse, Lever, Ashby, SmartRecruiters pollers
│ ├── data/ # Bundled US Census reference data (public domain)
│ ├── migrations/ # Numbered SQL schema migrations
│ ├── sheets.py # Beacon sheet reads/writes
│ ├── job_log.py # Job Log sheet reads/writes
│ ├── filter_engine.py # Keyword/seniority/location filtering
│ ├── visa_scan.py # Three-tier visa classification
│ ├── fit_scoring.py # Resume-vs-JD scoring (Sonnet)
│ ├── enrichment.py # Free-only company data enrichment
│ ├── seed_via_sheet.py # Type-a-name-to-onboard-a-company
│ ├── scheduler.py # APScheduler process entrypoint
│ └── cli.py # Manual command-line entrypoints
├── tests/ # pytest suite (fakes for Sheets/HTTP, no live calls)
├── docs/
│ ├── ARCHITECTURE.md # Full system diagram + data flow
│ └── screenshots/ # README images
├── seed_companies.example.yaml # Safe template — copy to seed_companies.yaml
├── seed_filters.yaml # Default filter keyword/threshold seed data
├── .env.example # Safe credential template
└── RUNBOOK.md # Day-to-day operating guide
Never committed (see .gitignore): .env, service_account.json, job_search.db*, resumes/, scheduler.log.
- No credentials are hardcoded anywhere in the source — every secret is loaded from
.envor the service-account JSON path, both gitignored .env.exampleandseed_companies.example.yamlcontain placeholder/template values only- The live SQLite database (which contains real scraped postings and personal application decisions) and the real service-account key are never committed
- See
PUBLISHING_GUIDE.mdfor the full secret-audit checklist this repo was published against
Can you get me a job, or tell me which companies sponsor visas?
No. See the Disclaimer below — there's no personally curated company list anywhere in this project, and I'm not a recruiter or immigration attorney. Beacon's core signal reads a specific job posting's own text and classifies what that posting says, nothing more. It also optionally shows a "likely sponsors" flag from real DOL government filing data (see Real historical sponsorship data) — that's a positive historical indicator, not a guarantee about any specific role.
Can I use more than one resume?
Not yet — a real, known limitation. Fit-scoring always reads a single file (
resumes/base_resume.md, falling back to.txtthen.docx, first match wins), with no way to select a different one per job or per role type. If you want to score against different resumes for different kinds of roles, today's only workaround is manually swapping the file between runs.
Does this scrape LinkedIn?
No. LinkedIn's Terms of Service explicitly prohibit automated scraping, and Beacon deliberately doesn't touch it — every source here (Adzuna, Greenhouse, Lever, Ashby, SmartRecruiters) is a public, documented API meant to be queried programmatically.
Is this a website or a service I sign up for, and is my data private?
Neither, and yes. No server, no account, no sign-up — you clone the code and run it entirely on your own machine, using your own accounts for every external service it talks to. Nothing is sent anywhere except the API calls you configure yourself (Google Sheets, Anthropic, Adzuna, the ATS platforms, FMP/StartupHub/TinyFish), and none of those see anything beyond the single request you're making in that moment. Nobody, including whoever wrote this code, sees your search activity, your resume, or your decisions.
How much does it actually cost to run?
The software itself is free — there's no fee to download or use it. You bring your own Anthropic API key and pay Anthropic directly, only for what you actually use, at their normal rate. See Savings: this project's own real usage, across 137,000+ jobs processed over several weeks, totals $8.88 — that's what actual usage costs at this scale, not a fee anyone charges you.
Do I need to know Python to use this?
You need to be comfortable running a few CLI commands and editing a
.envfile (see Setup). Day-to-day use afterward is entirely in Google Sheets.
Can I use this for a non-tech job search?
Yes — nothing in the filter/keyword design is tech-specific. The example keyword lists target Solutions Architect/Presales-style roles because that's what this was originally built for, but every keyword, title exclusion, and threshold lives in an editable table, not code.
Why Google Sheets instead of a real dashboard?
Because you already know how to use it, it's free, it's already got notifications/mobile access/sharing solved, and it means zero UI code to build or maintain.
- Make
countrya real, configurable setting instead of a hardcoded default a few layers intoapp/sources/adzuna.py. Adzuna already covers the UK, Canada, India, Germany, Australia, and more, and this problem is not US-specific — today'scountry="us"default andrequire_us_location: truedefault are just unconfigured defaults, not an architectural limit - Batch Google Sheets writes (
append_rows+ in-memory duplicate-check) instead of one API call per job — the current per-job cost is what makes a large backlog slow - Automatic re-validation of jobs already on the sheet against later filter-criteria changes (today, only newly-ingested jobs are checked against the current rules)
- Resume/cover-letter generation handoff to Claude Desktop on Approve (designed, not yet built)
- Salary-range filtering — salary is extracted and shown today, but nothing actually filters on it yet
- A real "startups only" filter —
founded_after_yearandemployee_count_maxare rough proxies today;companies.funding_stage/company_typeare already enriched (series_a,private, etc.) but never wired into filtering at all - Actually download and feed in more than one fiscal year's LCA file —
lca-enrichalready accepts and merges multiple files (see the Real Historical Sponsorship Data section above), but only one quarter (FY2026 Q2) has been run against the live table so far - Automate the quarterly LCA re-check on a schedule once there's a reliable way past DOL's bot protection (today it's a manual download +
python -m app.cli lca-enrich, by design — see the Real Historical Sponsorship Data section above) - Resume gap analysis and tailored-resume generation against your master resume, going beyond today's numeric fit score to actually explain what's missing and draft a tailored version for a specific posting
- Both of the above need a real UX, not a spreadsheet cell. A Sheets cell is a fine place for a visa flag or a 0-100 fit score; it's a bad place for a multi-paragraph gap analysis or a full tailored resume. These are natural candidates for the Claude Desktop handoff (already designed, not yet built) or some other dedicated output surface, not another Sheet column
- Adding other AI backends for visa and fit scoring, alongside Claude — specifically open-weight models run locally on your own laptop (e.g. via Ollama), not just another paid API. This is the concrete path toward the Goal of $0 ongoing cost: today's design already keeps Claude usage to cents by calling it only when free checks can't decide, and a local model swapped in for that same narrow, well-defined classification step removes even that small cost, as long as it can match Claude's reliability on the same task first
This entire pipeline — architecture, every source integration, the visa classification design, the Sheets automation, the test suite, and every bug fix along the way — was built through an extended pair-programming process with Claude Code, Anthropic's agentic CLI. The full build narrative, including real bugs found and fixed live against production data, is documented in RUNBOOK.md and docs/ARCHITECTURE.md.
Step-by-step for every credential .env.example asks for. No screenshots (UIs change and go stale; these steps don't).
- Go to console.anthropic.com and sign up or log in
- Go to Settings → API Keys
- Click Create Key, give it a name, and copy the value (starts with
sk-ant-) — you only get to see it once - Add billing/credits if you haven't already (Settings → Billing) — new accounts usually start with some free credit, but visa-scan and fit-scoring need an active balance to keep running past that
- Paste the key into
.envasANTHROPIC_API_KEY
- Go to developer.adzuna.com and register for a free account
- Once verified, your dashboard shows your App ID and App Key directly, no extra steps
- Copy both into
.env
Google Sheets service account (GOOGLE_SHEETS_CREDENTIALS_PATH, GOOGLE_SHEET_ID, GOOGLE_JOB_LOG_SHEET_ID)
This is the most involved one, but it's a one-time setup:
- Go to console.cloud.google.com and create a new project (or reuse an existing one)
- APIs & Services → Library — search for "Google Sheets API" and click Enable
- APIs & Services → Credentials → Create Credentials → Service Account — give it any name, no special roles needed for this
- Open the new service account → Keys tab → Add Key → Create New Key → JSON — this downloads a
.jsonfile - Save that file into the project (e.g. as
service_account.json) and pointGOOGLE_SHEETS_CREDENTIALS_PATHat it - Open the downloaded JSON and find the
client_emailfield — it looks likesomething@your-project.iam.gserviceaccount.com - Create two blank Google Sheets in your own Google account: one for active matches ("Beacon"), one for excluded jobs ("Job Log")
- Share each Sheet with that
client_emailaddress, giving it Editor access, exactly like sharing with a person — this is the step people most often miss, and without it every write will fail with a permissions error - Copy each Sheet's ID from its URL (the long string between
/d/and/edit) into.envasGOOGLE_SHEET_IDandGOOGLE_JOB_LOG_SHEET_ID
This is what makes Beacon feel "live" instead of a spreadsheet you have to remember to check — Google's own native notification rule, not anything this app builds or maintains. Set it up once, only on the Beacon sheet (the Job Log is deliberately left off — see Why two separate Google Sheets — so excluded jobs don't also email you):
- Open the Beacon sheet in your own Google account (the one you shared with the service account, not the service account itself)
- Tools → Notification settings (older Sheets UI: Tools → Notification rules...)
- Set it to notify "Any changes are made"
- Choose "Email - right away" if you want a ping the moment a new job lands or a status changes, or "Email - daily digest" for one summary a day instead
- Save — you'll get emailed at your Google account's address any time the app (or you) writes to the sheet
Because the app writes as the service account's own distinct identity (not you), Google's rule fires on every automated write same as it would for a human edit — no extra integration code needed. If you don't want emails at all, just skip this section; nothing else in Beacon depends on it.
- Go to financialmodelingprep.com and sign up for a free account
- Your API key is shown directly on your dashboard
- Copy it into
.env
- Go to startuphub.ai and sign up for a free account
- Get your API key from your account dashboard
- Copy it into
.env
A third, best-effort industry source, only queried for companies FMP and StartupHub both already checked and left blank. Free — TinyFish's Search API doesn't use credits, unlike their Agent/Browser APIs.
- Go to agent.tinyfish.ai/api-keys and grab a free key
- Copy it into
.envasTINYFISH_API_KEY - Skip this entirely if you don't want it —
enrich-companiesruns fine without it, it just won't attempt this third pass
This is the source behind the DOL LCA Match/Last Sponsored columns (see Real historical sponsorship data above for what those fields mean and, just as important, what they don't guarantee). Unlike everything else in this Appendix, there's no account and no key — it's public U.S. Department of Labor data — but it does need a bit more manual care to keep current.
Prerequisites
- Just a regular web browser. No signup, no key, no cost — this is public government data
- A few hundred MB of free disk space — one fiscal year's cumulative file runs ~100–150MB (the real FY2026 Q2 file, measured live, was ~137MB / ~1M rows)
- The download itself has to happen in your own normal browser session, not through this app — DOL's site blocks unattended/automated downloads (confirmed live: both this project's sandboxed browser tool and a plain scripted HTTP request were blocked)
Downloading a file
- Go to the OFLC Performance Data page
- Expand "Disclosure Data"
- Under "LCA Programs (H-1B, H-1B1, E-3)", find the fiscal year(s) you want. Each quarter's file is cumulative within its own federal fiscal year (Oct 1 – Sep 30), so for each fiscal year, download only its latest published quarter — downloading every quarter of the same year adds nothing, since the newest one already contains all the earlier ones
- Click the file link to download the
.xlsx(it's large — budget a few minutes) - Repeat steps 3–4 for any additional fiscal years you want covered
- Save every file you download into a local
lca_data/folder in the project root (already covered by.gitignore— these files are large and shouldn't be committed)
Feeding it into Beacon
python -m app.cli lca-enrich lca_data/FY2026_Q2.xlsx lca_data/FY2025_Q4.xlsx
Pass every file you want considered in the same command — it parses each one, merges them (keeping the most recent filing date per employer across all files given), matches every tracked company by name, and updates DOL LCA Match/Last Sponsored on both Sheets plus the underlying companies columns.
Keeping it current — there's no "training" here, just a snapshot you refresh by hand
- DOL adds a new quarter roughly every 3 months. Since each new quarter's file is cumulative for its fiscal year, re-running with just that newest file already picks up every new filing from that fiscal year — you don't need to re-download older quarters of the same year.
- It's safe to run
lca-enrichwith just one new file at a time as quarters come out — a matched company's stored date is only ever updated forward. If the date already in the database for a company is more recent than what the current run's file(s) show for it, the existing date is left alone rather than being overwritten backward in time (so accidentally re-feeding an older fiscal year's file after a newer one is already loaded can't roll anything back). - Still worth keeping every file you've downloaded in
lca_data/rather than deleting them — there's no persisted history of which files have already been applied, so if you ever need to rebuild the database from scratch, you'll want the full set on hand again.
MIT — see LICENSE.
Caution
- Not legal or immigration advice. I'm not a recruiter, employer, or immigration attorney, and nothing here guarantees any company will actually sponsor you.
- The "Likely work visa sponsor" signal is not a promise. It's a positive historical indicator from public government filing data — a company can have a strong filing history and still not sponsor for a specific role.
Beacon classifies what one specific job posting's own text says about sponsorship, nothing more. It doesn't offer you a job, and doesn't maintain any personally curated list of "companies that sponsor visas" — there is no such list. The one exception is the DOL LCA match, which is real public government filing data, not a curated opinion — and even that is a "likely sponsors" positive indicator, never a guarantee, since a company's past filing says nothing about whether this specific posting will sponsor. Every classification comes from analyzing that posting's text at the moment it was ingested, the same way you'd read it yourself, just automated. For any real decision about your immigration status, talk to a qualified immigration attorney, not this tool or its output.
Built for anyone tired of finding out on page 3 of a job description that they were never going to be considered.