Finds internships, scores them against your profile, writes tailored materials, fills the application form, and submits it — with an approval gate in front of the submit button by default.
Built and verified against live Greenhouse, Lever, and Ashby application forms.
Two things you should know before pointing this at real employers.
Automated submission violates most portals' terms of service. LinkedIn,
Indeed, Workday, and Handshake all prohibit it, and accounts do get flagged.
That's why autonomy: review is the default: the agent does everything up to
the submit button, and you approve each one with a keypress. Full auto exists,
it's one config line, and it's your call — but the risk is real and it's yours.
A bad application is worse than no application. The generator is instructed never to claim anything your profile doesn't support, and lists whatever the posting asked for that you don't have. Field matching refuses to guess: if your school isn't in a dropdown it picks "Other" rather than the nearest lookalike, because "Adams State University" is a different school from yours and that's your name on the form. Read the review screen. It exists for a reason.
discover ──▶ score ──▶ prepare ──▶ [review] ──▶ submit
│ │ │ │ │
│ │ │ │ └─ replays the form, verifies
│ │ │ │ it matches what you approved,
│ │ │ │ then clicks submit
│ │ │ └─ one key per application: y / n / skip
│ │ └─ cover letter + answers, fills the form, screenshots it
│ └─ cheap keyword rules, then a model pass on the survivors
└─ GitHub internship lists, Greenhouse/Lever/Ashby APIs, Handshake
Nothing is submitted until you approve it, unless you change autonomy.
Requires Python 3.11+.
git clone <your-repo-url> internship-agent
cd internship-agent
python -m venv .venv
.venv\Scripts\activate # Windows; source .venv/bin/activate elsewhere
pip install -e ".[dev]"
python -m playwright install chromium
cp .env.example .env # add your ANTHROPIC_API_KEY
cp config/config.example.yaml config/config.yaml
cp profile/profile.example.yaml profile/profile.yaml
cp /path/to/your/resume.pdf profile/resume/resume.pdfFill in profile/profile.yaml, then:
apply doctordoctor checks the API key, the resume (including whether text can actually be
extracted from it), Chromium, and which profile fields are still blank. Get it
green before your first real run.
Your resume, profile, config, and database are all gitignored. Nothing personal is committed.
apply run # discover -> score -> prepare
apply review # approve or reject, one key each
apply submit # send the approved batchOr step by step:
| Command | What it does |
|---|---|
apply discover |
Fetch postings. --source greenhouse to run just one. |
apply score |
Rule pass, then model scoring on survivors. |
apply prepare |
Generate materials, fill forms, screenshot. |
apply review |
The approval gate. |
apply submit |
Submit everything approved. Prompts first; --yes to skip. |
apply status |
Counts, what's waiting on you, what failed and why. |
apply show <id> |
Everything about one application, including its history. |
apply reset failed |
Requeue failures after fixing the cause. |
In apply review: y approve, n reject, s skip, o open the screenshot,
u open the form in a browser, c show the cover letter, q quit.
Everything lives in config/config.yaml. The parts that matter most:
autonomy: review # review | auto | hybridreview(default) — fills and queues. You approve. Nothing is ever sent without a keypress.auto— submits immediately after filling. No human in the loop.hybrid— auto-submits only when the gate passes, queues everything else:
hybrid_gate:
min_score: 85
require_no_unmapped_fields: true # any field it couldn't map -> you review it
require_no_freetext: true # any generated free-text -> you review itRegardless of mode, the agent refuses to auto-submit a form with an interactive captcha or an in-portal Handshake application.
match:
min_rule_score: 40 # below this, never reaches the model (saves money)
min_score: 60 # below this, skipped
titles_exclude: [senior, staff, manager, phd]
drop_if_text_matches: ["security clearance", "must be a us citizen"]score_model runs once per candidate posting; generate_model runs once per
prepared application. Both default to claude-opus-5. Scoring is the high-volume
one — set score_model: claude-sonnet-5 to cut that cost, and note that the rule
pass already filters most junk before any model call happens.
limits:
max_prepare_per_run: 15
max_submit_per_run: 10
delay_between_applications_s: 20 # don't set this to 0
max_per_company: 3| Source | Notes |
|---|---|
github_lists |
Curated GitHub internship lists. Public, high signal, no scraping risk. Handles both the markdown and HTML table formats those repos use, and reads their 🔒 / 🇺🇸 / 🛂 markers as closed / citizenship-required / no-sponsorship. |
greenhouse, lever, ashby |
Public JSON endpoints, one per company. Add board tokens (the slug in the company's careers URL) to companies. Full job descriptions and the most automatable forms. |
handshake |
Your university portal. Needs a login: run apply login-handshake once and sign in by hand. Best-effort — Handshake changes its DOM often. Applications that submit inside Handshake are review-only. |
One JavaScript pass extracts a descriptor for every field on the page — a reload-stable CSS selector, its type, its human label, its options. Python then decides what goes where. That's why the same code handles Greenhouse, Lever, Ashby, and custom forms without per-vendor selector lists.
src/internship_agent/apply/fieldmap.py is the lookup table: a label regex maps
to a semantic key, and the key resolves against your profile. It's deliberately
boring and readable — when a form asks something new, add a pattern and a branch.
Things it handles because live forms demanded it:
- react-select comboboxes — clicks open, reads only the options belonging to that combobox, clicks the match. Scoping matters: a page-wide option query once answered "Overall GPA" with "American Samoa".
- Server-filtered autocompletes — types progressively shorter queries until options appear.
- Number inputs —
2027-05becomes2027for a "graduation year" field. - GPA bands — places
3.8into3.75 - 4.0. - Degree synonyms — your "Bachelor of Science" matches their "Bachelor's Degree".
- EEO questions — selects "Decline to self-identify" unless you filled in
demographics. - Captchas — detected, and submission is refused with an explanation rather than a silent no-op. Distinguishes the invisible reCAPTCHA v3 badge (harmless) from a real challenge.
apply submit doesn't replay saved keystrokes. It re-opens the form, fills it
again, and compares the result against what you approved. If anything differs it
aborts instead of submitting. Approval means something specific: you approved
that set of values, not "whatever this form looks like later".
The pipeline needs a model for exactly two things: judging how well a posting fits you, and writing the cover letter plus free-text answers. Everything else is HTTP requests and browser automation.
Offline mode hands those two jobs to a headless Claude Code session (claude -p)
running on your Claude subscription. No ANTHROPIC_API_KEY, no API call. The
handoff is two JSON files in work/:
apply export-work -> work/pending.json (the postings, plus instructions)
claude -p ... -> work/completed.json (scores, cover letters, answers)
apply import-work -> straight into the database
| Step | Needs a model | Offline |
|---|---|---|
discover |
no | unchanged |
score rule pass |
no | unchanged |
score model pass |
yes | skipped; the rule score stands |
| writing materials | yes | Claude Code, via import-work |
| form filling, screenshots, digest | no | unchanged, but see below |
submit |
no | unchanged; re-fills and verifies as always |
Turn it on in config.yaml:
offline: trueIt is a config setting, not just a flag, on purpose. A form prepared offline
has to be re-filled offline at submit time. If submit runs online it adds
model-written answers that weren't there at approval, the refill no longer
matches what you approved, and every submission aborts with "form changed since
you approved it". One setting keeps both halves in sync. --offline / --online
override it per command when you need to.
Check the setup with apply doctor — in offline mode it verifies the claude
CLI instead of an API key.
This is the real cost. Online, when a form asks something your profile doesn't cover, the model answers it. Offline there is no model at fill time, so those questions get flagged instead:
| Situation | Online | Offline |
|---|---|---|
Field matched by fieldmap.py |
filled from your profile | same |
| Cover letter, why-this-company | generated on the spot | from imported materials |
| A novel long-answer question | model writes an answer | REQUIRED unfilled: |
| A dropdown with no profile match | model picks from real options | no matching option: |
Anything flagged REQUIRED blocks automatic submission, so it lands in the
"need you" half of the digest with its URL. A form that asks nothing unusual
fills identically either way; a form with three essay questions does not.
If you'd rather apply by hand, the daily run can produce a worksheet instead of filling forms. This is the default mode, and it needs no API key and no browser, so it finishes in under a minute instead of half an hour.
apply sheet --limit 20Writes artifacts/internships-YYYY-MM-DD.xlsx (plus a stable
internships-latest.xlsx) and opens it. One row per posting:
| Column | What |
|---|---|
| Score, Company, Role, Location, Posted, Source | the posting |
| Link | clickable; reads CHECK - ... if the posting looks dead |
| Why it fits | the scoring reasons |
| Watch out for | citizenship/sponsorship notes, and what the posting wants that your profile doesn't cover |
| Cover letter | the full tailored letter, ready to paste |
| Applied? / Date applied / Notes | empty, for you |
Because there's no browser, dead postings can't be caught by filling them, so
each link is fetched over HTTP first and flagged if it 404s or the page says the
role is closed. Postings already written to a sheet aren't offered again;
--repeat overrides that. The per-company cap still applies, so one employer
can't take the whole sheet.
Run it daily with scripts\daily-sheet.ps1, which does
discover -> score --offline -> export-work -> claude -p -> import-work -> sheet.
This is the other mode: the agent opens each application form and fills it. It needs the browser, and the essay questions ("what project are you most proud of?") need an API key to be answered, so without one a fair number of forms end up blocked on one or two fields. Switch to it with:
powershell -ExecutionPolicy Bypass -File scripts\install-schedule.ps1 -Mode Fillpowershell -ExecutionPolicy Bypass -File scripts\install-schedule.ps1
powershell -ExecutionPolicy Bypass -File scripts\install-schedule.ps1 -At 06:30 -Count 25
powershell -ExecutionPolicy Bypass -File scripts\install-schedule.ps1 -Remove
Start-ScheduledTask -TaskName InternshipAgent-Daily # run it now
Get-ScheduledTaskInfo -TaskName InternshipAgent-Daily # last result, next runscripts/daily.ps1 runs, in order: discover, score --offline,
export-work, claude -p, import-work, prepare --offline, digest. It
takes a global mutex so a manual run can't collide with the scheduled one and
corrupt the shared browser profile, and it exits non-zero if any stage fails so
Task Scheduler reports the truth.
The daily run submits nothing. It stops after filling and writing the digest.
When it actually runs. The task uses an interactive logon token, because
filling forms needs the desktop session holding your logged-in browser profile.
So it cannot run while you're logged out; a locked screen is fine. It requests
WakeToRun, but power plans can veto wake timers — where that happens the run
starts when the machine next wakes, which may be hours after the scheduled time.
| Path | What |
|---|---|
artifacts/digest.md |
the morning summary, overwritten each run |
artifacts/logs/daily-YYYY-MM-DD.log |
one per day |
artifacts/screenshots/ |
the filled form for each application |
artifacts/cover-letters/ |
the letter handed to each form |
work/*.json |
the current handoff, cleared at the start of every run |
Read artifacts/digest.md, which splits what was filled into "Ready to submit"
and "Filled, but need you". Then:
apply submit --all--all approves everything prepared that can actually be submitted and holds
back the rest, printing each with a reason and its URL:
| Held back when | Why |
|---|---|
| interactive captcha | the agent cannot solve it |
| a required field is unfilled | an incomplete application is worse than none |
| no fields were filled | the posting is almost certainly closed |
| the resume did not attach | the one thing that must not be missing |
| no submit button found | usually a multi-page form |
| Workday or Handshake | needs an account or an in-portal flow |
The digest and --all share one predicate, so the digest cannot promise
something submit would refuse.
"20 prepared" does not mean "20 sent". Every Lever form carries hCaptcha, about a third of postings are Workday, expired postings fill zero fields, and offline mode's flagged questions add to the pile. A run where nothing is auto-submittable is normal and is not a malfunction.
What you actually get is the research, the letters, and the typing done, and a morning spent clicking through leftovers instead of starting from a blank form.
src/internship_agent/
cli.py commands
offline.py the export/import handoff that removes the API-key need
pipeline.py discover -> score -> prepare -> submit
scorer.py rule pass + model pass
generate.py cover letters, free-text answers, multiple-choice answers
review.py the approval gate
llm.py Anthropic wrapper (refusal fallbacks, structured output)
resume.py PDF/DOCX text extraction
db.py sqlite store
models.py Job, Score, FilledForm, statuses
sources/ github_lists, ats (greenhouse/lever/ashby), handshake
apply/
browser.py persistent Playwright session
fieldmap.py label -> semantic key -> your data
filler.py form enumeration and filling
runner.py prepare / submit, and the re-fill verification
scripts/ daily.ps1, install-schedule.ps1, refresh-resume.ps1
tests/ 126 tests, no network required
pytest -qAll offline. Covers the list parsers (both formats), field classification, option matching, scoring rules, resume text repair, and dedup/status handling. The regression tests worth knowing about are the ones asserting it will not put a lookalike university in a school field, a country in a city field, or a university in a "High School Name" field. Each of those was a real bug caught on a live form.
To smoke-test the filler against a real posting without submitting anything, see
apply prepare with browser.headless: false and watch it work.
- Workday postings are discovered but not reliably filled. Workday requires an account per employer and uses a multi-page wizard; those get flagged for manual completion. About a third of what the GitHub lists surface is Workday.
- Captcha forms cannot be submitted by the agent, only prepared.
- Handshake selectors drift; treat that source as best-effort.
- Multi-page forms are filled one page deep. If a form paginates, the review screenshot shows page one and submission will abort rather than guess.
- Scoring quality depends entirely on how specific your
profile.yamlis. Vague profile in, vague tailoring out.
© 2026 Arunachalam Kasi. All rights reserved. Public for review purposes. No license granted for reuse or redistribution.