An API-based Claude agent that uses Python code with a SQLite database to match jobs to a resume.
Please forgive me for the clumsy tutorial, I'm updating it as I go (for now).
This is a work in progress. (Note I haven't uploaded files in development yet).
job-search-AI-agent/
├── Claude.md # AI prompt for Claude to score jobs based on resume
├── Script.txt # Project notes and commands
├── GitAI.bat # Git automation script
├── Claude/ # Python code generated by Claude for API usage
├── profile/
│ ├── og-resume.md # Master resume
│ └── projects.md # Portfolio projects
├── data/
│ ├── new_jobs.md # Current list of jobs to be scored by Claude
│ └── watchlist.json # Target companies and ATS platforms (moving to database)
├── daily-digest/ # Daily AI-generated files (postings URLs, company suggestions)
├── Excel/
│ └── New_jobs.xlsx # Excel export of job list (for testing)
├── Logs/
│ └── Errors_log.txt # Runtime error log (usually wrong slug in watchlist)
├── Database/
│ └── jobs.db # SQLite database (with tables new_jobs and jobs_hist)
├── scripts/
│ ├── Claude_API.py # Claude API integration
│ ├── check_boards.py # Queries APIs for supported ATS job boards
│ ├── Creates_jobs_AI_input.py # Builds the AI input (.md) from the new_jobs table
│ ├── Creates_jobs_hist_db.py # Creates initial job history database
│ ├── List_db_info.py # Displays database metadata (for dev/testing)
│ └── Export_db_to_Excel.py # Exports database to Excel
├── templates/ # Templates used to create the PDFs
└── README.md # Repository overview
After querying the APIs of a few ATS systems, the data is saved to a SQLite database table (new_jobs).
Records for onsite jobs are discarded from the table.
The main program (check_boards.py) used to query the ATS job boards fetches job postings from four portals: Workday, Greenhouse, Lever and Ashby. Since taking it over, I have made major changes to it, such as moving from CSV flat files to a SQLite database — I think the SQLite approach has made the data much more manageable. I've also expanded the collected data to include fields such as a hybrid flag, a US flag, the post date, the time zone, etc.
Another improvement I introduced is the use of the Claude API, which allows a better control of the cost.
Another change was made to speed up the ATS querying, using Python's module concurrent.futures.
With this module, one can submit all (or many) requests to a thread pool, which allows
multiple ATS boards to be queried simultaneously.
After the new_jobs database is created, a well-planned scoring logic is created from it (with all the applicable filters).
Finally, I've also fixed the fetching of the job description from Workday, which was not working.
Eventually, I will upload the final scripts here.
Claude was used to create Python code to leverage the API — as opposed to using Claude Desktop and having to pay for a monthly subscription – if the programs work well, higher subscription fees are avoided. Code generated by Claude is not always optimal, though (no recycling of blocks of code that can be performed by the same function).
The Python programs require anthropic, pandas, requests, markdown, jinja2, pypdf and weasyprint. You must also install the native Windows dependencies (GTK/Pango/Cairo) of WeasyPrint, since the PDFs are generated in Python. Download the GTK3 Runtime (64-bit) for Windows.
During development, I'm saving data structures returned by the AI as external json files, so I don't have to use the API again and use more tokens. I'm also taking backups of the tables, if something goes wrong.
AI prompt is a new tool (NLP) that allows you to pass instructions to an AI model and it understands and carries out your intructions correctly. Unfortunately, neither prompts nor AI are perfect. AI solutions often require touch-ups by a human -- it often gives answers that, although very useful, require polishing. For example, if you ask the AI if the code it just produced is all right, it will usually point out issues (this is inconsistency, it can't agree with itself at times).
Some of the tasks of the original AI prompt (such as checking valid/active postings) have been moved to scripts, so the prompt (Claude.md) was simplified, leading to less token usage. Adding entries to the companies watchlist is no longer needed, I've found a huge source of ATS platforms and companies.
Remember this is a job search, so the AI prompt needs to take into account multiple factors that one only realizes when they start to actually look at the data (such as time zones, inaccurate or missing data, whether the role is managerial, job location, etc.)
I ended up deciding to use AI only for resume tailoring, and leaving the scoring of the jobs for an algorithm that I built myself.
The ATS is not being verified by Claude, it's being verified by a function that checks the URL accessibility and reliability before the top jobs list is created (it's unlikely that the fetched URLs are wrong anyway, so this check is only worth doing for top matches that had a tailored resume created).
If the result is not valid with high confidence, the job listing is skipped.
The way the AI was scoring the jobs was not great, because the prompt was not telling it objectively how scoring should be done.
Hence, I have replaced the black-box AI logic with a simple heuristic with a keyword-driven hierarchy (i.e., some keywords, such as SQL or titles, dominate all others, in a cascading process), leaving only the resume tailoring to the AI (besides, I can use the savings).
I think this new way makes more sense and is more accurate than the AI prompt. The hierarchy allows me to:
- Exclude certain types of roles (such as managerial positions, for which I have limited experience).
- Prioritize jobs located in the Eastern or Central Time zones.
- Prioritize recent job postings.
- Prioritize specific industries.
- Prioritize jobs that match my main hard skills.
Currently, the scoring logic has 16 filters stored in Python f-string variables. The first five are not about my main hard skills. There's a requirement for postings to include some of my hard skills. Each in-scope job posting is assigned a binary score (keywords that correspond to the 1s are displayed next to the score in the HTML top job lists -- it makes it easy to debug when issues arise and is very useful for development).
I'm also only taking the posting with the highest score per company. (I tailor resumes for the top 10, and apply for the rest with a regular resume). If there is more than one highest-scoring posting per company, I take only one. Prior to applying the logic, records are removed from the database with a SQL delete statement (old postings, some titles and job descriptions without any of my main hard skills).
It's amazing how using SQL makes these tasks easy!
To be a great match, a posting can't strictly require hard skills that I don't have. I'm still figuring out how to incorporate that into the logic, though I've been doing it manually, when I click the link and read the description. That's a crucial part of an accurate matching, after all.
And for now, I will keep running this process manually.
Since this batch job is supposed to run daily, the process I envisioned was to come up with a single unique key for the jobs (final_job_id – comprised of platform, company and job_id — if job_id is not missing, which is nearly always the case ). If, however, the job_id is missing, the unique key is platform, company and title.
The purpose of the final_job_id was to deduplicate the current jobs list, based on previous results. Therefore, a jobs history table was kept as well (with final_job_id set as a unique key).
However, I have since modified the process to rely on Chrome's SQLite database, which is working.
A posting is old if its URL has already been visited in Chrome, and it's skipped for selection purposes.
Since I only take one job per company (the one with the highest score), a job that didn't score high previously
may reappear on the final list (once the one with the greatest match is ignored).
The key is still used to prevent adding duplicate records (by final_job_id) in the database (if, say, the same company is added more than once to the watchlist by mistake). Besides, the watchlist file is deduped by platform and slug before use.
I've added the job posting date to the database as well, since that is a very important part of the scoring logic. Jobs posted more recently have higher weight. Unfortunately, Workday uses a description for the post date -- but it can be converted to an actual post date (despite values such as "Posted 30+ Days Ago").
Postings older than 3 weeks are ignored.
A flag called "New" was updated every time the job ran, in the table new_jobs (by joining it with the history table). The flag is 1 if the job is actually new, and 0 otherwise.
It has now been changed to use Chrome's SQLite database to flag applied jobs, as it was a little manual. It's a daily process, where visited URLs are inserted into a history table. Then the URLs are used to flag "new" versus "old" postings.
From my observations, the is_remote flag is not always reliable, so it's tweaked based on the job description.
The is_hybrid flag, on the other hand, is based entirely on the description.
I also added a is_US flag to the main table, with a simple logic, since I noticed some locations are not in the US. E.g., if a foreign country name appears in the location (but US and its variants don't), then is_US=0.
One issue I noticed is that Workday at times lists the location generically (e.g., "2 Locations", "10 Locations", etc.)
In those cases the actual location is embedded in the job_id (or URL) field. To ensure that the US flag is created correctly,
a SQL update statement replaces the generic location with the job_id value (only if the platform is Workday and
the location field contains "Locations"). After that, the US flag logic can be applied to the location field as usual.
Because a candidate may not be interested in working far outside of his time zone, this flag is very important. But job locations can be messy in the data, so it's not easy to use geolocator modules available in Python.
I've decided to just use the location field and a simple logic based on states (case-sensitive for the abbreviation) and major cities (case-insensitive) to assign the time zones (ET, CT, MT and PT, which are assigned in this order).
The logic is useful and reasonably accurate, since some states and major cities appear in the description more frequently than others.
This step is run after the aforementioned generic locations are fixed (both the US flag and the time zone depend on it).
Since I'm constantly improving or tweaking the main flags, I've created modules that can be run without executing the entire pipeline. For example, I can easily update the scoring logic and the highest-scoring job per company in the database by changing and running Update_flags.
I can also verify the results easily by exporting the various databases to Excel.
A Python script created by Claude and fixed by me was used to suggest more entries for the watchlist.
It was run separately from the other two tasks (scoring jobs and tailoring resumes).
But I've finally found a useful source for ATS tenants though, with lots of entries. I had been querying mostly tech companies with the list I had — but not having worked in big tech before, the results were not great.
To handle such an overwhelming number of new ATS entries, a new database was created, with the records
of the four ATS systems that the script is able to query. The watchlist can now be created from that database.
Unfortunately, it doesn't include a field for the industry, so an approximate logic is used to select companies in specific industries.
Switching to industries that I have more experience in should help now.
ATS querying errors still go in an external log file.
As I imagined, the Claude script wasn't strictly necessary.
Because I have always worked for larger companies (and there are circa 14,000 in my ATS database), I would like to be able to flag medium to large companies, to narrow down the scope of my search. Unfortunately I haven't been able to find a free database to identify the size of the companies in my DB, so I can target the ones I feel better about. If you can help, feel free to reach out.
I'm using a script created by Claude to help me estimate the price per run, which is now only comprised of resume tailoring.
The number of tailored resumes (and cover letters) that the user chose to create adds to the price.
Btw, there's a simple example of a tailored resume, under folder resumes, sample resume.
It seems prompt caching may lead to savings in the script used to generate the tailored files.
Still, it's only advisable to enable prompt caching for that code if there are a minimum of 2 or 3 files (caching is conditional).
Caching only pays off when the same content gets reused across multiple calls.
Which is the case, since docs_gen.py is called up to 10 times per run, and its entire system_prompt is byte-for-byte identical across all 10 calls.
Pricing depends on how large the AI prompt is, the response length, what model is being used, etc.
Here's an example of cost estimate for an actual run:
| Metric | Value |
|---|---|
| Input tokens | 531,227 |
| Output tokens | 32,041 |
| Token cost | $2.07 |
| Web searches | 15 ($0.15) |
| TOTAL | $2.22 |
This is an out-of-date cost estimate though, as the process has changed. Here one run entailed feeding roughly a hundred jobs to be scored by Claude, running a few web searches with AI for the ATS watchlist, and tailoring about 10 resumes/cover letters.
For testing purposes, the database tables can be exported to Excel with just a click, whenever changes need to be inspected.
For your reference, you can find some of the latest ATS job data (in Excel format) in the Excel folder.
The more recent the file, the more improvements have been made to the logic used to create it.
External log files are created to collect information on the process. An error log is created to identify and troubleshoot ATS-related errors during job data extraction and processing. It records failures such as API response issues, parsing errors, missing fields, and unexpected ATS formats.
Here's a sample Log.
Code has been added to track the time it takes to query the four ATS portals (715 companies). The benchmark has ranged from 18 to 28 minutes, depending on execution conditions. As of Aug-18, there are now circa 9000 companies to query, and the workflow takes an hour and a half to run.
The bottom line is, I'm now only using AI for resume tailoring. The heuristic score is more accurate, and the rationale is much easier to create, manage and verify.
I'm now creating four single URL lists, one for each ATS portal. Still thinking about how to improve this process, but I've come a long way, I've put in many hours of work on this pipeline! And to be honest, I'm pretty satisfied with the outputs now, whereas a while ago I was getting pretty dismayed with the results!
The daily-digest folder has some samples of posting URLs, if you want to check it out.
Let me know if you could use someone with my analytical skills, I'm looking for a new role in this tough economy.
For a guide to this repository, please visit:
https://jrsousa2.github.io/#AI
PS - if you follow me here on GH, I will follow you back. Just make sure you have a picture of yourself.