Local AI‑Powered Job Application CLI

A minimal clone of ai‑job-search using free‑tier tools

Welcome to another official UVF IT guide! In this quick‑fire tutorial you’ll build a tiny command‑line job‑search assistant that mimics the core of ai‑job-search, but runs entirely on your laptop with no paid APIs.

We’ll use Python, the open‑source requests library for scraping, SQLite for storage, and a free LLM endpoint (e.g., a local llama‑cpp server) to generate cover letters – all in under 45 minutes.

Before you start…

Clone Architecture
LOCAL RUNTIME CLI Wrapper SQLite DB Web Scraper (requests) Free LLM (llama.cpp)
1

Phase 1: Scaffold the project

Create a fresh folder for the clone, set up an isolated Python virtual environment, and install the tiny dependencies we need. This gives us a clean workspace and guarantees the same library versions on any machine.

We’ll also add a simple run.sh helper so you can start the CLI with one command, keeping the workflow beginner‑friendly.

#!/usr/bin/env bash
# 1. Create project folder
mkdir -p ai_job_cli && cd ai_job_cli
# 2. Initialise virtual environment
python3 -m venv .venv
source .venv/bin/activate
# 3. Upgrade pip and install tiny deps
pip install --upgrade pip
pip install requests
# 4. Optional helper script
cat > run.sh <<'EOF'
#!/usr/bin/env bash
source .venv/bin/activate
python cli.py "$@"
EOF
chmod +x run.sh
# 5. Verify setup
python -c "import requests, sqlite3; print('Setup OK')"
2

Phase 2: Define the data model

We’ll store each job posting together with the generated cover letter in a single SQLite table. SQLite lives in a single file, needs no server, and works perfectly for a minimal CLI tool.

The model.py module creates the database (if missing) and provides a tiny helper to insert or fetch records, keeping the rest of the code clean.

model.py

import sqlite3
from pathlib import Path

DB_PATH = Path('jobs.db')

def _connect():
    """Return a connection with row dict access."""
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

def init_db():
    """Create the jobs table if it doesn't exist."""
    with _connect() as conn:
        conn.execute('''
            CREATE TABLE IF NOT EXISTS jobs (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT NOT NULL,
                company TEXT NOT NULL,
                url TEXT NOT NULL,
                cover_letter TEXT,
                fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        ''')
        conn.commit()

def add_job(title: str, company: str, url: str, cover_letter: str | None = None):
    """Insert a new job posting; returns the new row id."""
    with _connect() as conn:
        cur = conn.execute(
            'INSERT INTO jobs (title, company, url, cover_letter) VALUES (?,?,?,?)',
            (title, company, url, cover_letter)
        )
        conn.commit()
        return cur.lastrowid

def get_jobs():
    """Yield all stored jobs as dictionaries."""
    with _connect() as conn:
        for row in conn.execute('SELECT * FROM jobs ORDER BY fetched_at DESC'):
            yield dict(row)

# Initialise on import so the table is ready for the CLI.
init_db()
3

Phase 3: Scrape a simple job board

We’ll pull the first page of RemoteOK using its public JSON endpoint. Each job object contains the title, company, and a URL – exactly what we need for a minimal job board. The script parses the JSON, extracts the fields we care about, and stores them in the SQLite database created in Phase 2. This gives us a persistent list of opportunities to work with later.

scrape.py

import json, sqlite3, urllib.request

DB = 'jobs.db'
REMOTEOK_URL = 'https://remoteok.com/api'

def init_db():
    conn = sqlite3.connect(DB)
    cur = conn.cursor()
    cur.execute('''CREATE TABLE IF NOT EXISTS jobs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        company TEXT NOT NULL,
        url TEXT NOT NULL,
        cover_letter TEXT)''')
    conn.commit()
    conn.close()

def fetch_jobs():
    with urllib.request.urlopen(REMOTEOK_URL) as resp:
        data = json.load(resp)
    # RemoteOK returns a list where the first element is metadata; skip it
    return [job for job in data[1:] if job.get('title')]

def store_jobs(jobs):
    conn = sqlite3.connect(DB)
    cur = conn.cursor()
    for job in jobs:
        cur.execute('INSERT INTO jobs (title, company, url) VALUES (?,?,?)',
                    (job['title'], job['company'], job['url']))
    conn.commit()
    conn.close()

if __name__ == '__main__':
    init_db()
    jobs = fetch_jobs()
    store_jobs(jobs)
    print(f'Stored {len(jobs)} jobs into {DB}')
4

Phase 4: Generate a cover letter with a free LLM

Now we’ll call a locally‑running llama‑cpp server (or any free LLM that accepts HTTP POST). The prompt is built from the job title and company, plus a short template that asks the model to produce a concise cover letter. The generated text is written back into the same SQLite row, so later steps can retrieve it easily.

cover.py

import json, sqlite3, urllib.request

DB = 'jobs.db'
LLM_ENDPOINT = 'http://127.0.0.1:8000/v1/completions'  # llama-cpp server

PROMPT_TEMPLATE = (
    "Write a brief cover letter for a {title} role at {company}. "
    "Highlight relevant remote‑work experience and enthusiasm.")

def get_uncovered_jobs():
    conn = sqlite3.connect(DB)
    cur = conn.cursor()
    cur.execute('SELECT id, title, company FROM jobs WHERE cover_letter IS NULL')
    rows = cur.fetchall()
    conn.close()
    return rows

def generate_letter(title, company):
    prompt = PROMPT_TEMPLATE.format(title=title, company=company)
    payload = json.dumps({
        'model': 'llama',
        'prompt': prompt,
        'max_tokens': 150,
        'temperature': 0.7
    }).encode('utf-8')
    req = urllib.request.Request(LLM_ENDPOINT, data=payload,
                                 headers={'Content-Type': 'application/json'})
    with urllib.request.urlopen(req) as resp:
        result = json.load(resp)
    return result.get('choices',[{'text':''}])[0]['text'].strip()

def store_letter(job_id, letter):
    conn = sqlite3.connect(DB)
    cur = conn.cursor()
    cur.execute('UPDATE jobs SET cover_letter = ? WHERE id = ?', (letter, job_id))
    conn.commit()
    conn.close()

if __name__ == '__main__':
    for job_id, title, company in get_uncovered_jobs():
        letter = generate_letter(title, company)
        store_letter(job_id, letter)
        print(f'Generated cover letter for job {job_id}')
5

Phase 5: Run & verify the CLI

The final script ties everything together: it runs the scraper, calls the LLM to fill missing cover letters, and then prints a readable list of jobs with their generated letters. Running python cli.py should finish without errors and show the stored data, confirming that each phase worked end‑to‑end.

cli.py

import subprocess, sqlite3

def run_phase(script):
    subprocess.run(['python', script], check=True)

def show_jobs():
    conn = sqlite3.connect('jobs.db')
    cur = conn.cursor()
    cur.execute('SELECT title, company, url, cover_letter FROM jobs')
    for title, company, url, letter in cur.fetchall():
        print(f'=== {title} @ {company} ===')
        print(f'URL: {url}')
        print('Cover Letter:')
        print(letter if letter else '<< not generated >>')
        print('-' * 40)
    conn.close()

if __name__ == '__main__':
    # Phase 3 – scrape jobs
    run_phase('scrape.py')
    # Phase 4 – generate cover letters
    run_phase('cover.py')
    # Verify output
    show_jobs()

Verifying the Stack

CheckCommand / Why it matters
Database file created with job rowsls -l jobs.db && sqlite3 jobs.db "SELECT COUNT(*) FROM jobs;"
Cover letters populatedpython cli.py | grep -i 'Cover Letter' -A2
Script exits with status 0python cli.py; echo $?

You did it!

Your local AI‑powered job‑application CLI is now functional. Experiment by adding more job sources or swapping the LLM endpoint for another free model. Happy hunting!