How Application Tracking Works in the AI-Job-Search Framework: CSV-Based Pipeline Explained
Application tracking in the ai-job-search framework centers on a single CSV file job_search_tracker.csv that serves as the immutable source of truth, managed exclusively by the /apply command through case-insensitive matching logic to update existing open applications or append new records.
The ai-job-search framework by MadsLorentzen implements a deterministic application tracking system that persists every job search activity to a centralized CSV file. This design ensures that downstream commands such as /outcome, /rank, and /interview read from a consistent, canonical dataset rather than scattered state or external APIs. The entire tracking mechanism is encapsulated in Step 6b of the /apply command, which handles schema migration, row deduplication, and archival without external dependencies.
Tracker Architecture and Design Philosophy
Single Source of Truth
The framework designates job_search_tracker.csv as the sole authoritative record for all pipeline activities. According to .claude/commands/apply.md, no other component modifies this file, guaranteeing that ranking algorithms and status reports always operate on stable, predictable data. The file path itself is hardcoded as a protected string in tools/security_guards.py, preventing accidental deletion or corruption by auxiliary scripts.
Immutable Row Stability
The CSV is never reordered, rows are never deleted, and the command never touches job_scraper/seen_jobs.json. This immutability ensures that downstream analytics can rely on row identifiers remaining constant throughout the job search lifecycle.
The /apply Command Recording Logic (Step 6b)
The tracking implementation executes a six-phase workflow every time you submit an application. This logic is fully specified in .claude/commands/apply.md under the section "Step 6b: Record the Application."
Header Initialization and Legacy Migration
When /apply runs, it first ensures the CSV exists with the canonical 14-column header: date, company, sector, role, role_type, channel, status, contact_person, fit_rating, notes, cv_file, cover_letter_file, source, and deadline. If the file exists but lacks the trailing deadline column (legacy format), the command automatically appends it, leaving historical rows with empty values while ensuring new records include the field.
Case-Insensitive Row Matching
The system identifies existing applications by performing a case-insensitive match on the combination of company and role fields. This matching respects the formal status vocabulary defined in tests/test_tracker_status_vocab.py, ensuring only recognized states are considered when determining if a row is "open" or final.
Update Semantics for Open Applications
If a match exists and the current status is not final (i.e., not in {"applied", "offer accepted", "rejected"}), the command performs an atomic update rather than creating a duplicate. The update refreshes cv_file, cover_letter_file, fit_rating, source, and deadline while preserving the original date. The command appends an undated redrafted marker to the notes column to maintain an audit trail of modifications.
Append Logic for New Records
When no open match exists, the command appends a new row with status set to drafted, capturing the current date, the numeric fit_rating from Step 1 (0-100), and file paths for generated documents. The source field receives the posting URL, or remains empty when the content was pasted manually. The deadline field is populated with the exact value extracted from the job posting, or left empty if unavailable.
CSV Schema and Field Definitions
The tracker uses a strict schema that balances human readability with machine parsing. The column order matches the header exactly:
date: ISO format date of activity (today)companyandrole: Case-insensitive matching keysstatus: Controlled vocabulary includingdrafted,applied,offer accepted,rejected(enforced bytests/test_tracker_status_vocab.py)fit_rating: Numeric score (0-100) from initial screeningcv_fileandcover_letter_file: Paths to generated LaTeX source filessource: Original posting URLdeadline: Extracted application deadline
Job Posting Archival
After updating the tracker, the command archives the raw posting text captured in Step 0 to documents/applications/<company>_<role>/job_posting.md. This preserves the exact content that was submitted. If the file already exists, it is left untouched, ensuring the archive always reflects the content used for the original submission.
Practical Implementation Example
The following Python logic mirrors the framework's tracker management as implemented in the source:
import csv
import datetime
import pathlib
TRACKER = pathlib.Path("job_search_tracker.csv")
HEADER = [
"date","company","sector","role","role_type","channel","status",
"contact_person","fit_rating","notes","cv_file","cover_letter_file",
"source","deadline"
]
def ensure_tracker():
"""Initialize CSV with deadline column support for legacy files."""
if not TRACKER.exists():
TRACKER.write_text(",".join(HEADER) + "\n")
else:
first_line = TRACKER.read_text().splitlines()[0]
if not first_line.strip().endswith("deadline"):
new_header = first_line.rstrip("\n") + ",deadline\n"
rest = "\n".join(TRACKER.read_text().splitlines()[1:]) + "\n"
TRACKER.write_text(new_header + rest)
def match_row(company, role):
"""Case-insensitive match on company and role."""
with TRACKER.open(newline="") as f:
for row in csv.DictReader(f):
if (row["company"].lower() == company.lower() and
row["role"].lower() == role.lower()):
return row
return None
def write_row(data):
"""Append new record to tracker."""
with TRACKER.open("a", newline="") as f:
writer = csv.DictWriter(f, fieldnames=HEADER)
writer.writerow(data)
def update_row(existing, updates):
"""Rewrite file updating the matched row."""
rows = []
with TRACKER.open(newline="") as f:
for r in csv.DictReader(f):
if r is existing:
r.update(updates)
rows.append(r)
with TRACKER.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=HEADER)
writer.writeheader()
writer.writerows(rows)
# Example usage inside /apply (Step 6b)
ensure_tracker()
row = match_row(company="Acme Corp", role="Data Engineer")
if row and row["status"] not in {"applied", "offer accepted", "rejected"}:
update_row(row, {
"date": datetime.date.today().isoformat(),
"status": "drafted",
"fit_rating": "85",
"cv_file": "cv/main_Acme_Corp_Data_Engineer.tex",
"cover_letter_file": "cover_letters/cover_Acme_Corp_Data_Engineer.tex",
"notes": row["notes"] + " redrafted"
})
else:
write_row({
"date": datetime.date.today().isoformat(),
"company": "Acme Corp",
"sector": "",
"role": "Data Engineer",
"role_type": "",
"channel": "online",
"status": "drafted",
"contact_person": "",
"fit_rating": "85",
"notes": "",
"cv_file": "cv/main_Acme_Corp_Data_Engineer.tex",
"cover_letter_file": "cover_letters/cover_Acme_Corp_Data_Engineer.tex",
"source": "https://careers.acme.com/job/1234",
"deadline": "2026-09-30"
})
Integration with Downstream Commands
The tracker CSV serves as the read-only data source for the /rank command (which prioritizes opportunities based on fit_rating and deadline) and the /outcome command (which advances statuses through the pipeline). Because the file is append-only and never reordered, these commands can rely on row stability for consistent analytics and reporting.
Summary
- The ai-job-search framework uses
job_search_tracker.csvas the single source of truth for application tracking, protected as a critical asset intools/security_guards.py - The
/applycommand exclusively manages the file through Step 6b, handling both schema migration (adding thedeadlinecolumn to legacy files) and data writes - Records are matched case-insensitively on
companyandrole, with updates restricted to rows whosestatusis not final - The schema includes 14 fields covering metadata, document paths, and extracted deadline information
- Job postings are archived separately to
documents/applications/<company>_<role>/job_posting.mdto preserve submission context - Unit tests in
tests/test_apply_records_application.pyvalidate header conformity and the "tracker-wins" update rules
Frequently Asked Questions
What happens if I apply to the same company and role twice?
If the existing application has a final status (applied, offer accepted, or rejected), the second submission appends a new row. Otherwise, the framework updates the existing open row with new document paths and appends a redrafted marker to the notes column to maintain history.
Can I manually edit the job_search_tracker.csv file?
While technically possible, manual edits bypass the validation logic in tests/test_tracker_status_vocab.py and may break downstream commands like /outcome that expect specific status values. The framework treats this file as a protected asset for this reason.
How does the framework handle legacy CSV files without a deadline column?
The /apply command automatically detects missing headers using the logic in .claude/commands/apply.md and appends the deadline column to existing files. Legacy rows retain empty values for this field, while new records populate it with extracted deadline data.
Where is the job posting content stored after I submit an application?
The raw posting text is archived to documents/applications/<company>_<role>/job_posting.md immediately after the tracker update, preserving the exact content that was used to generate the application materials. If the file exists, it remains untouched to maintain the historical record.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →