Agentero
Connect your own Agent, keep research context in a local Vault. Seamlessly migrate from Zotero, read, understand, link, and reuse your research context.
AI translation
Track job applications and record when you apply on a company's site
AI summary · website content
ProductivityPython · ShellGitHub @Asit0007OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗A job-search pipeline that automates everything except the submit button.
Discovery, deduplication, fit scoring, resume tailoring, screening answers and reply tracking all run unattended. You apply, on the employer's own site, from a queue of fifteen.
Nothing in this codebase submits an application, logs into a job board, drives a browser session, or stores a job-board password.
That is a deliberate design constraint, not an unfinished feature.
Tools that drive your logged-in LinkedIn or Naukri session are the ones that get accounts restricted — verification email, then outreach disabled for weeks, then sometimes a lockout that never gets reversed. The asymmetry is the whole argument: losing your LinkedIn account mid-search is the worst outcome available, and auto-submit buys throughput on Easy Apply, the lowest-conversion channel there is.
jobpipe works one layer down, on the postings. Public ATS APIs, an aggregator, and job-alert emails you configured, read out of your own inbox. There is no activity on any job board, so there is nothing to detect and nothing to ban.
review_api.py is the only module that can write status = applied, and only
in response to you clicking a button after you have already submitted.
CI enforces this: a workflow fails the build if selenium, playwright,
puppeteer or a board credential ever appears in the source tree.
flowchart TD
A["<b>sources</b><br/>greenhouse · lever · ashby<br/>remotive · remoteok · arbeitnow<br/>jobicy · himalayas · weworkremotely<br/>adzuna · gmail alerts"] --> B["<b>normalize</b><br/>canonicalise · fingerprint · dedup"]
B --> C[("<b>discovered</b>")]
C --> D{"<b>prefilter</b><br/>free, deterministic<br/>no LLM call spent"}
D -->|"81% die here"| E["filtered"]
D -->|"19% survive"| F["<b>score</b><br/>LLM fit rating 0-100"]
F --> G[("<b>shortlisted</b>")]
G --> H["<b>tailor</b><br/>fact-gated selection<br/>+ screening answers"]
H --> I[("<b>prepared</b>")]
I --> J["<b>notify</b> → Telegram"]
J --> K((("<b>YOU APPLY</b><br/>on the real posting")))
K --> L[("<b>applied</b>")]
L --> M["<b>track</b><br/>Gmail replies → Telegram"]
M --> N[("<b>responded</b>")]
style K fill:#E8A33D,stroke:#8a5d00,stroke-width:3px,color:#1a1a1a
style D fill:#1B2330,stroke:#4FB3C9,color:#D9E1EA
style E fill:#2a2020,stroke:#5A6675,color:#78899B
Measured end to end across every configured source. Regenerate these numbers
from your own database at any time with make readme-stats -- it re-runs the
prefilter over stored rows and costs nothing, because the prefilter never calls
a model:
| stage | count | |
|---|---|---|
| ingested | 30,735 | 11 sources, deduplicated |
| killed on keywords | -14,613 | fewer than 2 must-haves present |
| killed on title | -7,629 | sales roles whose JD lists your whole toolchain |
| killed on hard rejects | -1,368 | seniority, shift work, geography |
| reach an LLM call | 7,125 | 23% - this is what protects the free tier |
| shortlisted | 199 | above shortlist_min_score |
| queued for you | 15/day cap | because volume is not the goal |
Every row killed above the LLM line costs nothing, and that is the whole design. The free tier allows 500 requests per day per model, so a single unfiltered run would exhaust the day before it finished. The prefilter is what makes the pipeline fit inside a free quota at all.
Calibrate
shortlist_min_scoreagainst your own model's output, not a guess. The default assumed scores would spread across 0–100. In practice the observed range was 0–85 with a mean of 21, so a threshold of 68 sat above almost everything and shortlisted nothing — silently, with no error. Score a few hundred rows first, look at the distribution, then set the threshold. Re-bucketing is free: scores are stored, so changing it costs no LLM calls.
An LLM asked to "tailor my resume to this JD" will quietly invent metrics, team sizes and durations. Those become interview questions you cannot answer.
jobpipe makes that structurally impossible rather than merely discouraged:
# config/facts.yaml — the only source of resume claims
- {id: F005, verified: false, tags: [ansible, automation],
text: "Wrote Ansible playbooks to replace manual configuration steps"}
The tailoring model never writes experience. It receives a menu of fact IDs
and must return a selection. tailor.py validates every ID that comes back
and drops anything it doesn't recognise.
Facts ship verified: false and are excluded from the menu entirely until you
flip them. The bar is one question:
Could you defend this line if an interviewer stopped you on it?
That friction is the feature. facts.yaml is also schema-validated on load — a
typo'd ID or a missing verified key fails loudly instead of silently dropping
the fact.
never_claimThe ID gate stops the model inventing a fact. It cannot stop it rephrasing a
real fact into a claim you cannot defend — which is the failure actually
observed here. So facts.yaml carries a second list:
never_claim:
- "Kubernetes in production - CKA planned, not yet hands-on"
- "Redis or MySQL in QuantBot - it uses flat files, no database at all"
claims.py reads it as a drift check, not a word list: a term counts only if
the rewrite contains it and the source fact does not. That distinction is what
makes it precise with no special cases — "Redis" is forbidden as a QuantBot claim
while appearing legitimately in a different project's fact text, and comparing
against the source tells the two apart on its own.
skill_rows — the keyword surfaceThe Technical Skills block is the densest keyword real estate on a resume, and it
is not model-written. It comes from facts.yaml, ordered against the JD:
skill_rows:
- label: "Infrastructure as Code"
match: [terraform, iac, cloud-init, provisioning, yaml]
text: "Terraform, end-to-end provisioning and lifecycle management across
four cloud providers including networking, compute, IAM and security
groups; cloud-init bootstrap; YAML, JSON"
match drives ordering — rows the JD asks for come first. An optional use_when
gates a row on JD terms, the way certifications already work. Write these once,
as prose you would defend; the generator never paraphrases them.
Applying to a company starts a 90-day clock. Another role there is reported with the date you applied, what you applied for, and when the company is worth approaching again:
COOLDOWN - applied to Cloudflare on 2026-09-09 (Systems Engineer);
next allowed 2026-12-08, 86 day(s) left
It never blocks the role. The posting still scores, still gets a tailored document, still reaches your queue — the cooldown is reported beside it and you decide. A filter that silently removes a job is indistinguishable from a pipeline that found nothing, which is a failure this codebase has had too many times to add another one deliberately.
The warning reaches every surface a role can be seen on: the Telegram message,
the review dashboard, the hosted queue, and the prepared .md itself. They all
render it through one function, so they cannot drift apart.
A second, separate signal covers work in flight:
IN YOUR QUEUE - Luxoft "Azure DevOps Engineer" is already queued and not yet applied
That is not a cooldown — it means you are about to prepare a second document for a company whose first one you have not sent. Different problem, different action, so it gets different words.
make cooldowns # every company you have applied to, and when each frees up
Companies are matched canonically, so Infosys, INFOSYS and
Infosys Limited are one company. The window is
thresholds.company_cooldown_days in profile.yaml.
Applications you made outside the pipeline — a referral, a direct email, a
recruiter — go in config/applied_companies.yaml (gitignored; copy the
.example). Those are the ones you are most likely to forget, because there is
no row to look at and nothing to click.
Notice period, current CTC, expected CTC, relocation, reason for leaving.
These are negotiating positions, not form fields. They surface as a reminder on
every prepared application, and you fill them in yourself. See HUMAN_ONLY in
screening.py.
Everything is free tier. No card, anywhere.
git clone https://github.com/Asit0007/JobPipe.git jobpipe && cd jobpipe
python3 -m venv .venv && source .venv/bin/activate
make install
make config # creates .env + your private config from the templates
config/profile.yaml and config/facts.yaml are gitignored — they hold your
work history and your salary target.
Paste your key into .env as GEMINI_API_KEY, then:
make doctor
It reports your tier, lists the models your key can actually call, and prints
the exact MODEL_SCORE / MODEL_TAILOR / GEMINI_RPM block to paste back into
.env. Re-run it until every check is green.
On the free tier Google may train on your prompts.
llm.pystrips emails, phone numbers, ID numbers and profile URLs from every prompt before it leaves the process. Add your employer's name toPII_DENY_TERMSin.env.
config/profile.yaml — titles, locations, thresholds, and the three reject
lists. Tune title_reject and hard_reject aggressively; every term you add
saves quota for free.
config/facts.yaml — see the facts gate. Nothing can be
prepared until at least one fact is verified. make doctor tells you the count.
make verify # pings every ATS slug, prints the dead ones
Prune whatever comes back DEAD, then add companies you actually want. Open a
careers page and read the URL:
| URL | put the slug under |
|---|---|
boards.greenhouse.io/SLUG | greenhouse: |
jobs.lever.co/SLUG | lever: |
jobs.ashbyhq.com/SLUG | ashby: |
Remotive, RemoteOK, Arbeitnow, Jobicy and Himalayas are global feeds rather
than company boards: no key, no slug, nothing in companies.yaml. They run on
every make ingest and contribute ~410 rows.
They are remote- and Western-skewed, so treat them as widening the funnel rather than as a primary source. Measured on one run, Jobicy had the best hit rate and RemoteOK returned nothing usable at all — the postings were genuinely off-target ("Gardener", "Equipment Maintenance"), not a broken adapter. It is kept anyway, because the prefilter discards a bad feed for free. Adding a weak feed costs ingest time, never quota.
Register at developer.adzuna.com, put the app id
and key in .env.
One call per entry in targets.titles, so 23 titles is 23 of your 1000 monthly
calls per run — fine daily, worth counting before you add more. Descriptions are
truncated to ~500 characters and that is permanent: the API hands back an
adzuna.*/land/ad/… redirect page rather than the employer's URL, and that page
refuses a plain GET, so jdfetch cannot enrich them. Scores are weighted down
to compensate.
If you are searching in India, this is the source that matters — 96% of its rows came back India-located, against a handful from the ATS boards.
Use IMAP with an App Password. It is free, has no expiry, and needs no Cloud project:
.env:
[email protected]
GMAIL_APP_PASSWORD=abcd efgh ijkl mnop
The spaces are fine — they are stripped.make gmail-imap-check PY=./.venv/bin/python — checks login, opens the
mailbox, runs the real alert query and prints what matched, by sender. A
credential that authenticates but matches nothing looks exactly like a
working setup until the pipeline reports "0 from 0 emails" and calls it a
success.Queries keep using Gmail search syntax — newer_than:3d,
label:"Job Alerts", -category:promotions all work. Gmail's IMAP server
implements X-GM-RAW, which takes a raw Gmail search string, so nothing had to
be translated into RFC 3501 and nothing about matching changed.
gmail.readonly is a restricted scope. An External OAuth app left in
Testing has its refresh tokens expired after 7 days, so a daily cron
silently stops pulling alerts every week. Neither escape works for a personal
project:
@gmail.com.The OAuth path still works and is used automatically when GMAIL_APP_PASSWORD
is empty, so an existing token keeps working through the switch. It is not a
base to build a cron on.
The scope is read-only. You configure the searches on their site; they email you; jobpipe reads your own inbox. Zero account activity on any platform.
Set up Naukri first. Its postings can be fetched, so those rows get a full
job description. LinkedIn, Indeed and Glassdoor cannot — LinkedIn's robots.txt
disallows /jobs/view/, and the other two serve a login wall to a plain GET.
Those rows keep whatever blurb the email carried and nothing more, so the
keyword prefilter stands aside for them rather than killing a row for having a
title it could not read.
/newbot → token into .env as
TELEGRAM_BOT_TOKEN/start). A bot cannot message you until
you have messaged it first, so this step is not optional.make telegram-check PY=./.venv/bin/python — validates the token and prints
your chat id from getUpdates, so you do not need a third-party bot for it.TELEGRAM_CHAT_ID, re-run, and it sends a real test message.Without a chat id the queue just prints to stdout. Note that an empty value
in .env reads as "configured" to a careless grep — check the value, not the
key.
make daily # everything below, in order, with the budget handled
make review # dashboard on 127.0.0.1:8080 -- you apply from here
make daily runs ingest -> score -> prepare -> pdf -> notify -> track -> readme-stats. Two things it does that running the stages by hand does not:
prepare to what the tailor chain can afford. prepare spends
2 calls per job (bullets + screening answers). The room is the sum of every
configured chain model's remaining daily budget, capped at the number of jobs
notify will queue.TAILOR_CHAIN, see
below): one probe per model, and a model that fails is benched for the rest of
the run instead of being retried five times against its 20-a-day cap. Only
the last model standing gets retries.make daily (05:00 IST, launchd) model calls
├── ingest
│ ├── ATS / job-board APIs (greenhouse, lever, ...) none 0
│ └── gmail_alerts: one parse per alert email MODEL_SCORE (flash-lite) ~1 per email
├── score: free prefilter, then one rating per survivor MODEL_SCORE (flash-lite) ~250-330 a day
├── prepare (per shortlisted job, up to notify's cap)
│ ├── tailor.run -> tailored bullets TAILOR_CHAIN, first to answer 1 per job
│ └── screening.generate_for -> screening answers TAILOR_CHAIN, first to answer 1 per job
├── pdf / tex (tectonic, local) none 0
├── notify (Telegram) none 0
└── track: replies from employers and recruiters
├── application mail: interview / rejection / ... MODEL_SCORE (flash-lite) 1 per email, once
└── recruiters, job-board messages and invitations none (rules) 0
TAILOR_CHAIN (config.py; ranked by the Artificial Analysis Intelligence Index, 2026-09-27)
gemini-flash-latest (= 3.8 Flash) -> gemini-3.7-flash -> groq:qwen/qwen3.8-27b -> gemini-3.6-flash
-> gemini-3.5-flash -> ollama:nemotron-3-ultra -> gemini-flash-lite-latest -> ollama:gemma4:31b
-> gemini-3.1-flash-lite -> zai:glm-4.7-flash -> ollama:nemotron-3-super -> groq:openai/gpt-oss-120b
-> ollama:gpt-oss:120b -> zai:glm-4.5-flash
Every call, any provider: redact() -> per-model daily budget + RPM window -> one request
-> appended to data/llm_usage.jsonl -> "AI models used, last 48 h" on the review dashboard
Only providers whose policy says they do not train on API prompts may join
the chain (Groq, Ollama Cloud, and since 2026-10-07 Z.AI; Gemini was here first and the
prompt is redacted before it leaves). Since 2026-10-07 the list can come from the shared
LLM_CATALOG (providers.json in the LLM-Catalog repo, read by ContentPipe too): only
its trainsOnPrompts: false entries are taken. llm.PROVIDERS holds the list with the policy it was checked
against; an unknown prefix such as mistral: is refused, never sent. A provider
with no key in .env is skipped.
A failing stage does not abort the rest, and the summary at the end names what broke. Run it after 12:30 IST -- Google rolls the free-tier day at midnight America/Los_Angeles, so a run started at 11:00 is on the previous quota day.
The stages are still individually available for when something needs redoing:
make ingest # pull every source, normalize, dedup
make score # free prefilter, then LLM on the survivors
make prepare # tailored bullets + screening answers -> .md, .tex, .json
make tex # rebuild .tex from the stored payload -- free, no LLM call
make pdf # compile the .tex to PDF -- free, skips what is current
make notify # push the day's shortlist to Telegram
make status # pipeline counts and remaining quota
make all is an alias for make daily.
prepare writes three files per job into out/, and they do different jobs:
| file | for |
|---|---|
.md | auditing. A rewritten bullet carries from Fxxx: with the original fact text underneath — that is how you catch a rewrite that drifted, and a PDF cannot show it. The line is emitted only when the rewrite differs from its source fact, so a document with no provenance lines is not missing its audit trail: it is one the model copied verbatim rather than tailored. |
.tex | sending. Carries the provenance map, the never_claim flags, a pre-send checklist and a block of automated checks as comments. |
.json | the validated payload, so the .tex can be rebuilt without spending an LLM call. |
make tex # rebuild every .tex from the stored payload -- free, offline
make tex JOB=56 # just one
make pdf JOB=56 # compile it
make pdf FORCE=--force # recompile even what is already current
make claims # show exactly what the never_claim gate matches on
make site # export the queue as an encrypted static site
make deploy # re-export and push to a static host
make site exports the queue for a static host (Vercel, Pages, anywhere). Two
things about it are deliberate:
The payload is encrypted, so the host does not matter. Network-edge auth
cannot cover the *.vercel.app domain a project is assigned automatically,
which serves the same files and is public; a Pages URL is public regardless of
repository visibility. So the export is AES-256-GCM under a PBKDF2-SHA256 key at
600k iterations, decrypted in the browser. The server only ever holds
ciphertext. Edge auth on top is defence in depth, not the load-bearing lock.
It is read-only. Marking applied is a database write and review_api.py
stays its only writer, so those buttons are removed rather than shipped inert.
Read the queue and the prepared documents anywhere; record the outcome with
make review at your desk.
notify reports deliveries, not attempts, and the difference is not
pedantry. Telegram's legacy Markdown treats _ as italic, so a real job title —
Linux Administration_94357 — opened a run that never closed and the API
answered 400 can't parse entities. The document stayed prepared with no
notified_at while the run printed "queued 15 for review". Interpolated
values are escaped now, a message that still cannot be formatted is retried as
plain text rather than dropped, and the count is of messages that actually
arrived.
A blank passphrase exits non-zero and the Makefile checks for ciphertext on disk before invoking any deploy — two independent gates, because the earlier filename-based safeguard was not one.
make deploy asserts the site actually serves the payload afterwards, and
that is not paranoia. Driven without a TTY, vercel --prod skips its setup
prompt and uploads nothing while still creating a deployment that reports
Ready — four consecutive production deployments once did exactly that, all
Ready, all serving 404, while a 220 kB payload.enc sat on disk. --yes fixes
it; verify-deploy proves it, by curling the payload and failing on anything
that is not a 200 with a real body.
Do not connect a Git integration to this repo. The exported site is
gitignored — it is the entire job hunt — so a build triggered from the repo root
produces nothing, deploys empty, and takes the production alias away from the
good CLI deployment. Measured here over twelve days: every source: cli
deployment had files, every source: git one was empty, and each empty one
matched a commit push. Pushing code silently un-deployed the dashboard, and
nothing noticed because the site had been verified once and nobody re-checked.
That is the general lesson: a verified deploy is not a durable one.
verify-deploy answers "did my deploy land", never "is the site up now" —
and those two diverge the moment anything else is allowed to deploy.
PDF needs a TeX engine. brew install tectonic is the light option — one
binary that fetches only the packages a document uses. make pdf reports the
page count and warns past thresholds.max_resume_pages in profile.yaml.
That limit is 3, because the body is 14pt. The two are one decision, and
they have to be changed together. article silently ignores a [14pt] option
and falls back to 10pt — the class is extarticle, which honours it. When the
font went up and the threshold did not, make pdf warned on 101 of 101
documents, and a warning with no negative case is not a signal; it is
decoration that teaches you to skip the line. If you set the body back to
10 or 11pt, drop max_resume_pages with it.
make pdf only compiles a .tex newer than its .pdf. daily runs it
over every prepared document every night, which was 60 compiles to reproduce
45 byte-identical files — and it reset every PDF's mtime, so "which of these
is new" stopped being answerable from a directory listing. The freshness check
only means anything because the writers are content-addressed too: make tex
rewrites a file only when the bytes actually differ, otherwise one no-op
rebuild would invalidate every PDF again. Measured on 60 documents: a repeat
make tex touches 0 of 240 files, and the following make pdf compiles
nothing. FORCE=--force overrides.
What the .tex checks for you. Resume advice is mostly rules nobody verifies.
These are verified, and reported in the file:
| check | why |
|---|---|
| a rewrite that introduces a technology its source fact does not name | the only drift this project has observed: a Windows fact came back as "Windows and Linux servers" |
| a rewrite that introduces a number its source fact does not state | an "L1/L2 on-call" fact came back as "24/7 on-call support" — a shift pattern nobody wrote down |
a never_claim term the rewrite introduced | dropped outright, not flagged |
| personal pronouns | found "I have hands-on experience…" in a document about to be sent |
| a missing teamwork signal | scanners look for collaboration language; 3 of 9 documents had none |
| summary word count against the 70–90 band | 9 of 9 documents were 33–49 words |
| page count | read out of the TeX engine's own log, judged against max_resume_pages |
make claims prints exactly what the gate matches on, per rule — the terms are
extracted heuristically from prose, so they are worth auditing rather than trusting.
Telegram pings you once a day with up to fifteen roles. Each shows the fit score, the deciding reason, your genuine skill gaps, and a link.
When someone writes back, the same run sends one more message: interviews and
offers first, then recruiters, application updates and rejections. It reads the
whole last 14 days of mail rather than the newest few, so a reply is not lost to a
busy inbox or a day the job did not run, and it reports each email once. A
recruiter writing straight to your inbox counts, not only replies to applications
you marked here, and so does a recruiter's Naukri mail or Cutshort reply; Naukri's
job mail collapses into one line of titles. So does a company reaching out through
a board (a foundit invitation to apply, a Shine shortlist, an employer's Cutshort
message, Naukri's "recruiters expressed interest"), one line per board, and a
named recruiter whose mail Gmail filed under Promotions. "Thank you for applying"
never reaches you. cli track --dry-run lists what each new email would count as,
without asking a model or sending anything.
Open make review, expand the prepared bullets and screening answers, apply on
the company's site, click Mark applied.
Budget 45 minutes. Fifteen tailored applications beat two hundred blind ones — and the applying is the part no pipeline can do for you.
One thing this tool cannot do. Referrals convert roughly 10–20× better than cold applications, and jobpipe is deliberately optimising the second-best channel. Don't let a wall of green checkmarks substitute for messaging someone who works there.
Recommended — an always-free Oracle Cloud ARM box:
rsync -av --exclude .venv --exclude data --exclude .git ./ ubuntu@<ip>:~/jobpipe/
ssh ubuntu@<ip> 'cd ~/jobpipe && bash deploy/oci-setup.sh'
The dashboard binds to 127.0.0.1 only. Reach it through a Cloudflare Tunnel
behind Cloudflare Access — no open ports, no ingress rule. deploy/crontab
drives the schedule via supercronic.
The full run is at 14:00 IST, and the hour is load-bearing. Google rolls the free-tier day at midnight America/Los_Angeles — 12:30 IST in PDT, 13:30 in PST — so a breakfast run sits on the previous quota day and inherits whatever an evening session already spent. On the tailor model, whose free cap is 20 calls a day, that is routinely all of it. Reply tracking also runs at 07:00 and 19:00; each email it has already handled is skipped, so the extra runs cost nothing.
Simplest of all — just run it yourself. make daily on the machine you
already review from. No infrastructure, the database is a file on your disk, and
nothing leaves the box but the API calls. This is the default, and for one person
applying to fifteen roles a day it is usually the right answer.
./deploy/build-launcher.sh # build the launcher app, once
cp deploy/com.asitminz.jobpipe.daily.plist.example \
~/Library/LaunchAgents/com.asitminz.jobpipe.daily.plist
# replace __REPO_ROOT__ and __HOME__ in that file, then:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.asitminz.jobpipe.daily.plist
launchd, not cron. A laptop is usually asleep at the scheduled minute, and
cron simply drops that run — no catch-up, no error, nothing to notice. launchd
runs a missed StartCalendarInterval job on the next wake.
Asleep, not powered off — and the difference is the whole schedule. Measured
2026-09-12 on the machine this was built for: it was shut down for 37 hours, so
12:35 passed twice with the power off. launchd replayed neither firing —
runs = 0 seven minutes after the next boot, and no data/logs/daily-<date>.log
for either day. So the catch-up above covers a shut lid and nothing more, and a
machine that gets shut down is, for scheduling purposes, a machine with no
scheduler. That is the argument for the always-on box, and it is the reason to
read status rather than trust the schedule: runs = 0 is the symptom, and
it looks identical to a job that was never installed.
Two things will otherwise fail silently, both measured on macOS 14:
~/Documents, which macOS guards with TCC, and
launchd has no grant there. Measured: a LaunchAgent could not list the repo,
read .env or the database, or even exec the run script — exit 126 —
while the identical script from Terminal ran fine, because Terminal holds a
Documents-folder grant and launchd does not. cron fails identically; this is
not a launchd quirk. A grant has to attach to a code identity, so
deploy/build-launcher.sh builds one: a tiny ad-hoc-signed Mach-O in
~/Applications/JobPipe Daily.app whose only job is to exec
deploy/run-daily.sh. Add that app under System Settings → Privacy &
Security → Full Disk Access; children inherit the attribution, which is how
the venv python underneath ends up able to read the repo. It has to be
compiled — a shell script with a shebang puts the grant back on /bin/bash,
handing full disk access to every background shell on the machine.tectonic lives in Homebrew, which is not on launchd's PATH
(/usr/bin:/bin:/usr/sbin:/sbin). The pdf stage tolerates a missing engine on
purpose, so PDFs would just stop appearing with no error at all.
deploy/run-daily.sh prepends /opt/homebrew/bin, sets PYTHONUNBUFFERED=1
— a killed run that cannot say how far it got is the worst kind — and writes
data/logs/daily-<date>.log, stamped with the Pacific quota date, pruned at
30 days.The launcher takes its command explicitly and refuses to default. Adding the
app to a Privacy pane launches it, and while it still defaulted to daily that
fired a full unattended pipeline run. The LaunchAgent passes daily; nothing
else can imply it.
The hour is a trade. The quota roll is 12:30 IST on PDT and 13:30 IST on PST,
so the OCI crontab's 14:00 always starts on a full Gemini quota. The Mac runs at
05:00 IST (owner, 2026-10-27), on the previous quota day, and leans on the
Groq/Ollama fallback in TAILOR_CHAIN when Gemini is already spent.
GitHub Actions is a manual escape hatch, not a scheduler.
.github/workflows/pipeline.yml is workflow_dispatch only, deliberately:
profile.yaml and facts.yaml are gitignored because they name your
employer; running here means putting them in repository secrets.Both are survivable for a one-off catch-up run you are watching. Neither is
survivable unattended — which is why the schedule is gone, and why it had failed
every run for weeks before anyone checked. To use it: set PROFILE_YAML,
FACTS_YAML and GEMINI_API_KEY under Settings → Secrets and variables →
Actions (or run make arm-ci, which does the same thing with gh and prints
the browser steps if you do not have it), then press Run workflow.
Not Vercel. Rate-limited scoring of 100 jobs takes 8–10 minutes against a
~60s function ceiling, and the SQLite file would reset on every invocation.
See deploy/VERCEL.md.
| Gemini | free tier — 20 requests/day per Flash model, 500 per Flash-Lite model, 5-15 RPM |
| Groq, Ollama Cloud, Z.AI (optional, tailor chain) | free tiers; Groq 1,000 requests and 200k tokens a day per model, Ollama monthly credits, Z.AI's GLM Flash models free (limits unpublished) |
| ATS APIs | public, no key, no quota |
| Adzuna | free tier, 1000 calls/month |
| Gmail IMAP | free, read-only, App Password (no expiry) |
| Telegram | free |
| Hosting | OCI always-free ARM |
| total | $0 |
A daily budget counter persists to disk behind a file lock, so a runaway retry loop can't burn the quota at 3am and leave you with nothing at 9am. The rate limiter and the counter are shared across processes — the scheduler and a manual run can overlap without doubling your real request rate.
Two things about that quota are easy to get wrong, and both were learned the expensive way:
MODEL_SCORE and MODEL_TAILOR each
get their own 500/day, so the counter tracks them separately. A single shared
counter meant a long score run locked out prepare for no reason.An alias and a pinned name draw on the same quota. gemini-flash-latest
(which answered as gemini-3.8-flash on 2026-09-27) and the pinned Flash models
each allow 20 requests a day; the Flash-Lite ones 500. The counter keys on
the name, so the chain lists the alias or the pinned name for a model, never
both.
config/profile.yaml search definition, three reject lists, thresholds
config/facts.yaml the verified-fact menu
config/applied_companies.yaml companies applied to outside the pipeline — the anti-hallucination boundary
also: skill_rows, never_claim, blockers, per-project use_when
config/companies.yaml ATS slugs (the part you maintain by hand)
scripts/doctor.py preflight: key, tier, models, config, integrations
src/jobpipe/
llm.py Gemini (raw REST) + Groq / Ollama — the tailor chain, rate limit,
budget, PII redaction
usage.py per-call model usage log + the dashboard's 48-hour summary
sources/ greenhouse · lever · ashby · adzuna · gmail alerts
remotive · remoteok · arbeitnow · jobicy · himalayas · weworkremotely
normalize.py canonicalisation, fingerprinting, fuzzy dedup
jdfetch.py descriptions for postings that arrive without one
score.py free prefilter → LLM scoring
tailor.py fact-bounded tailoring + the validation gate
screening.py screening answers; human-only questions carved out
notify.py Telegram review queue
track.py replies from employers and recruiters -> Telegram, follow-up nudges
review_api.py dashboard; the sole writer of status=applied; /api/models
deploy/ OCI setup, crontab, cloudflared example
run-daily.sh + launcher app + LaunchAgent for macOS
Stack — Python 3.14 · SQLite · httpx · FastAPI · Gemini (raw REST) · Groq · Ollama Cloud · rapidfuzz · Docker Compose · Cloudflare Tunnel
make test # 409 tests