A job search agent that runs on my own PC and never applies for me

It checks my sources every morning, scores new postings against my profile and drafts cover letters. Every decision stays with me.

aiautomationjob-searchself-hosting

Job hunting has a boring part and a part that needs judgement. The boring part is checking the same twenty sources every morning, skimming ads that are obviously wrong, and keeping track of what you’ve already seen. The judgement part is deciding whether a role actually fits, and writing a letter that doesn’t sound like everyone else’s.

I built an agent for the boring part. It runs every morning on my own PC, reads new postings, scores them against a written profile of what I want, and sends me a short list on Telegram. It drafts cover letters for the good ones. It never applies for anything, and it never logs into LinkedIn or Xing. I make every decision that matters.

This post explains what it does, the files it works from, what it’s good for, and how to set one up.

What it does

Every morning at 07:30:

  1. Collects new postings from fixed sources:
    • RSS feeds in a self-hosted FreshRSS instance: job boards, company career pages, saved searches
    • a small script that reads career sites built in JavaScript through the JSON API they use internally
    • job-alert emails sent to a dedicated mail alias
  2. Drops what it has already seen, by checking each URL against a tracker file.
  3. Scores each new posting from 0 to 100 against my profile. The output is a fixed JSON structure: score, must-haves met, gaps, deal-breakers, language, a one-line summary.
  4. Looks up the commute in a pre-computed table of real public-transport journey times. It never estimates one.
  5. Writes every scored posting to tracker.csv, rejects included.
  6. Sends me a Telegram message: full detail for anything above 70, one line each for 50–70, and just a count below 50.
  7. Drafts a cover letter for each job above 70, using only facts from my CV and an achievements file.

A typical good day looks like this:

1/1 · Feldmark Logistik Software — Senior Product Owner (m/w/d). Offenbach, ~35 min. €72–85k stated. Score 81, no deal-breakers. Gaps: Azure DevOps (I list Jira), no warehouse-domain experience. Cover letter drafted.

A typical bad day is three lines saying nothing cleared the bar. That’s the point: a short, honest list beats a long one padded with near-misses.

The model, and why it’s local

The agent runs on Hermes Agent, an open-source agent framework from Nous Research. The model behind it is Qwen 27B, served by llama.cpp on my own PC.

A job search involves a CV, salary expectations, notice periods and a list of employers I’ve talked to. None of that needs to leave the house, so none of it does. The model’s computer is the same computer the files sit on.

The files

The agent’s behaviour comes almost entirely from a handful of plain Markdown and CSV files. That’s deliberate: I can read, edit and version every rule it follows.

cv.md: my CV as plain text. This is one of the two sources of facts about me.

profile.md: what counts as a good job:

  • target titles in German and English, plus titles that look similar but are wrong (for me, sales roles disguised as “consulting”)
  • current and target seniority
  • home base, maximum commute, acceptable office days, remote yes or no
  • minimum and target salary, notice period, earliest start
  • deal-breakers, listed explicitly

Vague entries produce vague shortlists. “Senior or Lead, not Junior, not Head of” works far better than “senior roles”.

achievements.md: the only claims the agent may use in a cover letter. Each entry is two or three lines: the situation, what I did, what changed, with a number wherever I honestly have one. Each is tagged with keywords that match job ads. The header carries the rule that does the most work in the whole setup: if it isn’t here or in cv.md, it doesn’t go in a letter. A model with nothing to embellish has nothing to embellish with.

templates/cover-letter-en.md and cover-letter-de.md: the structure of a letter plus instructions:

  • one page
  • two or three achievements that match the ad’s top requirements
  • no “I am writing to apply”, no “passionate”
  • gaps are listed in notes to me, never in the letter

tracker.csv: one row per scored posting: date, source, company, title, URL, location, work pattern, salary stated or not, language, score, deal-breaker, status, applied date, response, notes. It doubles as the duplicate filter and becomes the record of the whole search.

SOUL.md: the agent’s standing instructions. Hermes reads it at the start of every session. The important lines:

  • your sources are exactly these two scripts; never search the web for jobs
  • if a source fails, say so in the first line, score what you got, and stop
  • every job gets the JSON; no JSON, no row in the tracker
  • never estimate a commute; never invent a salary; never submit an application

The supporting scripts

Three small Python scripts do everything that has one right answer:

  • fetch_jobs.py asks FreshRSS for unread items only, through its Google Reader-compatible API, skips URLs already in the tracker, and marks the rest as read. It prints nothing when there’s nothing new, so a scheduled run that finds nothing wakes nobody.
  • fetch_ats.py handles career sites that load their listings with JavaScript. You open the browser’s developer tools once, copy the request the page makes (“Copy as cURL”), and the script turns it into a reusable source, including refreshing short-lived access tokens.
  • commute.py builds a lookup table of real journey times from my home station, using the regional transport operator’s journey planner API and its official station list. The agent reads the table; it never asks the model how long a trip takes.

The split is the main design decision: code computes, the model judges, I approve. Fetching, deduplicating, commute times and the tracker are code. Deciding whether a role fits is the model. Applying is me.

What it’s actually good for

  • Time. The morning check takes the length of a Telegram message instead of an hour of tabs.
  • Consistency. Every posting is measured against the same written profile. That’s harder to do by hand on day forty than on day one.
  • Better letters, faster. The drafts start from my real achievements, matched to the ad. I still rewrite them, but from a decent second draft rather than a blank page.
  • A record. After a few weeks, the tracker shows which sources produce anything useful, and which of my deal-breakers cut the most.
  • Privacy. Nothing about my search leaves my machine.

What went wrong, and the fixes

It improvised when its sources broke. One morning two of four sources were down: an expired token and a mail login failure. Instead of saying so, the agent went searching the web, found a job on an aggregator that reposts ads, and scored it 64 with “commute unknown”. The report read like work. The fix was the “your sources are fixed” section in SOUL.md. Now a broken source is the first line of the report, and the agent stops there.

Its structured output drifted into prose when things went wrong. Scores ended up in sentences instead of JSON, and only some jobs reached the tracker. Hence the rule: no JSON, no row.

The model guessed journey times. Confidently, and wrongly. That’s why commute times come from a table.

Context size matters. Hermes needs at least a 64K-token context. With 32K it won’t start.

How to set it up

You need a PC with a GPU that can run a mid-sized model; I use 24 GB of VRAM for a 27B model, and smaller models work with weaker scoring. You also need some patience with the command line, and a messaging app if you want the morning report on your phone. Hermes supports Telegram and others.

1. Run a local model. Start llama-server from llama.cpp with your model, a 64K context and one parallel slot:

llama-server -m your-model.gguf --jinja -c 65536 -np 1 --port 9931

One slot means the full context goes to one conversation. It also means scheduled jobs queue behind each other, so stagger their times.

2. Install Hermes and create a profile just for the job search, so its memory and rules stay separate from anything else you use Hermes for:

hermes profile create scout

Point the profile at your local server as an OpenAI-compatible endpoint. Don’t add any cloud API key or fallback model to this profile. That’s what keeps the data local.

3. Write the Markdown files. Start with profile.md and achievements.md. These take the most effort and matter the most. Put them, with cv.md, the templates and an empty tracker.csv, in a folder the profile can read. Then write SOUL.md with the rules above.

4. Set up your sources.

  • RSS: subscribe to job feeds in FreshRSS under one category, for example “Jobs”, and enable its API in the settings. For career pages without a feed, FreshRSS can scrape a page’s listings with XPath. For pages built in JavaScript, use the cURL approach.
  • Email alerts: create the alerts on job boards, sent to a dedicated address the agent reads.
  • LinkedIn and Xing: their terms prohibit bots, so the agent never logs in there. Their own email alerts are the safe route.

5. Build the commute table once, for the stations near the employers you care about. Rebuild it when you add a new area.

6. Test by hand before you schedule anything. Run the fetch scripts and check the output files. Then ask the agent in a chat session to score today’s postings, and compare its scores with your own judgement on ten real ads. Adjust profile.md until you mostly agree. This step decides whether the whole thing is useful.

7. Schedule it. Create a daily job in the profile, for example at 07:30, that runs the two fetch scripts and then the scoring. Check it was really created with hermes -p scout cron list.

8. Read the report every morning, and keep editing the profile. The first week will surface deal-breakers you never wrote down.

What I’d tell someone starting

Spend your time on profile.md and achievements.md, not on the model. A precise profile and honest achievements matter more than which model you run. Keep everything with one right answer out of the model’s hands. And keep the final click yours: the agent finds and drafts, and you decide.

← All posts