I automated my entire job search with AI — here's the full stack I built
Manual job searching is genuinely miserable.
You open LinkedIn. You scroll. You find something promising. You copy-paste
your resume into their portal, tweak the cover letter for 20 minutes, submit,
and hear nothing for two weeks. Then you do it again. And again.
I got tired of it, so I built a tool that does the entire pipeline for me:
search → AI score → tailor resume → generate cover letter → email me the
best matches.
Here's how it works and what I learned building it.
What it actually does
- Scrapes jobs from LinkedIn, Indeed, Glassdoor, ZipRecruiter, and Google Jobs simultaneously
- Scores each listing against your resume using Gemini AI (0–100% match, with reasoning and a "missing skills" list)
- Skips low matches — anything below your threshold (default 75%) gets stored but not processed further
- Tailors your resume bullet points to the specific job description
- Writes a cover letter personalised to the role and company
- Emails you a summary of the best matches with the tailored documents attached
The whole pipeline runs in the background. You set it once, and it finds and
evaluates jobs while you sleep.
The tech stack
| Layer | Tech |
|---|---|
| Backend | FastAPI (Python 3.11) |
| Frontend | React + TypeScript + Tailwind CSS |
| Database | SQLite via SQLAlchemy ORM |
| AI | Google Gemini API |
| Job scraping | jobspy |
| Resend SMTP | |
| Auth | httpOnly cookie sessions + bcrypt |
| Hosting | VPS via Coolify + Cloudflare CDN |
Nothing exotic — the interesting parts are in how the pieces connect.
The scraping layer
I'm using jobspy, an open-source Python library that wraps LinkedIn,
Indeed, and others into a single interface. It returns a pandas DataFrame
which I convert straight to dicts:
from jobspy import scrape_jobs
jobs = scrape_jobs(
site_name=["linkedin", "indeed", "glassdoor"],
search_term="React Developer",
location="Remote",
results_wanted=15,
hours_old=72,
linkedin_fetch_description=True, # full JD, not just snippet
)
linkedin_fetch_description=True is the one flag that matters most — without
it you get a 2-sentence snippet, not the actual job description. It also makes
searches 2–3x slower because each listing needs a separate HTTP request, but
the AI scoring is useless without the full text.
The AI scoring pipeline
Each job gets sent to Gemini with the user's resume and the full job
description. The prompt asks for a structured JSON response:
prompt = f"""
You are a recruiter. Given this resume and job description, return a JSON
object with exactly these fields:
- score: integer 0-100 (match percentage)
- reasoning: 2-3 sentence explanation
- missing_skills: list of strings (skills in JD not in resume)
Resume:
{resume_text}
Job Description:
{job_description}
Return only valid JSON. No markdown fences.
"""
Gemini is surprisingly good at this. The scores correlate well with what a
human recruiter would say — a senior dev resume against a junior listing gets
95+, the same resume against a role requiring 5 years of Java when you've
never touched it gets 20–30.
I added an exponential-backoff retry wrapper because Gemini's free tier hits
rate limits when processing a batch of 15–20 jobs in parallel:
async def call_gemini_with_retry(prompt: str, max_retries: int = 4):
delay = 2
for attempt in range(max_retries):
try:
response = await model.generate_content_async(prompt)
return response.text
except ResourceExhausted:
if attempt == max_retries - 1:
raise
await asyncio.sleep(delay + random.uniform(0, 1))
delay *= 2
Streaming results with SSE
Waiting 5–8 minutes for a search to finish with no feedback is a terrible UX.
I switched the search endpoint to Server-Sent Events so the frontend sees
jobs appear in real time as each platform finishes:
@router.post("/search")
async def search_jobs(payload: SearchPayload, ...):
async def event_stream():
for platform in payload.platforms:
jobs = await scrape_platform(platform, payload)
for job in jobs:
scored = await score_job(job, user.resume_text)
yield f"data: {json.dumps(scored)}\n\n"
yield "data: {\"done\": true}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
On the React side, the EventSource API processes each job as it arrives
and appends it to the feed — the list builds itself as you watch.
The bug that humbled me
About two days after launching, a user reported seeing someone else's jobs
in their feed.
I panicked.
After two hours of debugging, I found the root cause: Cloudflare was
caching /api/jobs responses by URL, completely ignoring the auth cookie.
The sequence that caused it:
User A logs in, hits /api/jobs → Cloudflare caches the response
User B logs in, hits /api/jobs → Cloudflare serves User A's cached data
It only happened after a cache purge (which I triggered on every deployment)
because the first request after a cold cache got stored. The fix was two lines
on every API response:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
response.headers["Vary"] = "Cookie, Authorization"
Plus a Cloudflare Cache Rule to bypass caching entirely for /api*. The
private directive tells any intermediary (Cloudflare, ISPs, proxies) that
the response is user-specific and must never be shared. The Vary: Cookie
tells it that even if it wanted to cache, responses differ per cookie value.
Lesson: never assume a CDN won't cache your authenticated API endpoints.
Always set cache headers explicitly.
The freemium model
I added a tier system to avoid burning through my Gemini API credits. The
backend is straightforward:
TIER_LIMITS = {"free": 3, "premium": 10}
def check_rate_limit(user: User):
today = date.today().isoformat()
if user.last_search_date != today:
user.daily_searches_used = 0
user.last_search_date = today
limit = TIER_LIMITS.get(user.tier or "free", 3)
if user.daily_searches_used >= limit:
raise HTTPException(429, f"Daily limit of {limit} searches reached")
user.daily_searches_used += 1
The reset logic compares ISO date strings — no timezone handling needed for
a daily counter. Admins bypass the check entirely.
What I'd do differently
Use PostgreSQL from day one. SQLite is fine for a solo project but
migrating later is painful. The moment you want concurrent writes or proper
JSON queries, you'll wish you started with Postgres.
Abstract the AI layer earlier. All Gemini calls are currently scattered
across scorer.py, tailor_service.py, etc. with duplicated retry logic. A
single ai_client.py with a clean interface would have saved a lot of
copy-paste.
Add observability before users arrive. I had no logging, no error
tracking, nothing. When the Cloudflare bug hit, I was debugging blind. Even
a free Sentry integration would have surfaced the issue immediately.
Try it / look at the code
The project is open source and free to use:
Live: workfinderx.sanambir.com
GitHub: github.com/Sanambir/AutoJob-Finder
If you're in a job search right now, give it a try. If you're a developer,
the codebase is a reasonable example of a full-stack FastAPI + React app
with AI integration, SSE streaming, and a freemium tier system.
Built with Claude Code
I vibe coded the majority of this with Claude Code
— Anthropic's AI coding assistant that works directly in the terminal and
your editor. Most of the backend routers, the React components, the Gemini
integration, and the Cloudflare debugging were done in conversation with it.
If you're building a side project and haven't tried AI-assisted development
yet, it's worth it. The iteration speed is genuinely different — you spend
more time on product decisions and less time looking up syntax or boilerplate.
Questions or feedback welcome in the comments.
Top comments (0)