Inspiration
Job hunting and freelance prospecting are the same task wearing two hats: read a firehose of postings, work out which ones you actually qualify for, and write something tailored for each one. The reading is mechanical. The writing is the part that matters — and it's the part everyone skips by the fortieth application.
Every "AI job agent" I looked at either mass-applies (which gets your account banned and your application ignored), or is a chat window that gives advice and leaves the work with you. I wanted the opposite: something that runs on a schedule without me, ends in a concrete artifact — a print-ready tailored resume, a cover letter, a three-paragraph client pitch — and stops one click short of sending it, because the send is the human's job.
The second thing that bothered me: most pipelines are only as good as the source list a human typed into a config file. If the agent can reason, it should be able to find its own sources too.
What it does
TalentOS is an autonomous opportunity intelligence platform with two pipelines sharing one profile, one auth, and one workspace.
TalentOS // Careers ingests ~2,900 postings per run from four public ATS platforms (Greenhouse, Lever, Ashby, SmartRecruiters), direct company portals (Workable, Workday, Oracle, iCIMS, Taleo, SuccessFactors), and open aggregator feeds. It dedupes, pre-filters deterministically, evaluates each survivor against your hard and soft requirements, and drafts a tailored HTML resume and cover letter for every match.
TalentOS // Studio does the same for freelance work: r/forhire RSS plus a bounded, profile-led search-grounded discovery pass, scoring client pain points against your verified portfolio and producing a targeted pitch with a suggested rate and a deep link to send it yourself.
Two things it does that I haven't seen elsewhere:
It writes its own source list. Board Scout runs a search-grounded pass to propose ATS boards nobody configured — then refuses to trust its own output. Every proposal is called against the real Greenhouse/Lever/Ashby/SmartRecruiters API and must return live postings before it is written to the registry. A hallucinated board returns zero jobs and is discarded. Coverage grows between runs without anyone editing an environment variable, and only through sources the system has independently verified exist.
It cannot overspend, even under attack. Every bounded model call reserves its worst-case cost against a per-user and a fleet-wide Firestore ledger — in one transaction, before Vertex is invoked — then settles exact reported usage after. Two concurrent runs cannot spend the same remaining dollar. Both ceilings exist because they fail differently: a per-user cap stops one runaway account, but only a fleet cap survives unlimited account creation.
Every non-match is logged with the specific unmet requirement, so you learn whether the gap is your resume or your filter.
How we built it
The architecture rests on one tenet: the model is not the control flow.
Ingestion, deduplication, pre-filtering, rate caps, and persistence are ordinary Python inside LangGraph state graphs. Gemini 3.6 Flash on Vertex AI (global endpoint) is invoked through Google ADK only for the two things that need judgment — qualitative fit evaluation and drafting — with an isolated session per job and structured Pydantic outputs.
- Compute: Cloud Run (private, OIDC), triggered by Cloud Scheduler on a 6-hour cadence
- State: Firestore Native mode, multi-tenant collections (
jobs,applications,leads,pitches,runs) - Observability: Langfuse v2 with a zero-overhead offline fallback
- Frontend: Next.js 15 App Router, React 19, Tailwind on Firebase App Hosting
Cheap filters run before expensive ones. On a measured 2,575-posting sweep, 2,059 were dropped on title and 245 on seniority — 89.5% of the corpus eliminated for the price of a string comparison, every drop counted and reported by reason.
Verdicts use strict three-state logic: MET, UNMET, or NOT STATED. Silence in a job description is never treated as a rejection — that single distinction is the difference between a useful filter and one that throws away half your real matches.
And the guardrail that shaped the whole design: no direct bot submission. Upwork bans it outright. LinkedIn prohibits automated actions. The agent does all the work — finding, matching, drafting, personalizing — and the send stays a human click. That isn't a compromise on autonomy; it's the only version of this that survives contact with a real platform.
Challenges we ran into
The transcript that ate the budget. The first version drove everything as one conversation: fetch, loop, evaluate, digest. Every evaluated job's description stayed in the transcript and was re-sent on every later turn. A 10-job run billed 285,893 input tokens for roughly 20,000 tokens of actual job text. Moving the loop into Python with a fresh session per job brought the same run to 36,074 — 7.9× less input, $0.504 → $0.134. It also made a wrong verdict debuggable, because it is now one request containing one job.
A verdict split in half. A 500 in production: Invalid JSON: EOF while parsing, on an input starting mid-array. The response had arrived in two parts, split right after "unmet_requirements":, and the reader kept only the last one — handing Pydantic the tail of its own JSON. Every existing test stubbed that function, so nothing exercised the loop that assembles the response. Fixed by joining the parts and excluding thought parts — and, the more important half, making one unparseable verdict cost a single re-evaluation next run instead of aborting the whole batch.
Every request ran as me. require_user_scope was a sync generator dependency, so FastAPI ran it in a threadpool. The ContextVar was set in the worker thread's copied context, which never propagates back. In public-preview mode that meant any signed-in visitor was reading and writing the owner's profile, jobs, and materials — on the owner's unmetered budget tier. The old test drove the generator by hand, which is exactly why this survived: the threadpool hop only exists when FastAPI wires the dependency. Making it async fixed it, and three tests now issue real requests through the ASGI stack.
Three feeds that failed silently. Studio was ingesting nothing at all. Reddit serves its .rss endpoint as Atom — the parser looked for RSS <item>, matched nothing, and returned an empty list from a completely successful fetch. Contra's public project API 404s and its pages are client-rendered shells. We Work Remotely's contract category 301s with no Location header. A source that returns zero results and no error is worse than one that crashes.
Knowing when not to optimize. Evaluation is ~89% of a run's model cost, so it's the obvious place to drop in a cheaper model. I built a benchmark before doing it — and the measured answer was don't.
Accomplishments that we're proud of
The benchmark is the one I'd point a judge at. On the same 20 real postings:
| Model | Matches found | Cost | Agreement |
|---|---|---|---|
| gemini-3.6-flash (reference) | 5/20 | $0.227 | — |
| gemini-3.5-flash-lite | 0/20 | $0.029 | 75% |
| gemini-2.5-flash-lite | 3/20 | $0.009 | 80% |
Both lite tiers fail in the direction that loses jobs. All five of 3.5-flash-lite's disagreements were postings it skipped that the reference matched — and its reasoning shows why: it treats PREFERRED qualifications as hard requirements ("falls short of the preferred 8+ years"), which the evaluator instructions explicitly forbid. A false MATCH costs one wasted drafting call; a false SKIP hides a real job from the user entirely. That asymmetry is why EVALUATOR_MODEL is never changed on vibes.
Also proud of: 373 hermetic offline tests running in ~11 seconds — no credentials, no network, verifiable from a fresh clone in under a minute. A source registry that expands itself but validates before it trusts. And a spend guard that is enforced in code and covered by tests/test_spend_guard.py, not promised in a README.
What we learned
Every claim should be checkable in under a minute. The README's evidence table exists because "it's fast" and "it's cheap" are worthless next to a command that prints the number.
Measure before you swap a model. The intuitive optimization — cheaper model on the hot path — was wrong, and only a benchmark that reported disagreements weighted by direction could show that.
Silence is the dangerous failure. The r/forhire bug, uncounted pre-filter drops, the job that went unevaluated — each was a system reporting success while doing nothing. Now every drop is counted by reason, and a failed source says so out loud.
Test the thing the framework actually wires up. Both the auth bug and the split-verdict bug hid behind tests that bypassed the real code path.
What's next for TalentOS
- Contact discovery beyond public sources — currently a fallback path; making it reliable without touching anything that prohibits scraping is the interesting constraint.
- Outcome feedback into the evaluator — the system knows what it matched, but not yet what got a reply. Closing that loop turns match strength from a judgment into a measurement.
- Board Scout at wider scope — company-portal discovery, not just the four ATS platforms, under the same validate-before-trust rule.
- Per-user scheduling and digest cadence, so runs follow the user's rhythm rather than a single global cron.
- A published evaluator eval set, so the benchmark is reproducible by anyone, not just against my own reference run.
Built With
- fastapi
- firebase
- firestore
- gemini
- google-adk
- google-cloud
- langchain
- langfuse
- langgraph
- next.js
- pytest
- python
- react
- tailwindcss
- typescript
- uvicorn
- vertex-ai
Log in or sign up for Devpost to join the conversation.