$ interninbox scan --new-only
COMPANY TITLE LOCATIONS POSTED URL
stripe Software Engineering Intern (Summer) New York, NY 2026-08-01 https://stripe.com/jobs/...
linear Product Engineering Intern Remote 2026-07-28 https://jobs.ashbyhq.com/linear/...
plaid Data Science Intern San Francisco - https://jobs.lever.co/plaid/...
3 internships across 3 companies
List your target companies once, then get every matching internship from their public job boards in one command. interninbox reads the documented public board APIs of Greenhouse, Lever, Ashby, and SmartRecruiters (the same endpoints each company's own careers page calls). It can also read the official USAJOBS API for federal Pathways internships.
Why interninbox
- 🔒 Private by design. No accounts, no API keys, no LLMs, no telemetry. The only things ever written are your config and a local state file.
- 📍 Search by location. A country, a US state, or a city, with smart aliases:
"California"finds boards that wrote"CA", and vice versa. - 🎯 Search by role. Nine curated presets (
software,cybersecurity,finance, and more) or your own whole-word keywords. - 🛂 Filter by eligibility.
require_sponsorship = truehides listings known to not sponsor visas or to require US citizenship, read from list metadata and job descriptions. Season (terms) and degree filters too. - 📋 Scan the community lists.
sources = ["simplify"]pulls the SimplifyJobs seasonal list (thousands of curated internships across every employer, kept fresh by the community) in one polite request, and diffs it with--new-only. - 🏢 100+ curated companies. Big and small, every slug live-verified. Scan your own list, or sweep a whole tier of the registry.
- 🧭 First-run wizard. No config? Answer three questions, get results, and optionally save them for next time.
- 📬 A personal feed.
--new-onlyshows just what appeared since your last scan; run it on a schedule and it becomes your morning internship digest. - 🤝 Polite by construction. Sequential, rate-limited requests, an honest User-Agent, documented public APIs only. No scraping.
Install
pipx install interninbox # recommended: isolated, on your PATH
uv tool install interninbox # or with uv
uvx interninbox scan # or zero-install, run it straight from PyPI
Requires Python 3.11+. Installing straight from git works too
(pipx install git+https://github.com/hiratinspace/interninbox), or run from a
checkout with uv sync && uv run interninbox --help.
Updating
Already have an older version? Update it the same way you installed it:
pipx upgrade interninbox # if you used pipx
uv tool upgrade interninbox # if you used uv
pip install --upgrade interninbox # if you used pip
Then check with interninbox --version. Upgrades are safe: your
interninbox.toml keeps working and the state file migrates itself. If an
upgrade insists you are already current but the version looks old, force it
with the installer's --force (pipx / uv tool) or --force-reinstall (pip).
Quickstart
Install, then just run:
interninbox scan
On a fresh terminal with no config, scan opens a short wizard. It asks
where you want to work, which role types, and which companies (your own list,
or a tier of the registry with a rough scan-time
estimate), scans immediately, and offers to save your answers to
interninbox.toml for next time. Blank answers mean "no preference." The
wizard only appears on a real terminal with no config; cron jobs and pipes are
never interrupted (pass --interactive to force it).
Prefer to set things up by hand?
interninbox init # 1. write a starter interninbox.toml here
interninbox companies # 2. print the curated registry to copy from
interninbox scan # 3. scan every configured company
Edit interninbox.toml between steps 2 and 3, then re-run interninbox scan
whenever you want fresh results. Add --new-only to see only what's new since
your last scan.
Commands
| Command | What it does |
|---|---|
interninbox scan |
Scan every configured company and print matching internships |
interninbox init |
Write a starter interninbox.toml (refuses to overwrite) |
interninbox companies |
Print the curated registry as ready-to-paste ats:slug entries, with each company's size and tags |
interninbox roles |
Print the role presets and the exact keywords each expands to |
interninbox find-board NAME |
Probe the supported ATSes for a company's board slug and print ready-to-paste "ats:slug" lines |
interninbox --version |
Print the version |
scan flags
| Flag | Effect |
|---|---|
--config PATH |
Use a config other than ./interninbox.toml |
--json |
Emit machine-readable JSON instead of the table |
--markdown |
Emit a Markdown table (paste it anywhere) |
--new-only |
Show only listings not seen by a previous scan |
--state PATH |
Use a state file other than the one derived from the config name |
--interactive |
Ask location/role/company questions before scanning (automatic on a terminal when no config exists). With an existing config, the answers apply to that run only unless you save them: a one-shot override of locations, roles, and registry that leaves everything else untouched |
--since WINDOW |
Show only listings posted within the window (7d, 36h, 2w); undated listings are kept |
--quiet, -q |
Suppress the banner and per-company progress lines |
An interactive scan opens with the block wordmark ("intern" in white, "inbox" in blue, matching the logo), then prints per-company progress:
██ ██ ██ ██
██ ██
██ ██████ ██▀▀▀▀ ██████ ██▀▀██ ██████ ██ ██████ ██████ ██████ ██ ██
██ ██ ██ ██ ██▄▄██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ▀██▀
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ▄██▄
██ ██ ██ ▀████ ██████ ██ ██ ██ ██ ██ ██ ██████ ██████ ██ ██
> find internships. in the terminal.
It goes to stderr (never stdout), so piped --json / --markdown output
stays clean. It appears only on a real terminal, honors NO_COLOR, uses a
theme-proof 256-color blue, and vanishes under pipes, redirects, and cron.
Pass --quiet to silence it (and the progress lines) anywhere. On a real
terminal, each result's URL is a clickable OSC 8 hyperlink.
Exit codes: 0 on success (a single company that fails prints a one-line
warning and never aborts the scan); 1 when the config is invalid or every
company failed.
Configuration
interninbox init writes this file; every key is explained inline.
# Target companies as "ats:slug".
companies = [
"greenhouse:stripe", # job-boards.greenhouse.io/<slug>
"lever:plaid", # jobs.lever.co/<slug>
"ashby:linear", # jobs.ashbyhq.com/<slug>
]
# Also sweep the bundled curated registry: "none" (default), "top" (~50
# well-known boards), "all", "large", or "startups". Unioned with `companies`.
registry = "none"
# Community internship lists to scan too ("simplify" = the SimplifyJobs
# seasonal list; one polite request for thousands of curated internships).
sources = ["simplify"]
[filters]
# Extra title keywords that count as an internship signal, on top of the
# built-in one (intern, internship, co-op, summer analyst, apprentice, ...).
include_keywords = []
# Named role presets that narrow to a field. Run `interninbox roles` to list
# them. Their keywords merge into match_keywords. Example: roles = ["cybersecurity"].
roles = []
# Drop any listing whose title contains one of these (case-insensitive).
exclude_keywords = ["mechanical"]
# Keep only listings whose location contains one of these as a whole word
# (case-insensitive). Empty = keep every location.
locations = ["New York", "Remote"]
# When true (the default), remote listings always pass the locations filter.
remote_ok = true
# Hide listings KNOWN to not sponsor visas or to require US citizenship.
# Silent listings are always kept.
require_sponsorship = true
# Keep only these seasons / degree levels (unknown always passes).
terms = ["Summer 2027"]
degrees = ["Bachelor's"]
# Optional: federal Pathways internships via the official USAJOBS API.
[usajobs]
enabled = true
email = "you@example.com" # the email your API key is registered under
keywords = ["software"]
api_key_env = "USAJOBS_API_KEY" # environment variable holding your key
| Key | Type | Default | Meaning |
|---|---|---|---|
companies |
list of "ats:slug" |
required unless registry/[usajobs] is set |
Boards to scan; ats is greenhouse, lever, or ashby |
registry |
"none", "top", "all", "large", "startups" |
"none" |
Also scan the curated registry; unioned with companies (duplicates removed) |
sources |
list of strings | [] |
Community lists to scan too: "simplify" (current season) or "simplify-summer2026" / "simplify-summer2027" to pin one |
filters.include_keywords |
list of strings | [] |
Extra title keywords OR-ed with the built-in internship signal (broadens) |
filters.match_keywords |
list of strings | [] |
Whole-word title keywords required on top of the signal (narrows) |
filters.roles |
list of strings | [] |
Named role presets whose keywords merge into match_keywords |
filters.exclude_keywords |
list of strings | [] |
Title substrings that drop a listing |
filters.locations |
list of strings | [] |
Whole-word location terms to keep; empty keeps everything |
filters.remote_ok |
bool | true |
Whether remote listings bypass the locations filter |
filters.require_sponsorship |
bool | false |
Hide listings known to not sponsor visas or to require US citizenship |
filters.terms |
list of strings | [] |
Keep only these seasons (e.g. ["Summer 2027"]); unknown passes |
filters.degrees |
list of strings | [] |
Keep only these degree levels (e.g. ["Bachelor's"]); unknown passes |
usajobs.enabled |
bool | false |
Turn the USAJOBS adapter on |
usajobs.email |
string | none | The email your USAJOBS key is registered under |
usajobs.keywords |
list of strings | [] |
Extra keyword filter for the USAJOBS query |
usajobs.api_key_env |
string | "USAJOBS_API_KEY" |
Environment variable holding your key |
A commented copy ships as
interninbox.example.toml.
Finding a company's slug
Open a company's careers page and read the URL of an actual job listing:
| You see | Add to your config |
|---|---|
job-boards.greenhouse.io/acme/... |
"greenhouse:acme" |
jobs.lever.co/acme/... |
"lever:acme" |
jobs.ashbyhq.com/acme/... |
"ashby:acme" |
jobs.smartrecruiters.com/AcmeCorp/... |
"smartrecruiters:AcmeCorp" |
Or let the tool guess: interninbox find-board "Acme Corp" probes all four
ATSes with the obvious slug candidates and prints whatever answers. If a scan
reports HTTP 404 from <host>: check the slug exists, the slug is wrong or
the company changed ATS providers. interninbox companies lists the full
registry of known-good entries to start from.
How matching works
All matching is local, deterministic heuristics that are fast, free, and predictable:
- Internship signal. Word-boundary regexes on the title (
intern,internship,co-op,summer analyst,apprentice,student trainee, and friends), OR any of yourinclude_keywords. Word boundaries matter: "International Program Manager" and "Internal Tools Engineer" do not match. match_keywords/rolesrequirement. If set, the title must also contain one of them as a whole word. This narrows ("internship AND security");include_keywordsbroadens.- Staff-role exclusion. Roles about interns rather than for them (recruiter, program manager) and seniority markers (Senior, Staff, II/III) are dropped.
- Your filters.
exclude_keywords, thenlocations/remote_ok.
A listing with no stated location is dropped when locations is set (there
is nothing to match against). Leave locations = [] to keep such listings.
Location matching is whole-word and case-insensitive: "NY" matches
"Albany, NY" but not "Sunnyvale, CA". Common US-state and country
forms are expanded for you, so "California" also matches "CA", and
"NYC" ⇄ "New York", "UK" ⇄ "United Kingdom", "US"/"USA" ⇄
"United States". Two safety rules keep the short forms honest:
- A full state name expands to its comma-anchored code (
"Oregon"→", OR"), never the bare code, so"Oregon"matches "Portland, OR" but never the word or in "Remote in USA or Canada" (the same anchor protectsIN,ME,OK,HI, and the pronoun-safeUS). "LA"deliberately means Louisiana only; for Los Angeles, spell it out.
Role presets
Narrow to a whole field with a named preset instead of hand-listing keywords:
[filters]
roles = ["cybersecurity"] # keeps only security internships
Run interninbox roles to see every preset and the exact whole-word keywords
it expands to: software, data, cybersecurity, finance, business,
marketing, design, product, and hardware. A preset's keywords merge
into match_keywords (both mean "titles I want to see"), so roles = ["software"] keeps titles containing "software", "backend", "platform", and
friends. Nothing is magic: the command prints the keywords, and an unknown
role name fails with the list of valid ones.
The company registry
interninbox bundles a curated registry of 100+ internship-hiring companies
across Greenhouse, Lever, and Ashby: a mix of large public companies and
startups, each tagged by industry. Every slug was live-verified against its
public board API when the registry was authored
(scripts/verify_registry.py); companies do
migrate ATSes, so a slug that later goes stale degrades gracefully (one warning
line) rather than crashing a scan.
Sweep a tier without listing companies by hand:
registry = "top" # ~50 of the most-recognized boards
| Tier | What it scans |
|---|---|
"top" |
~50 of the most-recognized companies |
"all" |
the whole registry |
"large" |
only the large / public companies |
"startups" |
only the startups |
The tier is unioned with any companies you list (duplicates removed).
Big sweeps are slow on purpose: polite pacing floors same-host requests at
500 ms, so a scan of 20+ boards prints an honest estimate up front (e.g.
scanning 103 boards, roughly ~2 min).
Contributing an entry: add a RegistryCompany(ats, slug, name, size, tags) row to src/interninbox/registry.py, then run
scripts/verify_registry.py. It must report a live PASS (HTTP 200) before
the entry ships. Never commit an unverified slug.
Community list sources
The broadest coverage comes from the community: the SimplifyJobs seasonal internship lists track thousands of internships across every employer, including companies on ATSes this tool has no adapter for, and publish the data behind their README as structured JSON. Add the list as a source:
sources = ["simplify"]
One polite request fetches the whole list; entries arrive with sponsorship,
season, and degree metadata that feeds the
eligibility filters. Your keyword, role, and location
filters apply to list entries exactly as they do to scanned boards, and
--new-only diffs the list run over run, so the terminal becomes a feed over
the list students refresh by hand. "simplify" follows the current season;
pin "simplify-summer2026" or "simplify-summer2027" to a specific one.
List data is community-maintained (credit: SimplifyJobs and contributors) and links out to each employer's own posting; interninbox never scrapes those hosts. A source that is unreachable degrades to one warning line and the rest of the scan continues.
Eligibility filters
The questions that actually disqualify an application get first-class filters:
[filters]
require_sponsorship = true # for international students
terms = ["Summer 2027"]
degrees = ["Bachelor's"]
require_sponsorship = truehides listings known to not sponsor visas or to require US citizenship. Signals come from community-list metadata and from the job description itself: Lever and Ashby include descriptions in their normal responses, and Greenhouse descriptions are fetched (only when this filter is on, since they inflate each board fetch). Classification is requirement-aware and sentence-scoped: "unable to sponsor", "must not require sponsorship", "US citizenship is required", and clearance/ITAR requirements disqualify, while hedged mentions ("clearance preferred", "no sponsorship required") never do. A listing that says nothing is always kept. USAJOBS listings count as citizenship-restricted (federal Pathways positions are citizenship-limited). SmartRecruiters postings carry no descriptions in the list API, so they stay unknown and are always kept; the phrase lists are English-only, so non-English boards also stay unknown.termskeeps only the seasons you want, read from list metadata or the title ("... Intern (Summer 2027)"). Unknown seasons pass.degreeskeeps only listings open to your level (list-source entries carry this metadata). Unknown passes.
The same data flows into --json output as sponsorship and terms fields
on every listing, so scripts can post-process it.
--new-only and the state file
Every scan records what it saw in a small state file next to your config.
With --new-only, only listings absent from that file are shown, so "new"
always means "since my last scan", whether or not earlier scans used the
flag. A listing counts as seen once it has been fetched, even if your filters
hid it, so loosening a filter later won't flood --new-only with old posts.
- First scan: everything is new. Missing or corrupt state file: everything counts as new (one warning, never a crash).
- Each config gets its own state file (
interninbox.toml→.interninbox-state.json,work.toml→.interninbox-state.work.json), so two configs in one directory don't share state. Override with--state PATH. - The file stores only a listing key and the date it was last seen (no URLs); entries not seen for a year are pruned, so it never grows without bound.
initwrites only the TOML; if your config lives in a git repo, add.interninbox-state.jsonto your.gitignoreyourself.
Run it on a schedule (cron, launchd, a shell alias) and --new-only becomes a
personal internship feed.
USAJOBS (optional)
Federal Pathways internships come from the official USAJOBS Search API, which needs a free key:
- Request one at https://developer.usajobs.gov/apirequest/.
- Export it:
export USAJOBS_API_KEY=.... - Set
[usajobs] enabled = trueandemail = "...".
Per USAJOBS's documented contract, requests must carry the registered email as
the User-Agent; interninbox sends exactly that, for that host only. If
[usajobs] is enabled but the key variable is unset, the scan skips it with an
info line and carries on.
How a scan works
flowchart LR
A[interninbox.toml] --> B["Fetcher<br/>polite HTTP, one per scan"]
B --> C1[Greenhouse boards API]
B --> C2[Lever postings API]
B --> C3[Ashby posting API]
B --> C4["USAJOBS API<br/>(optional)"]
C1 & C2 & C3 & C4 --> D["Internship signal<br/>+ staff-role exclusion"]
D --> E["Your filters<br/>roles, keywords, locations"]
E --> F["State diff<br/>(--new-only)"]
F --> G[Table / JSON / Markdown]
Politeness is enforced in one place (src/interninbox/fetch.py) that every
adapter goes through, so it is not a setting you can forget: sequential
requests, ≥500 ms between same-host calls, a 15-second timeout, at most one
retry (on transient failures only), and an honest User-Agent. Only documented
public APIs are used, with no HTML scraping and nothing behind a login.
Scope, honestly
This tool does one-shot local scans. That is its whole job, done politely and fast. It deliberately does not verify a listing is still live, deduplicate reposts across boards, watch continuously, or apply for you.
Title-only matching also misses some real internships: bare "Trainee", titles
like "Software Engineer (Intern) II" (the seniority filter wins), and languages
beyond the built-in German/French patterns. include_keywords can widen the
net. Continuous verification, curation, and instant alerts are what the hosted
Interninbox product (coming soon) does. This CLI is the honest local version:
you run it, you own your data, and nothing phones home.
FAQ
Why these ATSes? Greenhouse, Lever, Ashby, and SmartRecruiters expose documented public board APIs designed for exactly this, and the community list covers employers on everything else. PRs welcome for any source with a documented public API.
Does it store or send my data anywhere? No. The only writes are your config and the local state file. There is no telemetry of any kind.
A company I added returns 404. The slug is wrong or the company changed ATS providers. See Finding a company's slug.
Can it email or notify me? Not built in. Pipe --json into whatever you
like, or run it on a schedule with --new-only and a mail hook.
Development
uv sync
uv run pytest # the full offline suite (MockTransport + synthetic fixtures)
uv run ruff check .
Every test is offline, with no recorded third-party data, ever
(provenance note). Source lives in
src/interninbox/ (adapters, filters, fetcher, registry, wizard, CLI).
Support is best-effort via GitHub issues; see
CONTRIBUTING.md.
License
MIT © 2026 Interninbox contributors
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file interninbox-0.3.0.tar.gz.
File metadata
- Download URL: interninbox-0.3.0.tar.gz
- Upload date:
- Size: 1.2 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f94cb38f44415e08bbb3e8d32d5a7f423cd5524299bf2f9f99c0afe1fb0a299d
|
|
| MD5 |
a5b5bcaac64c10ccc9df3f1529f730e4
|
|
| BLAKE2b-256 |
ad7973bd4e4ca8ea83eeef3ee54d5a77f26786aa0f655e405443f602797cb4d1
|
Provenance
The following attestation bundles were made for interninbox-0.3.0.tar.gz:
Publisher:
release.yml on hiratinspace/interninbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
interninbox-0.3.0.tar.gz -
Subject digest:
f94cb38f44415e08bbb3e8d32d5a7f423cd5524299bf2f9f99c0afe1fb0a299d - Sigstore transparency entry: 2477254061
- Sigstore integration time:
-
Permalink:
hiratinspace/interninbox@5935e6bfd9eae819c5392744aa61cea7ede64c75 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/hiratinspace
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5935e6bfd9eae819c5392744aa61cea7ede64c75 -
Trigger Event:
release
-
Statement type:
File details
Details for the file interninbox-0.3.0-py3-none-any.whl.
File metadata
- Download URL: interninbox-0.3.0-py3-none-any.whl
- Upload date:
- Size: 61.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c7e56faac7e576b53cdae6ee0ec100bcecfc28a9ac9609b72e4291a384e87267
|
|
| MD5 |
e478702824eefbefe79b09d54174d183
|
|
| BLAKE2b-256 |
81585ce8dd6c63ff759315aa515c080fe79bdb20be11db5487d1106b97716c8d
|
Provenance
The following attestation bundles were made for interninbox-0.3.0-py3-none-any.whl:
Publisher:
release.yml on hiratinspace/interninbox
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
interninbox-0.3.0-py3-none-any.whl -
Subject digest:
c7e56faac7e576b53cdae6ee0ec100bcecfc28a9ac9609b72e4291a384e87267 - Sigstore transparency entry: 2477254809
- Sigstore integration time:
-
Permalink:
hiratinspace/interninbox@5935e6bfd9eae819c5392744aa61cea7ede64c75 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/hiratinspace
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5935e6bfd9eae819c5392744aa61cea7ede64c75 -
Trigger Event:
release
-
Statement type: