AI Job Application Agent with Fara-7B

Build an AI agent that browses job boards and applies using Fara-7B.

Introduction

30 Second Summary

Job hunting is exhausting. You spend hours scrolling through listings, tailoring resumes, and tracking applications across multiple boards.

In this project, you will build an AI-powered job application agent that autonomously browses job boards, scores listings against your resume, and generates tailored application materials for the best matches.

What You'll Build

A Python application that uses Microsoft's Fara-7B Computer Use Agent to visually navigate LinkedIn Jobs, Indeed, and MyVisaJobs, then leverages GPT-4o-mini to score and generate tailored resumes and cover letters.

By the end of this project, you'll have:

  • A working AI agent that uses Fara-7B to autonomously browse multiple job boards and extract matching H1B-sponsoring job listings.
  • A resume match scoring system that evaluates each job against your resume on a 1-5 scale and only proceeds with jobs scoring 4/5 or above.
  • An automated pipeline that generates tailored resumes and cover letters, emails you for approval, and tracks everything in a local SQLite database.
  • Secret Mission: Add automated cold email drafting to recruiters and schedule the agent to run daily at 8 AM.

Are there any prerequisites?

You'll need a Microsoft or GitHub account (for Azure signup) and a credit or debit card for Azure free trial verification. No prior AI or automation experience is required.

Before We Start

Before We Start...

Set Up Your Development Environment

To build your AI job application agent, you need a few tools installed on your machine. Python runs the agent code, Git tracks your changes, and VS Code gives you an editor with a built-in terminal.

In this step, you will install all three tools and set up an isolated project folder with its own dependencies. This means your agent's packages won't interfere with anything else on your machine.

In this step, get ready to:
  • Install Python 3.12 and VS Code on your Windows machine.
  • Install Git for Windows.
  • Create a project folder with a virtual environment and install core dependencies.
Install Python 3.12 and VS Code

Python 3.12 is the runtime your agent scripts run on. Let's check if it's already installed.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Check your Python version by running this command:
python --version

✔️ I see version 3.12 or higher

Python is ready to go. Skip ahead to the VS Code install instructions below.

ⓧ I see an older version

You have an older Python that won't work with this project's dependencies. Download Python 3.12 to replace it.

  • Open python.org/downloads in your browser.
  • Click the download button for the latest Python 3.12 release.
  • Run the installer.
  • On the very first screen of the installer, check the box that says Add python.exe to PATH.
  • Click Install Now.
  • Close and reopen PowerShell, then run python --version again to confirm it shows 3.12.

Still seeing the old version?

The old Python may still be first in your PATH. Uninstall the older version from Settings > Apps > Installed apps, then reopen PowerShell and try again.

help me fix my Python PATH on Windows so python --version shows 3.12

ⓧ Command not found

Python isn't installed yet. Let's fix that now.

  • Open python.org/downloads in your browser.
  • Click the download button for the latest Python 3.12 release.
  • Run the installer.
  • On the very first screen of the installer, check the box that says Add python.exe to PATH.
  • Click Install Now.
  • Close and reopen PowerShell, then run python --version to confirm it shows 3.12.

Still not recognized after installing?

Make sure you checked Add python.exe to PATH during installation. If you missed it, rerun the installer, select Modify, and enable the PATH option.

help me get python --version working on Windows after installing Python 3.12

Now let's install your code editor.

  • Open code.visualstudio.com in your browser.
  • Click the Windows download button and run the installer.
  • Accept the defaults and finish the installation.
  • Press the Windows key to open your search bar.
  • Type Visual Studio Code and press Enter to open it.

Why VS Code?

VS Code has a built-in terminal that lets you run commands without switching windows. You will write code and run your agent from the same place.

Install Git for Windows

Git is the version control tool you will use to clone the Fara repository in a later step. Let's check if it's already on your machine.

  • In VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Check your Git version by running this command:
git --version

✔️ I see a version number

Git is ready. Move on to creating your project folder below.

ⓧ Command not found

Git isn't installed yet. Let's add it now.

  • Open git-scm.com/install/windows in your browser.
  • Click the 64-bit Git for Windows Setup link to download the installer.
  • Run the installer and accept all defaults.
  • Close and reopen the VS Code terminal (Ctrl+` twice), then run git --version again to confirm it shows a version number.

Still not recognized after installing?

Close VS Code completely and reopen it. VS Code caches the PATH when it launches, so it won't see newly installed tools until you restart it.

help me get git --version working in VS Code after installing Git for Windows

Create your project folder and install dependencies

Every Python project should live in its own folder with its own isolated set of packages. This isolation is called a virtual environment.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir job-agent
cd job-agent

What did these commands do?

mkdir job-agent creates a new folder called job-agent on your Desktop. cd job-agent moves your terminal inside that folder so all future commands run from there.

  • Confirm the folder was created by running:
pwd

You should see a path ending in Desktop\job-agent.

  • Create a Python virtual environment by running this command:
python -m venv .venv
  • Activate the virtual environment by running:
.venv\Scripts\activate

You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

What is a virtual environment?

A virtual environment is a self-contained copy of Python inside your project folder. Packages you install here stay isolated from your system Python and other projects.

Seeing a script execution policy error?

Windows may block PowerShell scripts by default. Run this command to allow scripts in the current session, then try the activate command again:

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

help me fix a PowerShell execution policy error when activating a Python virtual environment

Now let's define the packages your agent needs.

  • In VS Code, click File in the top menu bar.
  • Click New File.
  • Type requirements.txt as the file name and press Enter.
  • Add the following content to requirements.txt:
playwright
openai
python-dotenv

What are these packages?

  • playwright is a browser automation framework that Fara-7B uses to control the browser.
  • openai is the Python client for calling Azure OpenAI models like GPT-4o-mini.
  • python-dotenv loads environment variables from a file so you can keep secrets out of your code.
  • Save the file by pressing Ctrl+S.
  • Install all three packages by running this command in your terminal:
pip install -r requirements.txt

You should see pip download and install each package along with its sub-dependencies. The output ends with a line confirming all packages were installed successfully.

Seeing a permission error or install failure?

Make sure (.venv) appears in your prompt. If it doesn't, the virtual environment isn't active and pip may be trying to install system-wide. Run .venv\Scripts\activate first, then try the install command again.

help me fix a pip install error inside my Python virtual environment on Windows

✔️ Awesome, I've got everything!

Great. Make sure you have saved requirements.txt and that all packages installed without errors.

ⓧ I'd like to double check the full code

Here is the complete requirements.txt file:

playwright
openai
python-dotenv

Your development environment is ready. Next up, you'll deploy Microsoft's Fara-7B model on Azure Foundry so your agent has a brain to browse the web with.

Set Up Azure Foundry and Deploy Fara-7B

Fara-7B is a Computer Use Agent that needs serious GPU power to run. Hosting it locally would require 24GB or more of VRAM.

Instead of buying expensive hardware, you will deploy Fara-7B as a serverless endpoint on Azure AI Foundry. Azure handles the infrastructure, and you just call an API. In this step, you will create an Azure free account, deploy Fara-7B, and save your credentials locally.

In this step, get ready to:
  • Create an Azure free account with $200 credit.
  • Deploy Fara-7B as a serverless endpoint on Azure AI Foundry.
  • Save your endpoint credentials in local configuration files.
Create an Azure free account

Azure gives new customers $200 in credit to use within 30 days. This is more than enough to cover the entire project.

  • Open your browser and navigate to the Azure free account signup page:
  • https://azure.microsoft.com/free
  • Click Start free (or Try Azure for free).
  • Sign in with your existing Microsoft or GitHub account.
  • Complete the verification form. You will need a phone number and a credit or debit card (non-prepaid) for identity verification.
  • Follow the remaining prompts to finish creating your account.

Will I be charged?

No. The card is for identity verification only. Azure gives you $200 in free credit and will not charge your card unless you explicitly upgrade to a paid plan after the trial ends.

This project's estimated total cost is $5-15, well within the $200 credit.

Can't create an account?

Azure limits free accounts to one per new customer. If you have used a free trial before, you may need to use a different Microsoft account or consider a pay-as-you-go subscription.

Prepaid cards and virtual cards are often rejected. Use a standard credit or debit card.

Help me troubleshoot my Azure account creation

Deploy Fara-7B on Azure AI Foundry

Azure AI Foundry is Microsoft's model catalog where you can browse and deploy AI models. You will find Fara-7B here and deploy it as a serverless endpoint that your Python code can call.

  • Navigate directly to the Fara-7B model page by opening this link in your browser:
  • https://ai.azure.com/explore/models/Fara-7B/version/2/registry/azureml-msr
  • Sign in with the same Microsoft or GitHub account you used for your Azure free account.
  • Review the model card. You will see details about Fara-7B including its capabilities and benchmarks.
  • Click Deploy to open the deployment wizard.
  • If prompted for deployment settings, keep the default name or enter Fara-7B as the deployment name.
  • Review the pricing terms. Fara-7B uses pay-per-token billing at approximately $0.20 per 1M tokens.
  • If prompted to accept terms, select the checkbox to agree to the terms of use.
  • Click Deploy to create your serverless endpoint.
  • Wait for the deployment to complete. The deployment status should show Succeeded when ready.

What is a serverless endpoint?

A serverless endpoint means Azure hosts the model for you and handles all the GPU infrastructure. You only pay for the tokens you send and receive. There is no idle cost when you are not using it.

This is the opposite of provisioned compute, where you rent a dedicated GPU and pay by the hour regardless of usage.

  • Once deployment is complete, you will land on the deployment details page.
  • Locate and copy the Target URI (this is your endpoint URL). Paste it somewhere temporary like a text file.
  • Locate and copy the Key value (this is your API key). Paste it in the same temporary location.

Keep your key safe

The API key grants access to your deployed model. Do not share it publicly or commit it to version control. You will store it in local-only files in the next substep.

Deployment failed or stuck?

Make sure your Azure region supports Fara-7B. If the deployment fails, try selecting a different region in the deployment wizard.

If your deployment stays in a pending state for more than 10 minutes, try refreshing the page and checking the deployment status again.

Help me debug my Fara-7B deployment on Azure AI Foundry

Save your endpoint credentials locally

Your Python scripts need two pieces of information to talk to Fara-7B: the endpoint URL and the API key. You will save these in two files so different parts of the project can access them.

  • Switch back to VS Code with your job-agent folder open.
  • Create a new file called azure_foundry_config.json in the root of your job-agent folder by pasting the following:
{
  "endpoint": "your-endpoint-url-here",
  "api_key": "your-api-key-here",
  "model": "fara-7b"
}
  • Replace your-endpoint-url-here with the Target URI you copied from Azure.
  • Replace your-api-key-here with the Key value you copied from Azure.

What does this config file do?

The azure_foundry_config.json file stores the connection details that the Fara client needs to reach your deployed model. The endpoint field is where the model lives. The api_key field authenticates your requests. The model field identifies which model to use.

  • Save azure_foundry_config.json.

✔️ Awesome, I've got everything!

Great. Make sure your endpoint URL and API key are pasted in correctly and the file is saved.

ⓧ I'd like to double check the full code

{
  "endpoint": "your-endpoint-url-here",
  "api_key": "your-api-key-here",
  "model": "fara-7b"
}
  • Now create a second file called .env in the root of your job-agent folder by pasting the following:
AZURE_FOUNDRY_ENDPOINT=your-endpoint-url-here
AZURE_FOUNDRY_API_KEY=your-api-key-here
  • Replace your-endpoint-url-here with the same Target URI.
  • Replace your-api-key-here with the same Key value.

Why two files?

The .env file is the standard way Python projects store environment variables. The python-dotenv package (already in your requirements) loads these automatically.

The azure_foundry_config.json file is specifically for the Fara client, which expects a JSON config. Having both gives you flexibility depending on which part of the project needs the credentials.

  • Save the .env file.
  • Confirm both files exist by checking the VS Code Explorer sidebar. You should see azure_foundry_config.json and .env listed in your job-agent folder.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved with your real endpoint URL and API key values.

ⓧ I'd like to double check the full code

AZURE_FOUNDRY_ENDPOINT=your-endpoint-url-here
AZURE_FOUNDRY_API_KEY=your-api-key-here

Never commit credentials to Git

Both .env and azure_foundry_config.json contain your API key. If you use Git later, add both filenames to a .gitignore file so they are never pushed to a remote repository.

VS Code not showing the .env file?

Files starting with a dot are hidden by default on some systems. In VS Code, they should still appear in the Explorer sidebar. If you do not see it, make sure you created the file in the correct folder (job-agent/ root, not inside .venv/).

Help me find my .env file in VS Code

Your Azure endpoint is live and your credentials are saved locally. Next up, you will install the Fara client package and test that your agent can actually connect to the model.

Install Fara-7B Client and Test the Agent

You have your Azure Foundry endpoint ready to go. Now you need the Fara client software that actually sends tasks to that endpoint and controls a browser.

Fara is an open-source package from Microsoft that wraps the Fara-7B model into a working agent. It handles the loop of taking screenshots, sending them to the model, reading the predicted action, and executing that action in a real browser window.

In this step, get ready to:
  • Clone the Fara repository and install the package into your virtual environment.
  • Install Playwright browser binaries so Fara can open and control a browser.
  • Run a test task and watch Fara-7B navigate a webpage autonomously.
Clone the Fara repository and install the package

Fara is installed from its source code on GitHub rather than from PyPI. This gives you the full agent harness including the fara-cli command-line tool.

  • Navigate to your Desktop (where your job-agent folder lives) by running this command:
cd ~/Desktop
  • Clone the Fara repository by running:
git clone https://github.com/microsoft/fara.git
  • Confirm the repository was cloned by running:
ls

You should see both fara and job-agent folders listed in the output.

Clone failed or permission denied?

Make sure git is installed by running git --version. If it is not found, revisit Step 1 to install Git.

If you see a network error, check your internet connection and try the command again.

Help me troubleshoot this

  • Move into the cloned repository by running:
cd fara
  • Install the Fara package into your virtual environment by running:
pip install -e .

What does this command do?

The -e flag installs the package in editable mode, meaning Python links directly to the source code you just cloned. The . tells pip to look for the package definition in the current directory.

Since you are using Azure Foundry to host the model remotely, you skip the [vllm] extras that would only be needed for local GPU hosting (which requires 24GB or more VRAM).

This install may take a minute as it pulls in Fara's dependencies. Wait until you see a success message in your terminal.

Seeing install errors?

Make sure your virtual environment is still activated. You should see (.venv) at the beginning of your terminal prompt. If not, navigate to your job-agent folder and reactivate with .venv\Scripts\activate before trying the install again.

Help me fix this

  • Install the Playwright browser binaries by running:
python3 -m playwright install

Why install browsers separately?

The playwright Python package is just the automation library. It needs actual browser binaries (Chromium, Firefox, WebKit) downloaded to your machine to open and control real browser windows.

Fara-7B uses these browsers to visually navigate webpages. Without them, the agent has no browser to control.

You should see download progress for Chromium and other browsers. This may take a few minutes depending on your internet speed.

python3 not recognized?

On some Windows installations, the python3 command is not available. Try python -m playwright install instead.

Help me figure out the right command

  • Navigate back to your project folder by running:
cd ../job-agent
  • Open requirements.txt in VS Code and add fara as a new line at the bottom to track the dependency.

Your requirements.txt should now look like this:

playwright
openai
python-dotenv
fara
  • Save requirements.txt.
Run a test task with fara-cli to verify the connection

With Fara installed and your config file ready, you can now send a real task to your Azure Foundry endpoint. The agent will open a browser, navigate to a website, and report back what it finds.

  • Run this test command from your job-agent folder:
fara-cli --task "how many pages does wikipedia have" --endpoint_config azure_foundry_config.json

What does this command do?

The fara-cli command launches the Fara agent. The --task flag gives it a natural language instruction to follow. The --endpoint_config flag points to your Azure Foundry credentials so the agent knows where to send screenshots for analysis.

Fara-7B will open a Chromium browser, navigate to Wikipedia, find the answer, and then terminate the task.

fara-cli command not found?

If your terminal says the command is not recognized, use the Python module fallback instead:

python -m fara.run_fara --task "what is the weather in new york now"

Also confirm your virtual environment is still activated (look for (.venv) in your prompt).

Help me troubleshoot this

Observe Fara-7B navigating a webpage autonomously

When the test task runs, a browser window opens on your screen. Watch closely because this is a Computer Use Agent in action.

You should see the agent performing these actions in sequence:

  • A Chromium browser window opens automatically.
  • The agent takes a screenshot of the current page and sends it to your Azure Foundry endpoint.
  • Fara-7B analyzes the screenshot, decides what to do next, and returns coordinates to click or text to type.
  • The agent executes that action in the browser (clicking a link, typing in a search box, scrolling down).
  • This loop repeats until the agent decides the task is complete and terminates.

How is this different from web scraping?

Traditional web scraping parses HTML source code to extract data. Fara-7B never reads HTML. It sees the webpage visually through screenshots and predicts mouse coordinates to click, just like a human would.

This visual approach means the agent can handle any website without needing custom selectors or APIs. It averages about 16 steps per task, making it efficient for real-world browsing.

Once the agent finishes, you will see a final output in your terminal showing the result it found. The browser window closes automatically.

Agent seems stuck or not responding?

Each step involves sending a screenshot to Azure Foundry and waiting for a response. Expect a few seconds of delay between actions. If nothing happens for more than 30 seconds, check that your endpoint URL and API key in azure_foundry_config.json are correct.

If the browser opens but the agent makes incorrect clicks, this is expected occasionally. Fara-7B has a 73.5% success rate on browser navigation tasks. Try running the command again with a simpler task.

Help me debug this

Your Fara-7B agent is confirmed working. Next up, you will build the multi-board job search agent that dispatches real job-hunting tasks across LinkedIn Jobs, Indeed, and MyVisaJobs.

Build the Multi-Board Job Search Agent

Your Fara-7B endpoint is connected and ready to browse the web autonomously. Now it is time to put it to work on a real task: searching for H1B-sponsoring jobs across multiple job boards.

Each job board has different strengths. LinkedIn Jobs offers the highest volume of listings. Indeed provides breadth across industries. MyVisaJobs gives you verified H1B sponsor data with approval rates. In this step, you will configure your search criteria, build the orchestration script, and run the agent across all three boards.

In this step, get ready to:
  • Create a job search configuration file with your filters and target boards.
  • Write a Python script that dispatches natural language tasks to Fara-7B for each board.
  • Run the agent, deduplicate results, and save extracted jobs to a JSON file.
Create the job search configuration

Before the agent can search, it needs to know what you are looking for. A configuration file defines your role keywords, preferred location, sponsorship preference, and which boards to search.

  • Create a new file called job_config.json inside your job-agent folder by pasting the following:
{
  "role_keywords": ["software engineer", "data engineer"],
  "location": "United States",
  "h1b_sponsorship": true,
  "target_companies": [],
  "boards": ["linkedin", "indeed", "myvisajobs"]
}

What does this configuration do?

Each field controls part of the search:

  • role_keywords are the job titles Fara-7B will search for on each board.
  • h1b_sponsorship tells the agent to include visa sponsorship terms in its search queries.
  • boards lists which job boards to visit sequentially.
  • target_companies is an optional filter you can fill in later to narrow results.
  • Save job_config.json.
  • Confirm the file exists by running:
ls job_config.json

You should see job_config.json in the output.

Write the multi-board search script

The search script will load your configuration, build a natural language prompt for each board, and dispatch each prompt to Fara-7B using the CLI. Fara-7B visually navigates each site like a human would, extracting the job data you asked for.

  • Create a new file called job_search_agent.py in your job-agent folder.
  • Start by adding the imports and configuration loading function:
import json
import subprocess
import os
from pathlib import Path


def load_config():
    """Load job search configuration from job_config.json."""
    with open("job_config.json", "r") as f:
        return json.load(f)

What does this code do?

The subprocess module lets your script call fara-cli commands from Python. The load_config() function reads your job_config.json file and returns it as a Python dictionary you can use to build prompts.

  • Below load_config(), add the function that builds natural language prompts for each board:
def build_prompts(config):
    """Construct natural language task prompts for each job board."""
    keywords = " ".join(config["role_keywords"])
    location = config["location"]
    sponsorship = "visa sponsorship" if config["h1b_sponsorship"] else ""

    prompts = {}

    if "linkedin" in config["boards"]:
        prompts["linkedin"] = (
            f"Go to linkedin.com/jobs, search for '{keywords} {sponsorship}' "
            f"in '{location}', and extract the first 10 job titles, companies, "
            f"and application links. Return the results as a JSON array with "
            f"keys: title, company, link, description."
        )

    if "indeed" in config["boards"]:
        prompts["indeed"] = (
            f"Go to indeed.com, search for '{keywords} {sponsorship}' "
            f"in '{location}', and extract the first 10 job titles, companies, "
            f"and application links. Return the results as a JSON array with "
            f"keys: title, company, link, description."
        )

    if "myvisajobs" in config["boards"]:
        prompts["myvisajobs"] = (
            f"Go to myvisajobs.com, search for '{keywords}' jobs, "
            f"and extract the first 10 job titles, companies, H1B sponsorship "
            f"status, and application links. Return the results as a JSON array "
            f"with keys: title, company, link, description."
        )

    return prompts

What does this code do?

Each prompt is a natural language instruction that tells Fara-7B exactly what to do on a specific website. The model reads these instructions, opens a browser, navigates to the job board, types in your search terms, and extracts the data visually.

This is fundamentally different from traditional web scraping. Fara-7B perceives the page through screenshots and clicks on predicted coordinates. It does not parse HTML.

  • Below build_prompts(), add the function that dispatches a task to Fara-7B and parses the results:
def run_fara_task(prompt, config_path="azure_foundry_config.json"):
    """Dispatch a single task to Fara-7B and capture the output."""
    try:
        # Try fara-cli first
        result = subprocess.run(
            ["fara-cli", "--task", prompt, "--endpoint_config", config_path],
            capture_output=True,
            text=True,
            timeout=300
        )
        return result.stdout
    except FileNotFoundError:
        # Fallback if fara-cli is not in PATH
        result = subprocess.run(
            ["python", "-m", "fara.run_fara", "--task", prompt,
             "--endpoint_config", config_path],
            capture_output=True,
            text=True,
            timeout=300
        )
        return result.stdout
    except subprocess.TimeoutExpired:
        print("Task timed out after 5 minutes.")
        return ""


def parse_results(output, source_board):
    """Parse Fara-7B output into structured job postings."""
    jobs = []
    try:
        # Extract JSON array from the output text
        start = output.find("[")
        end = output.rfind("]") + 1
        if start != -1 and end > start:
            raw_jobs = json.loads(output[start:end])
            for job in raw_jobs:
                jobs.append({
                    "title": job.get("title", "Unknown"),
                    "company": job.get("company", "Unknown"),
                    "link": job.get("link", ""),
                    "description": job.get("description", ""),
                    "source_board": source_board
                })
    except json.JSONDecodeError:
        print(f"Could not parse results from {source_board}.")
    return jobs

What does this code do?

run_fara_task() calls fara-cli with your prompt and endpoint config. It captures whatever text Fara-7B outputs after completing its browser navigation. The 5-minute timeout prevents the script from hanging if the agent gets stuck.

parse_results() looks for a JSON array in that output and converts each entry into a structured dictionary with consistent keys. The source_board field tracks where each job came from.

  • Finally, add the deduplication logic and main execution block at the bottom of the file:
def deduplicate_jobs(all_jobs):
    """Remove duplicate job postings based on title and company."""
    seen = set()
    unique_jobs = []
    for job in all_jobs:
        # Create a key from lowercased title and company
        key = (job["title"].lower().strip(), job["company"].lower().strip())
        if key not in seen:
            seen.add(key)
            unique_jobs.append(job)
    return unique_jobs


def main():
    """Run the full multi-board job search pipeline."""
    print("Loading job search configuration...")
    config = load_config()

    print("Building search prompts for each board...")
    prompts = build_prompts(config)

    all_jobs = []
    for board, prompt in prompts.items():
        print(f"\nSearching {board}...")
        print(f"Task: {prompt[:80]}...")
        output = run_fara_task(prompt)
        jobs = parse_results(output, board)
        print(f"Found {len(jobs)} jobs from {board}.")
        all_jobs.extend(jobs)

    print(f"\nTotal jobs found: {len(all_jobs)}")
    unique_jobs = deduplicate_jobs(all_jobs)
    print(f"After deduplication: {len(unique_jobs)} unique jobs.")

    # Save results to extracted_jobs.json
    with open("extracted_jobs.json", "w") as f:
        json.dump(unique_jobs, f, indent=2)
    print("Results saved to extracted_jobs.json.")


if __name__ == "__main__":
    main()

What does this code do?

deduplicate_jobs() removes duplicate listings that appear on multiple boards. It compares lowercased job titles and company names to catch matches even when formatting differs slightly.

main() ties everything together. It loads the config, builds prompts, dispatches each to Fara-7B one board at a time, collects all results, removes duplicates, and saves the final list to extracted_jobs.json.

  • Save job_search_agent.py.

Why use the CLI instead of importing Fara directly?

The fara-cli command is the documented interface for running tasks against your Azure Foundry endpoint. It handles browser initialization, screenshot capture, and multi-round agent reasoning internally. Calling it via subprocess gives your script a clean boundary while keeping the agent's full capability intact.

✔️ Awesome, I've got everything!

Great. Double check you have saved job_search_agent.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete job_search_agent.py file:

import json
import subprocess
import os
from pathlib import Path


def load_config():
    """Load job search configuration from job_config.json."""
    with open("job_config.json", "r") as f:
        return json.load(f)


def build_prompts(config):
    """Construct natural language task prompts for each job board."""
    keywords = " ".join(config["role_keywords"])
    location = config["location"]
    sponsorship = "visa sponsorship" if config["h1b_sponsorship"] else ""

    prompts = {}

    if "linkedin" in config["boards"]:
        prompts["linkedin"] = (
            f"Go to linkedin.com/jobs, search for '{keywords} {sponsorship}' "
            f"in '{location}', and extract the first 10 job titles, companies, "
            f"and application links. Return the results as a JSON array with "
            f"keys: title, company, link, description."
        )

    if "indeed" in config["boards"]:
        prompts["indeed"] = (
            f"Go to indeed.com, search for '{keywords} {sponsorship}' "
            f"in '{location}', and extract the first 10 job titles, companies, "
            f"and application links. Return the results as a JSON array with "
            f"keys: title, company, link, description."
        )

    if "myvisajobs" in config["boards"]:
        prompts["myvisajobs"] = (
            f"Go to myvisajobs.com, search for '{keywords}' jobs, "
            f"and extract the first 10 job titles, companies, H1B sponsorship "
            f"status, and application links. Return the results as a JSON array "
            f"with keys: title, company, link, description."
        )

    return prompts


def run_fara_task(prompt, config_path="azure_foundry_config.json"):
    """Dispatch a single task to Fara-7B and capture the output."""
    try:
        # Try fara-cli first
        result = subprocess.run(
            ["fara-cli", "--task", prompt, "--endpoint_config", config_path],
            capture_output=True,
            text=True,
            timeout=300
        )
        return result.stdout
    except FileNotFoundError:
        # Fallback if fara-cli is not in PATH
        result = subprocess.run(
            ["python", "-m", "fara.run_fara", "--task", prompt,
             "--endpoint_config", config_path],
            capture_output=True,
            text=True,
            timeout=300
        )
        return result.stdout
    except subprocess.TimeoutExpired:
        print("Task timed out after 5 minutes.")
        return ""


def parse_results(output, source_board):
    """Parse Fara-7B output into structured job postings."""
    jobs = []
    try:
        # Extract JSON array from the output text
        start = output.find("[")
        end = output.rfind("]") + 1
        if start != -1 and end > start:
            raw_jobs = json.loads(output[start:end])
            for job in raw_jobs:
                jobs.append({
                    "title": job.get("title", "Unknown"),
                    "company": job.get("company", "Unknown"),
                    "link": job.get("link", ""),
                    "description": job.get("description", ""),
                    "source_board": source_board
                })
    except json.JSONDecodeError:
        print(f"Could not parse results from {source_board}.")
    return jobs


def deduplicate_jobs(all_jobs):
    """Remove duplicate job postings based on title and company."""
    seen = set()
    unique_jobs = []
    for job in all_jobs:
        # Create a key from lowercased title and company
        key = (job["title"].lower().strip(), job["company"].lower().strip())
        if key not in seen:
            seen.add(key)
            unique_jobs.append(job)
    return unique_jobs


def main():
    """Run the full multi-board job search pipeline."""
    print("Loading job search configuration...")
    config = load_config()

    print("Building search prompts for each board...")
    prompts = build_prompts(config)

    all_jobs = []
    for board, prompt in prompts.items():
        print(f"\nSearching {board}...")
        print(f"Task: {prompt[:80]}...")
        output = run_fara_task(prompt)
        jobs = parse_results(output, board)
        print(f"Found {len(jobs)} jobs from {board}.")
        all_jobs.extend(jobs)

    print(f"\nTotal jobs found: {len(all_jobs)}")
    unique_jobs = deduplicate_jobs(all_jobs)
    print(f"After deduplication: {len(unique_jobs)} unique jobs.")

    # Save results to extracted_jobs.json
    with open("extracted_jobs.json", "w") as f:
        json.dump(unique_jobs, f, indent=2)
    print("Results saved to extracted_jobs.json.")


if __name__ == "__main__":
    main()
Run the agent and save results

With both files in place, you can now run the full multi-board search. The agent will open a browser for each board, navigate to the search results, and extract job listings.

  • Make sure your virtual environment is still activated in the terminal.
  • Run the search agent by executing:
python job_search_agent.py

What should I expect to see?

The agent will search each board sequentially. For each board, Fara-7B opens a browser window (headless by default), takes screenshots to understand the page, and performs actions like clicking search fields and typing your keywords.

This process takes several minutes per board because the model completes approximately 16 steps per task. You will see status messages in your terminal as it progresses through LinkedIn Jobs, Indeed, and MyVisaJobs.

  • Once the script finishes, verify the output file was created by running:
ls extracted_jobs.json

You should see extracted_jobs.json listed. Open it to inspect the results.

  • View the first few entries by running:
python -c "import json; data=json.load(open('extracted_jobs.json')); print(f'{len(data)} jobs found'); print(json.dumps(data[:2], indent=2))"

You should see a count of unique jobs and the first two entries with title, company, link, description, and source_board fields.

Not seeing any results?

  • Make sure azure_foundry_config.json is in the same folder as job_search_agent.py. The script looks for it in the current working directory.
  • Check that your Fara-7B endpoint is still active in Azure Foundry. Serverless endpoints can time out after periods of inactivity.
  • If you see a timeout error, the agent may have encountered a CAPTCHA or unexpected page layout. Try running the script again. Fara-7B is experimental and may not succeed on every attempt.

Why is my job search agent not returning results?

Why might results vary between runs?

Fara-7B is a Computer Use Agent that visually perceives webpages. Unlike traditional web scrapers that parse HTML deterministically, CUA models can produce different results depending on page layout changes, load times, and visual complexity. Some runs may extract more listings than others.

Your agent now searches three job boards autonomously and saves structured results. Next up, you will build a scoring system that evaluates each job against your resume so only the best matches move forward.

Build the Resume Match Scoring System

Your agent has now extracted job postings from LinkedIn Jobs, Indeed, and MyVisaJobs. But not every job is worth pursuing.

In this step, you will build an intelligent scoring system that evaluates each extracted job against your resume. Only high-match jobs (scoring 4 or 5 out of 5) will move forward to the tailored resume and cover letter stage.

In this step, get ready to:
  • Create your base resume file that the agent uses for all matching.
  • Write a GPT-4o-mini scoring function that evaluates job-resume fit on a 1-5 scale.
  • Run the scoring system and filter results into a high-match list and a tracking database.
Create your base resume file

The scoring function needs your resume text to compare against each job description. You will create a plain text file containing your current resume.

  • In VS Code, create a new file called my_resume.txt inside your job-agent folder.
  • Paste your full resume into this file as plain text (name, education, skills, experience, projects).
  • Save my_resume.txt.

Keep it detailed

The more specific your resume text is, the better the scoring function can evaluate job fit. Include technical skills, years of experience, and specific projects or achievements.

  • Confirm the file exists by running:
ls my_resume.txt

You should see my_resume.txt listed in the output.

Deploy GPT-4o-mini and write the scoring function

The scoring function uses GPT-4o-mini to intelligently evaluate how well each job matches your resume. You need to deploy this model on Azure OpenAI Service first.

  • Return to Azure AI Foundry at ai.azure.com.
  • In the left navigation, select Models + endpoints, then click + Deploy model.
  • Search for gpt-4o-mini in the model picker and select it.
  • Choose Standard as the deployment type and keep the default deployment name (gpt-4o-mini).
  • Click Deploy.
  • Once deployed, copy the Endpoint URL and API key from the deployment's Keys and Endpoint section.

What is GPT-4o-mini?

GPT-4o-mini is a fast, cost-effective model ideal for text generation tasks. It supports 128,000 input tokens and 16,384 output tokens, making it perfect for processing job descriptions and generating resume evaluations.

At $0.15 per 1M input tokens and $0.60 per 1M output tokens, scoring 20 jobs costs less than a cent from your $200 Azure credit.

  • Add your GPT-4o-mini credentials to the .env file by adding these two lines below the existing entries:
AZURE_OPENAI_ENDPOINT=your-gpt4o-mini-endpoint-url
AZURE_OPENAI_API_KEY=your-gpt4o-mini-api-key

What do these variables do?

AZURE_OPENAI_ENDPOINT is the base URL of your Azure OpenAI resource (e.g. https://your-resource.openai.azure.com/).

AZURE_OPENAI_API_KEY authenticates your requests to the deployed GPT-4o-mini model.

  • Save .env.

Now you will write the scoring script. Create a new file called scorer.py in your job-agent folder.

  • Add the imports, client initialization, and database setup function by pasting this code into scorer.py:
import json
import os
import sqlite3
from datetime import datetime
from openai import AzureOpenAI
from dotenv import load_dotenv

# Load environment variables from .env
load_dotenv()

# Initialize Azure OpenAI client for GPT-4o-mini
client = AzureOpenAI(
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    api_version="2024-10-21"
)

def init_database():
    """Create the applications database and table if they don't exist."""
    conn = sqlite3.connect("applications.db")
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS applications (
            job_title TEXT,
            company TEXT,
            description TEXT,
            source_board TEXT,
            match_score INTEGER,
            match_reasoning TEXT,
            status TEXT,
            date_found TEXT
        )
    """)
    conn.commit()
    return conn

What does this code do?

  • AzureOpenAI(...) creates a client that connects to your deployed GPT-4o-mini model using the endpoint and key from .env.
  • api_version="2024-10-21" specifies the latest stable Azure OpenAI data plane API version.
  • init_database() creates a SQLite database with an applications table to track every scored job (both accepted and rejected).
  • Add the score_job function below the init_database function:
def score_job(resume_text, job_description):
    """Score a job against the resume using GPT-4o-mini. Returns {score: int, reasoning: str}."""
    prompt = f"""You are a career advisor evaluating job-resume fit.
Score how well this resume matches the job description on a scale of 1-5:
1 = Poor match (few relevant skills or experience)
2 = Below average match
3 = Moderate match (some relevant skills)
4 = Good match (strong skills overlap and relevant experience)
5 = Excellent match (highly relevant skills, experience, and role alignment)

Consider these factors:
- Skills overlap
- Experience relevance
- Role alignment
- H1B sponsorship likelihood

RESUME:
{resume_text}

JOB DESCRIPTION:
{job_description}

Respond in JSON format:
{{"score": <1-5>, "reasoning": "<brief explanation>"}}"""

    # Call GPT-4o-mini to evaluate the match
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3
    )

    # Parse the JSON response
    result = json.loads(response.choices[0].message.content)
    return result

What does this function do?

  • It sends your resume and a job description to GPT-4o-mini with a detailed scoring rubric.
  • The model evaluates skills overlap, experience relevance, role alignment, and H1B sponsorship likelihood.
  • It returns a structured result with a numeric score (1-5) and a reasoning string explaining the evaluation.
  • temperature=0.3 keeps responses consistent across multiple evaluations of the same job.

Why use the deployment name as the model parameter?

Azure OpenAI uses your deployment name to route requests. When you deployed GPT-4o-mini with the default name gpt-4o-mini, that becomes both the deployment name and the value you pass in the model parameter.

If you chose a custom deployment name (e.g. my-gpt4o-mini), use that exact name in the model parameter instead.

  • Add the main execution block at the bottom of scorer.py:
if __name__ == "__main__":
    # Load your resume
    with open("my_resume.txt", "r") as f:
        resume_text = f.read()

    # Load extracted jobs from the previous step
    with open("extracted_jobs.json", "r") as f:
        jobs = json.load(f)

    # Initialize the tracking database
    conn = init_database()
    cursor = conn.cursor()

    # Score each job against your resume
    high_match_jobs = []
    print(f"Scoring {len(jobs)} jobs against your resume...\n")

    for i, job in enumerate(jobs):
        print(f"[{i+1}/{len(jobs)}] Scoring: {job['title']} at {job['company']}...")

        result = score_job(resume_text, job.get("description", ""))
        score = result["score"]
        reasoning = result["reasoning"]

        # Log every job to the database (including rejected ones)
        cursor.execute("""
            INSERT INTO applications
            (job_title, company, description, source_board, match_score, match_reasoning, status, date_found)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            job["title"],
            job["company"],
            job.get("description", ""),
            job.get("source_board", "unknown"),
            score,
            reasoning,
            "scored",
            datetime.now().isoformat()
        ))

        # Keep only high-match jobs (score 4 or 5)
        if score >= 4:
            job["match_score"] = score
            job["match_reasoning"] = reasoning
            high_match_jobs.append(job)
            print(f"  Score: {score}/5 - MATCH! {reasoning}")
        else:
            print(f"  Score: {score}/5 - Skipped. {reasoning}")

    conn.commit()
    conn.close()

    # Save high-match jobs to a separate file
    with open("high_match_jobs.json", "w") as f:
        json.dump(high_match_jobs, f, indent=2)

    print(f"\nDone! {len(high_match_jobs)}/{len(jobs)} jobs scored 4/5 or above.")
    print("High-match jobs saved to high_match_jobs.json")
    print("All scores logged to applications.db")

What does this main block do?

  • It reads your resume and the extracted jobs from the previous step.
  • It scores each job by calling score_job() and logs every result (accepted or rejected) to applications.db.
  • Jobs scoring 4 or 5 are added to high_match_jobs.json for the next pipeline stage.
  • Progress is printed in real time so you can watch the agent evaluate each job.
  • Save scorer.py.

\u2714\ufe0f Awesome, I've got everything!

Great. Double check you have saved scorer.py before moving on.

\u24e7 I'd like to double check the full code

Here is the complete scorer.py file:

import json
import os
import sqlite3
from datetime import datetime
from openai import AzureOpenAI
from dotenv import load_dotenv

# Load environment variables from .env
load_dotenv()

# Initialize Azure OpenAI client for GPT-4o-mini
client = AzureOpenAI(
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    api_version="2024-10-21"
)

def init_database():
    """Create the applications database and table if they don't exist."""
    conn = sqlite3.connect("applications.db")
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS applications (
            job_title TEXT,
            company TEXT,
            description TEXT,
            source_board TEXT,
            match_score INTEGER,
            match_reasoning TEXT,
            status TEXT,
            date_found TEXT
        )
    """)
    conn.commit()
    return conn

def score_job(resume_text, job_description):
    """Score a job against the resume using GPT-4o-mini. Returns {score: int, reasoning: str}."""
    prompt = f"""You are a career advisor evaluating job-resume fit.
Score how well this resume matches the job description on a scale of 1-5:
1 = Poor match (few relevant skills or experience)
2 = Below average match
3 = Moderate match (some relevant skills)
4 = Good match (strong skills overlap and relevant experience)
5 = Excellent match (highly relevant skills, experience, and role alignment)

Consider these factors:
- Skills overlap
- Experience relevance
- Role alignment
- H1B sponsorship likelihood

RESUME:
{resume_text}

JOB DESCRIPTION:
{job_description}

Respond in JSON format:
{{"score": <1-5>, "reasoning": "<brief explanation>"}}"""

    # Call GPT-4o-mini to evaluate the match
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.3
    )

    # Parse the JSON response
    result = json.loads(response.choices[0].message.content)
    return result

if __name__ == "__main__":
    # Load your resume
    with open("my_resume.txt", "r") as f:
        resume_text = f.read()

    # Load extracted jobs from the previous step
    with open("extracted_jobs.json", "r") as f:
        jobs = json.load(f)

    # Initialize the tracking database
    conn = init_database()
    cursor = conn.cursor()

    # Score each job against your resume
    high_match_jobs = []
    print(f"Scoring {len(jobs)} jobs against your resume...\n")

    for i, job in enumerate(jobs):
        print(f"[{i+1}/{len(jobs)}] Scoring: {job['title']} at {job['company']}...")

        result = score_job(resume_text, job.get("description", ""))
        score = result["score"]
        reasoning = result["reasoning"]

        # Log every job to the database (including rejected ones)
        cursor.execute("""
            INSERT INTO applications
            (job_title, company, description, source_board, match_score, match_reasoning, status, date_found)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            job["title"],
            job["company"],
            job.get("description", ""),
            job.get("source_board", "unknown"),
            score,
            reasoning,
            "scored",
            datetime.now().isoformat()
        ))

        # Keep only high-match jobs (score 4 or 5)
        if score >= 4:
            job["match_score"] = score
            job["match_reasoning"] = reasoning
            high_match_jobs.append(job)
            print(f"  Score: {score}/5 - MATCH! {reasoning}")
        else:
            print(f"  Score: {score}/5 - Skipped. {reasoning}")

    conn.commit()
    conn.close()

    # Save high-match jobs to a separate file
    with open("high_match_jobs.json", "w") as f:
        json.dump(high_match_jobs, f, indent=2)

    print(f"\nDone! {len(high_match_jobs)}/{len(jobs)} jobs scored 4/5 or above.")
    print("High-match jobs saved to high_match_jobs.json")
    print("All scores logged to applications.db")
Run the scoring system on all extracted jobs

Time to let GPT-4o-mini evaluate every job your agent found. The script will score each one and show you the results in real time.

  • Run the scoring script by executing this command:
python scorer.py

You should see output like this for each job, with scores and reasoning printed line by line:

Scoring 15 jobs against your resume...

[1/15] Scoring: Software Engineer at Microsoft...
  Score: 5/5 - MATCH! Strong skills overlap in Python and cloud...
[2/15] Scoring: Data Analyst at Acme Corp...
  Score: 2/5 - Skipped. Limited experience in data visualization...
...
Done! 6/15 jobs scored 4/5 or above.
High-match jobs saved to high_match_jobs.json
All scores logged to applications.db

What should I see?

Each job gets printed with its score and reasoning. Jobs scoring 4 or 5 are marked as a MATCH. Jobs below 4 are skipped with an explanation of why.

The final summary shows how many jobs passed the filter. Your exact numbers depend on how many jobs were extracted and how well they match your resume.

  • Verify high_match_jobs.json was created by running:
ls high_match_jobs.json applications.db

You should see both high_match_jobs.json and applications.db listed.

Seeing an authentication error?

  • Check that AZURE_OPENAI_ENDPOINT in .env is your base resource URL (e.g. https://your-resource.openai.azure.com/), not the full deployment path.
  • Verify your AZURE_OPENAI_API_KEY is copied correctly from the Azure portal without extra spaces.
  • Confirm your GPT-4o-mini deployment is active in Azure AI Foundry under Models + endpoints.

Help me debug my Azure OpenAI connection error

Seeing a JSON parse error?

  • GPT-4o-mini occasionally returns malformed JSON. Re-run the script and it usually resolves on the next attempt.
  • If the model parameter does not match your deployment name, you will get a 404 error. Check that your deployment is named gpt-4o-mini in Azure, or update the model value in scorer.py to match your custom deployment name.

Help me fix my GPT-4o-mini scoring function

Your scoring system is working. Next up, you will generate tailored resumes and cover letters for the high-match jobs that passed the filter.

Generate Tailored Resumes and Cover Letters

Your scoring system has filtered the job listings down to only high-match opportunities. Now it's time to put those matches to work.

In this step, you will build a generation pipeline that takes each high-scoring job description and your base resume, then produces a tailored resume and personalized cover letter using GPT-4o-mini. The generated materials get stored directly in your tracking database.

In this step, get ready to:
  • Confirm your GPT-4o-mini deployment is active on Azure OpenAI Service.
  • Write a Python function that generates tailored resumes and cover letters.
  • Test the generation pipeline and store results in your database.
Confirm your GPT-4o-mini deployment is active

Your Azure OpenAI endpoint and API key are already stored in .env from the scoring step. Before writing new code, confirm the deployment is still responsive.

  • Switch back to the Azure portal in your browser.
  • Navigate to your Azure OpenAI resource.
  • Click Model deployments in the left sidebar.
  • Verify that your gpt-4o-mini deployment shows a Succeeded status.

Why confirm the deployment?

Azure deployments can be paused or deleted if your free trial credit runs low. A quick visual check now saves debugging time later.

Write the generation function

The core of this step is a function called generate_materials that accepts a job description and your base resume, then returns a tailored resume and cover letter. Each document gets its own GPT-4o-mini call with a dedicated system prompt.

  • Create a new file called generator.py inside your job-agent folder by running:
New-Item generator.py
  • Open generator.py in VS Code and add the imports and client setup:
import os
import json
import sqlite3
from openai import AzureOpenAI
from dotenv import load_dotenv

# Load environment variables from .env
load_dotenv()

# Initialize Azure OpenAI client for GPT-4o-mini
client = AzureOpenAI(
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    api_version="2024-10-21"
)


def generate_materials(job_description, base_resume):
    """Generate a tailored resume and cover letter for a specific job."""

    # Construct the prompt for tailored resume generation
    resume_prompt = f"""Based on the following base resume and job description, create a tailored resume
that emphasizes the most relevant skills and experience for this specific role.
Keep all information truthful - only reorder and emphasize existing experience.

BASE RESUME:
{base_resume}

JOB DESCRIPTION:
{job_description}

Return ONLY the tailored resume text, formatted professionally."""

    # Call GPT-4o-mini for the tailored resume
    resume_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are an expert resume writer who tailors resumes to specific job descriptions."},
            {"role": "user", "content": resume_prompt}
        ],
        temperature=0.7
    )
    tailored_resume = resume_response.choices[0].message.content

    # Construct the prompt for cover letter generation
    cover_letter_prompt = f"""Based on the following resume and job description, write a personalized cover letter.
The letter should highlight why this candidate is a strong fit for this specific role.
Keep it concise (3-4 paragraphs) and professional.

RESUME:
{base_resume}

JOB DESCRIPTION:
{job_description}

Return ONLY the cover letter text."""

    # Call GPT-4o-mini for the cover letter
    cover_letter_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are an expert cover letter writer who creates personalized, compelling letters."},
            {"role": "user", "content": cover_letter_prompt}
        ],
        temperature=0.7
    )
    cover_letter = cover_letter_response.choices[0].message.content

    return {"tailored_resume": tailored_resume, "cover_letter": cover_letter}

What does this code do?

  • The AzureOpenAI client connects to your deployed GPT-4o-mini model using the endpoint and key from .env.
  • The generate_materials function makes two separate API calls. One generates a tailored resume that reorders and emphasizes your existing experience. The other writes a cover letter explaining why you fit the role.
  • Setting temperature=0.7 adds slight variation so each document reads naturally without being too random.
  • Below the generate_materials function, add the pipeline function that processes all high-match jobs and stores results in the database:
def process_high_match_jobs():
    """Generate materials for all high-match jobs and store in the database."""

    # Read the base resume
    with open("my_resume.txt", "r") as f:
        base_resume = f.read()

    # Load high-match jobs
    with open("high_match_jobs.json", "r") as f:
        high_match_jobs = json.load(f)

    # Connect to the database and add new columns if needed
    conn = sqlite3.connect("applications.db")
    cursor = conn.cursor()

    # Add tailored_resume and cover_letter columns if they don't exist
    try:
        cursor.execute("ALTER TABLE applications ADD COLUMN tailored_resume TEXT")
    except sqlite3.OperationalError:
        pass
    try:
        cursor.execute("ALTER TABLE applications ADD COLUMN cover_letter TEXT")
    except sqlite3.OperationalError:
        pass

    # Generate materials for each high-match job
    for job in high_match_jobs:
        print(f"Generating materials for: {job['job_title']} at {job['company']}...")
        materials = generate_materials(job["description"], base_resume)

        # Update the database with generated materials
        cursor.execute("""
            UPDATE applications
            SET tailored_resume = ?, cover_letter = ?, status = 'materials_generated'
            WHERE job_title = ? AND company = ?
        """, (materials["tailored_resume"], materials["cover_letter"],
              job["job_title"], job["company"]))

        print(f"  Done - resume and cover letter generated")

    conn.commit()
    conn.close()
    print(f"\nGenerated materials for {len(high_match_jobs)} high-match jobs.")


if __name__ == "__main__":
    process_high_match_jobs()

What does this code do?

  • The process_high_match_jobs function reads your base resume from my_resume.txt and loads the filtered jobs from high_match_jobs.json.
  • It adds tailored_resume and cover_letter columns to your existing applications.db database. The try/except blocks handle the case where columns already exist from a previous run.
  • For each job, it calls generate_materials, stores the output in the database, and updates the job status to materials_generated.
  • Save generator.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved generator.py before moving on.

ⓧ I'd like to double check the full code

import os
import json
import sqlite3
from openai import AzureOpenAI
from dotenv import load_dotenv

# Load environment variables from .env
load_dotenv()

# Initialize Azure OpenAI client for GPT-4o-mini
client = AzureOpenAI(
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    api_version="2024-10-21"
)


def generate_materials(job_description, base_resume):
    """Generate a tailored resume and cover letter for a specific job."""

    # Construct the prompt for tailored resume generation
    resume_prompt = f"""Based on the following base resume and job description, create a tailored resume
that emphasizes the most relevant skills and experience for this specific role.
Keep all information truthful - only reorder and emphasize existing experience.

BASE RESUME:
{base_resume}

JOB DESCRIPTION:
{job_description}

Return ONLY the tailored resume text, formatted professionally."""

    # Call GPT-4o-mini for the tailored resume
    resume_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are an expert resume writer who tailors resumes to specific job descriptions."},
            {"role": "user", "content": resume_prompt}
        ],
        temperature=0.7
    )
    tailored_resume = resume_response.choices[0].message.content

    # Construct the prompt for cover letter generation
    cover_letter_prompt = f"""Based on the following resume and job description, write a personalized cover letter.
The letter should highlight why this candidate is a strong fit for this specific role.
Keep it concise (3-4 paragraphs) and professional.

RESUME:
{base_resume}

JOB DESCRIPTION:
{job_description}

Return ONLY the cover letter text."""

    # Call GPT-4o-mini for the cover letter
    cover_letter_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are an expert cover letter writer who creates personalized, compelling letters."},
            {"role": "user", "content": cover_letter_prompt}
        ],
        temperature=0.7
    )
    cover_letter = cover_letter_response.choices[0].message.content

    return {"tailored_resume": tailored_resume, "cover_letter": cover_letter}


def process_high_match_jobs():
    """Generate materials for all high-match jobs and store in the database."""

    # Read the base resume
    with open("my_resume.txt", "r") as f:
        base_resume = f.read()

    # Load high-match jobs
    with open("high_match_jobs.json", "r") as f:
        high_match_jobs = json.load(f)

    # Connect to the database and add new columns if needed
    conn = sqlite3.connect("applications.db")
    cursor = conn.cursor()

    # Add tailored_resume and cover_letter columns if they don't exist
    try:
        cursor.execute("ALTER TABLE applications ADD COLUMN tailored_resume TEXT")
    except sqlite3.OperationalError:
        pass
    try:
        cursor.execute("ALTER TABLE applications ADD COLUMN cover_letter TEXT")
    except sqlite3.OperationalError:
        pass

    # Generate materials for each high-match job
    for job in high_match_jobs:
        print(f"Generating materials for: {job['job_title']} at {job['company']}...")
        materials = generate_materials(job["description"], base_resume)

        # Update the database with generated materials
        cursor.execute("""
            UPDATE applications
            SET tailored_resume = ?, cover_letter = ?, status = 'materials_generated'
            WHERE job_title = ? AND company = ?
        """, (materials["tailored_resume"], materials["cover_letter"],
              job["job_title"], job["company"]))

        print(f"  Done - resume and cover letter generated")

    conn.commit()
    conn.close()
    print(f"\nGenerated materials for {len(high_match_jobs)} high-match jobs.")


if __name__ == "__main__":
    process_high_match_jobs()
Test the generation pipeline

With the script complete, run it to generate tailored documents for every job that scored 4 or 5 in your match scoring.

  • Run the generator by executing this command in your terminal:
python generator.py
  • Watch the terminal output. You should see a line for each high-match job being processed, followed by a confirmation message.

What should I see?

The script prints the job title and company for each job as it generates materials. Expect each job to take a few seconds because it makes two API calls (one for the resume, one for the cover letter). At the end, you should see a summary line showing the total count of jobs processed.

  • Verify the database was updated by running this quick check:
python -c "import sqlite3; conn = sqlite3.connect('applications.db'); cursor = conn.cursor(); cursor.execute('SELECT job_title, company, status FROM applications WHERE tailored_resume IS NOT NULL'); rows = cursor.fetchall(); [print(f'{r[0]} at {r[1]} - {r[2]}') for r in rows]; conn.close()"
  • You should see each high-match job listed with the status materials_generated.

Seeing an authentication error?

  • Make sure your .env file has the correct AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY values. Copy them again from the Azure portal if needed.
  • Confirm your GPT-4o-mini deployment name in Azure matches the model parameter in the script (gpt-4o-mini). If you gave it a custom deployment name, update the model value in both client.chat.completions.create calls.
  • Check that your Azure free trial still has credit remaining by visiting Cost Management in the Azure portal.

Why is my generator.py failing to connect to Azure OpenAI?

Your agent can now produce job-specific application materials automatically. Next up, you will wire in email notifications and a tracking system so you can review and approve each application before it goes out.

Build the Email Approval and Tracking System

Your agent can now find jobs, score them against your resume, and generate tailored application materials. But you still need a way to review everything before actually applying.

In this step, you'll build an email notification system that sends you each high-match job for approval. You'll also expand your tracking database with additional columns for recruiter contacts and sponsorship verification.

In this step, get ready to:
  • Set up Gmail app password credentials for SMTP email sending.
  • Write an approval email function that sends job details and generated materials for your review.
  • Expand the SQLite database schema with recruiter contact, sponsorship, and email tracking columns.
Set up email sending with a Gmail app password

Python's built-in smtplib module can send emails through Gmail's SMTP server. To authenticate without exposing your main Google password, you'll use a Gmail app password.

What is a Gmail app password?

An app password is a 16-digit passcode that gives a specific app permission to access your Google Account. It requires 2-Step Verification to be enabled on your account.

This keeps your main Google password safe while allowing your Python script to send emails on your behalf.

  • Make sure 2-Step Verification is enabled on your Google Account. Open your Google Account, go to Security & sign-in, and confirm 2-Step Verification is turned on.
  • Visit https://myaccount.google.com/apppasswords to create a new app password.
  • Enter a name like Job Agent for the app, then click Create.
  • Copy the 16-digit password that appears. You will only see it once.

Don't see the App Passwords option?

App Passwords only appear if you have 2-Step Verification enabled with a method other than security keys only. If you use a work or school Google account, your organization may have disabled this feature.

help me set up a Gmail app password for my Python script

Now add your email credentials to the project's environment file.

  • Open .env in your editor and add the following three lines at the bottom:
EMAIL_SENDER=your-gmail-address@gmail.com
EMAIL_PASSWORD=your-sixteen-digit-app-password
EMAIL_RECIPIENT=your-email-address@gmail.com

What do these variables do?

  • EMAIL_SENDER is the Gmail address your script sends from.
  • EMAIL_PASSWORD is the 16-digit app password you just generated (not your regular Gmail password).
  • EMAIL_RECIPIENT is where the approval emails land. This can be the same address or a different one.
  • Replace each placeholder with your actual values, then save .env.

Keep your .env out of version control

Never commit .env to Git. If you plan to push this project to GitHub later, add .env to your .gitignore file.

Write the approval email function

The email function will compose an HTML message containing the job details, your match score, and the generated application materials. This lets you review everything in your inbox before deciding whether to apply.

  • Create a new file called emailer.py in your job-agent folder.
  • Add the imports and configuration section by pasting this code:
import smtplib
import sqlite3
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from dotenv import load_dotenv

load_dotenv()

# Load email credentials from environment variables
EMAIL_SENDER = os.getenv("EMAIL_SENDER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
EMAIL_RECIPIENT = os.getenv("EMAIL_RECIPIENT")

# Path to the SQLite database
DB_PATH = "applications.db"

What does this code do?

  • smtplib is Python's built-in library for sending emails via SMTP. No extra installation needed.
  • MIMEMultipart and MIMEText let you compose HTML emails with proper formatting.
  • load_dotenv() reads your .env file so the email credentials are available as environment variables.
  • DB_PATH points to the existing applications.db so this module can also manage database records.
  • Below the configuration, add the send_approval_email function:
def send_approval_email(job, tailored_resume, cover_letter):
    """Send an approval email with job details and generated materials."""

    # Build the subject line with job title and company
    subject = f"Job Application Review: {job['job_title']} at {job['company']}"

    # Compose the HTML body with all relevant details
    html_body = f"""
    <h2>New Job Match for Your Review</h2>
    <h3>{job['job_title']} at {job['company']}</h3>
    <p><strong>Source Board:</strong> {job.get('source_board', 'Unknown')}</p>
    <p><strong>Match Score:</strong> {job['match_score']}/5</p>
    <p><strong>Reasoning:</strong> {job.get('match_reasoning', 'N/A')}</p>
    <hr>
    <h3>Job Description</h3>
    <p>{job.get('description', 'No description available')}</p>
    <hr>
    <h3>Tailored Resume</h3>
    <pre>{tailored_resume}</pre>
    <hr>
    <h3>Cover Letter</h3>
    <pre>{cover_letter}</pre>
    <hr>
    <p><em>Reply APPROVE to proceed or SKIP to pass on this opportunity.</em></p>
    """

    # Create the email message container
    msg = MIMEMultipart("alternative")
    msg["Subject"] = subject
    msg["From"] = EMAIL_SENDER
    msg["To"] = EMAIL_RECIPIENT
    msg.attach(MIMEText(html_body, "html"))

    # Connect to Gmail SMTP server and send the email
    with smtplib.SMTP("smtp.gmail.com", 587) as server:
        server.starttls()
        server.login(EMAIL_SENDER, EMAIL_PASSWORD)
        server.sendmail(EMAIL_SENDER, EMAIL_RECIPIENT, msg.as_string())

    print(f"Approval email sent for: {job['job_title']} at {job['company']}")
    return True

What does this function do?

  • It takes the job dictionary (with title, company, score, reasoning), the tailored resume, and the cover letter as inputs.
  • It composes an HTML email that displays everything in a readable format with section dividers.
  • smtp.gmail.com on port 587 is Gmail's SMTP server. The starttls() call encrypts the connection before sending your credentials.
  • The function returns True on success so you can track which emails were sent.
  • Save emailer.py.

Getting an authentication error when testing?

Make sure you pasted the 16-digit app password (not your regular Gmail password) into EMAIL_PASSWORD in .env. Also confirm there are no extra spaces around the value.

help me fix Gmail SMTP authentication errors in my Python script

Expand the database schema and add utility functions

Your existing applications.db tracks basic job data. You need additional columns to record recruiter contact info, sponsorship verification status, and whether an approval email has been sent.

  • Add the database migration function below send_approval_email in emailer.py:
def migrate_database():
    """Add new tracking columns to the existing applications table."""

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()

    # Add columns for recruiter contact, sponsorship status, and email tracking
    new_columns = [
        ("recruiter_contact", "TEXT DEFAULT ''"),
        ("sponsorship_verified", "INTEGER DEFAULT 0"),
        ("email_sent", "INTEGER DEFAULT 0"),
    ]

    for col_name, col_type in new_columns:
        try:
            cursor.execute(f"ALTER TABLE applications ADD COLUMN {col_name} {col_type}")
        except sqlite3.OperationalError:
            # Column already exists, skip it
            pass

    conn.commit()
    conn.close()
    print("Database migration complete.")

What does this function do?

  • It uses ALTER TABLE to add three new columns to your existing applications table without losing any data.
  • recruiter_contact stores the hiring manager or recruiter's email if found.
  • sponsorship_verified flags whether the company's H1B sponsorship status was confirmed (from MyVisaJobs data).
  • email_sent tracks whether the approval email was sent for that job.
  • The try/except block makes the migration safe to run multiple times. If a column already exists, it skips it.
  • Add the insert_application utility function below migrate_database:
def insert_application(job, tailored_resume, cover_letter):
    """Insert a new application record into the database."""

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()

    # Insert a complete application record with all tracking fields
    cursor.execute("""
        INSERT INTO applications (
            job_title, company, description, source_board,
            match_score, match_reasoning, tailored_resume,
            cover_letter, recruiter_contact, sponsorship_verified,
            email_sent, status, date_found
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
    """, (
        job["job_title"],
        job["company"],
        job.get("description", ""),
        job.get("source_board", ""),
        job["match_score"],
        job.get("match_reasoning", ""),
        tailored_resume,
        cover_letter,
        job.get("recruiter_contact", ""),
        1 if job.get("sponsorship_verified") else 0,
        0,
        "pending_review",
    ))

    conn.commit()
    conn.close()

What does this function do?

This inserts a full application record including all new and existing columns. The datetime('now') call automatically timestamps when the job was found. New records default to pending_review status with email_sent set to 0.

  • Add the update_application_status function and the __main__ block at the bottom of emailer.py:
def update_application_status(job_title, company, status, email_sent=False):
    """Update the status and email tracking for an application."""

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()

    # Update the status and email_sent flag for a specific application
    cursor.execute("""
        UPDATE applications
        SET status = ?, email_sent = ?
        WHERE job_title = ? AND company = ?
    """, (status, 1 if email_sent else 0, job_title, company))

    conn.commit()
    conn.close()


if __name__ == "__main__":
    # Run the migration when this script is executed directly
    migrate_database()

What does this code do?

  • update_application_status lets you mark jobs as approved, rejected, or applied after reviewing the email.
  • The if __name__ == "__main__" block means running python emailer.py directly will trigger the database migration.
  • Save emailer.py.
  • Run the migration to add the new columns by running this command:
python emailer.py
  • You should see Database migration complete. printed in your terminal.

Seeing a 'no such table' error?

This means applications.db doesn't have the applications table yet. Make sure you ran the scorer from the previous step first, as that creates the initial table structure.

help me fix a missing table error in my SQLite database

✔️ Awesome, I've got everything!

Great. Double check you've saved emailer.py and that the migration ran successfully.

ⓧ I'd like to double check the full code

Here is the complete emailer.py file:

import smtplib
import sqlite3
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from dotenv import load_dotenv

load_dotenv()

# Load email credentials from environment variables
EMAIL_SENDER = os.getenv("EMAIL_SENDER")
EMAIL_PASSWORD = os.getenv("EMAIL_PASSWORD")
EMAIL_RECIPIENT = os.getenv("EMAIL_RECIPIENT")

# Path to the SQLite database
DB_PATH = "applications.db"


def send_approval_email(job, tailored_resume, cover_letter):
    """Send an approval email with job details and generated materials."""

    # Build the subject line with job title and company
    subject = f"Job Application Review: {job['job_title']} at {job['company']}"

    # Compose the HTML body with all relevant details
    html_body = f"""
    <h2>New Job Match for Your Review</h2>
    <h3>{job['job_title']} at {job['company']}</h3>
    <p><strong>Source Board:</strong> {job.get('source_board', 'Unknown')}</p>
    <p><strong>Match Score:</strong> {job['match_score']}/5</p>
    <p><strong>Reasoning:</strong> {job.get('match_reasoning', 'N/A')}</p>
    <hr>
    <h3>Job Description</h3>
    <p>{job.get('description', 'No description available')}</p>
    <hr>
    <h3>Tailored Resume</h3>
    <pre>{tailored_resume}</pre>
    <hr>
    <h3>Cover Letter</h3>
    <pre>{cover_letter}</pre>
    <hr>
    <p><em>Reply APPROVE to proceed or SKIP to pass on this opportunity.</em></p>
    """

    # Create the email message container
    msg = MIMEMultipart("alternative")
    msg["Subject"] = subject
    msg["From"] = EMAIL_SENDER
    msg["To"] = EMAIL_RECIPIENT
    msg.attach(MIMEText(html_body, "html"))

    # Connect to Gmail SMTP server and send the email
    with smtplib.SMTP("smtp.gmail.com", 587) as server:
        server.starttls()
        server.login(EMAIL_SENDER, EMAIL_PASSWORD)
        server.sendmail(EMAIL_SENDER, EMAIL_RECIPIENT, msg.as_string())

    print(f"Approval email sent for: {job['job_title']} at {job['company']}")
    return True


def migrate_database():
    """Add new tracking columns to the existing applications table."""

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()

    # Add columns for recruiter contact, sponsorship status, and email tracking
    new_columns = [
        ("recruiter_contact", "TEXT DEFAULT ''"),
        ("sponsorship_verified", "INTEGER DEFAULT 0"),
        ("email_sent", "INTEGER DEFAULT 0"),
    ]

    for col_name, col_type in new_columns:
        try:
            cursor.execute(f"ALTER TABLE applications ADD COLUMN {col_name} {col_type}")
        except sqlite3.OperationalError:
            # Column already exists, skip it
            pass

    conn.commit()
    conn.close()
    print("Database migration complete.")


def insert_application(job, tailored_resume, cover_letter):
    """Insert a new application record into the database."""

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()

    # Insert a complete application record with all tracking fields
    cursor.execute("""
        INSERT INTO applications (
            job_title, company, description, source_board,
            match_score, match_reasoning, tailored_resume,
            cover_letter, recruiter_contact, sponsorship_verified,
            email_sent, status, date_found
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
    """, (
        job["job_title"],
        job["company"],
        job.get("description", ""),
        job.get("source_board", ""),
        job["match_score"],
        job.get("match_reasoning", ""),
        tailored_resume,
        cover_letter,
        job.get("recruiter_contact", ""),
        1 if job.get("sponsorship_verified") else 0,
        0,
        "pending_review",
    ))

    conn.commit()
    conn.close()


def update_application_status(job_title, company, status, email_sent=False):
    """Update the status and email tracking for an application."""

    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()

    # Update the status and email_sent flag for a specific application
    cursor.execute("""
        UPDATE applications
        SET status = ?, email_sent = ?
        WHERE job_title = ? AND company = ?
    """, (status, 1 if email_sent else 0, job_title, company))

    conn.commit()
    conn.close()


if __name__ == "__main__":
    # Run the migration when this script is executed directly
    migrate_database()

Your email and tracking system is ready. Next up, you'll wire all the components together into a single orchestration script that runs the full pipeline end-to-end.

Tie It All Together

You have all four modules built and tested independently. The job search agent finds postings, the scorer evaluates them, the generator creates tailored materials, and the emailer sends approval emails.

Now you need a single orchestration script that calls each module in sequence and connects them into a complete workflow. This script will also handle errors gracefully so one failed job doesn't crash the entire pipeline.

In this step, get ready to:
  • Build a main.py orchestration script that runs the full pipeline end-to-end.
  • Add error handling, logging, and status updates for monitoring.
  • Verify the filtering works by running the complete pipeline.
Build the orchestration script

The orchestration script is the conductor that calls each module in the right order and passes data between them. It also decides what happens when something goes wrong at any stage.

  • In VS Code, create a new file called main.py inside your job-agent folder.
  • Add the imports and logging configuration by pasting the following code:
import json
import sqlite3
import logging
from datetime import datetime

from job_search_agent import search_all_boards
from scorer import score_job
from generator import generate_materials
from emailer import send_approval_email

# Set up logging to both file and console
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
    handlers=[
        logging.FileHandler("pipeline.log"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

What does this code do?

The imports pull in each module you built in previous steps. The logging.basicConfig call configures Python's logging system to write messages to both a file (pipeline.log) and the console simultaneously.

Every log entry includes a timestamp, severity level, and the message itself. This means you can review what happened during a pipeline run even after it finishes.

  • Below the logging setup, add a helper function that stores job records in your SQLite database:
def store_application(job, score, reasoning, tailored_resume="", cover_letter="", email_sent=False):
    """Store a job application record in the SQLite database."""
    conn = sqlite3.connect("applications.db")
    cursor = conn.cursor()
    cursor.execute("""
        INSERT INTO applications (
            job_title, company, description, source_board,
            match_score, match_reasoning, tailored_resume,
            cover_letter, recruiter_contact, sponsorship_verified,
            email_sent, status, date_found
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        job.get("title", ""),
        job.get("company", ""),
        job.get("description", ""),
        job.get("source_board", ""),
        score,
        reasoning,
        tailored_resume,
        cover_letter,
        job.get("recruiter_contact", ""),
        job.get("sponsorship_verified", False),
        email_sent,
        "applied" if email_sent else "scored",
        datetime.now().isoformat()
    ))
    conn.commit()
    conn.close()
    logger.info(f"  Stored in database: {job.get('title', 'unknown')} ({('applied' if email_sent else 'scored')})")

What does this function do?

The store_application function inserts a single job record into applications.db using the schema you created in the previous step. It maps each field from the job dictionary to the corresponding database column.

The status field automatically becomes "applied" if an email was sent, or "scored" if the job was only evaluated. This lets you filter the database later to see which jobs actually had materials generated.

  • Now add the main pipeline function that connects all your modules together:
def run_pipeline():
    """Run the full job application pipeline end-to-end."""
    logger.info("=" * 50)
    logger.info("STARTING JOB APPLICATION PIPELINE")
    logger.info("=" * 50)

    # Phase 1: Search all job boards with Fara-7B
    logger.info("Phase 1: Searching job boards with Fara-7B...")
    try:
        jobs = search_all_boards()
        logger.info(f"Found {len(jobs)} total jobs across all boards.")
    except Exception as e:
        logger.error(f"Job search failed: {e}")
        return

    # Load your base resume for scoring
    with open("my_resume.txt", "r") as f:
        resume = f.read()

    # Phase 2: Score each job and filter to 4/5+
    logger.info("Phase 2: Scoring jobs against your resume...")
    high_match_jobs = []
    for job in jobs:
        try:
            score, reasoning = score_job(resume, job["description"])
            logger.info(f"  {job['title']} at {job['company']}: {score}/5")

            if score >= 4:
                job["match_score"] = score
                job["match_reasoning"] = reasoning
                high_match_jobs.append(job)
            else:
                # Store low-scoring jobs in database but skip generation
                store_application(job, score, reasoning)
                logger.info(f"    Below threshold - logged and skipped.")
        except Exception as e:
            logger.error(f"  Scoring failed for {job.get('title', 'unknown')}: {e}")

    logger.info(f"Phase 2 complete: {len(high_match_jobs)}/{len(jobs)} jobs scored 4/5 or above.")

    # Save high-match jobs to file
    with open("high_match_jobs.json", "w") as f:
        json.dump(high_match_jobs, f, indent=2)

    # Phase 3: Generate tailored materials for high-match jobs only
    logger.info("Phase 3: Generating materials for high-match jobs...")
    for job in high_match_jobs:
        try:
            # Generate tailored resume and cover letter
            tailored_resume, cover_letter = generate_materials(job["description"], resume)
            logger.info(f"  Generated materials for {job['title']} at {job['company']}.")

            # Send approval email
            send_approval_email(job, tailored_resume, cover_letter)
            logger.info(f"  Approval email sent for {job['title']}.")

            # Store the full application in the database
            store_application(job, job["match_score"], job["match_reasoning"],
                            tailored_resume, cover_letter, email_sent=True)
        except Exception as e:
            logger.error(f"  Failed for {job.get('title', 'unknown')}: {e}")
            # Still store what we have so nothing is lost
            store_application(job, job.get("match_score", 0),
                            job.get("match_reasoning", ""))

    # Final summary
    logger.info("=" * 50)
    logger.info("PIPELINE COMPLETE")
    logger.info(f"  Total jobs found: {len(jobs)}")
    logger.info(f"  High-match jobs (4/5+): {len(high_match_jobs)}")
    logger.info(f"  Low-match jobs (logged, skipped): {len(jobs) - len(high_match_jobs)}")
    logger.info("=" * 50)


if __name__ == "__main__":
    run_pipeline()

What does this pipeline do?

  • Phase 1 calls search_all_boards() from job_search_agent.py. If the entire search fails, the pipeline stops early rather than continuing with no data.
  • Phase 2 loops through every found job and calls score_job() from scorer.py. Jobs scoring below 4 are stored in the database but skipped for generation.
  • Phase 3 only processes high-match jobs (4/5 or above). For each one, it generates tailored materials, sends an approval email, and stores the full record in the database.
  • Each phase has try/except blocks so that one failed job does not crash the entire run. Errors are logged and the pipeline continues with the next job.
  • Save main.py.

Seeing import errors?

Make sure your virtual environment is activated before running the script. Check that all module files (job_search_agent.py, scorer.py, generator.py, emailer.py) are in the same job-agent folder as main.py.

help me debug import errors in my main.py orchestration script

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py with all three sections: imports/logging, the store_application function, and the run_pipeline function.

ⓧ I'd like to double check the full code

import json
import sqlite3
import logging
from datetime import datetime

from job_search_agent import search_all_boards
from scorer import score_job
from generator import generate_materials
from emailer import send_approval_email

# Set up logging to both file and console
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(levelname)s - %(message)s",
    handlers=[
        logging.FileHandler("pipeline.log"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


def store_application(job, score, reasoning, tailored_resume="", cover_letter="", email_sent=False):
    """Store a job application record in the SQLite database."""
    conn = sqlite3.connect("applications.db")
    cursor = conn.cursor()
    cursor.execute("""
        INSERT INTO applications (
            job_title, company, description, source_board,
            match_score, match_reasoning, tailored_resume,
            cover_letter, recruiter_contact, sponsorship_verified,
            email_sent, status, date_found
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        job.get("title", ""),
        job.get("company", ""),
        job.get("description", ""),
        job.get("source_board", ""),
        score,
        reasoning,
        tailored_resume,
        cover_letter,
        job.get("recruiter_contact", ""),
        job.get("sponsorship_verified", False),
        email_sent,
        "applied" if email_sent else "scored",
        datetime.now().isoformat()
    ))
    conn.commit()
    conn.close()
    logger.info(f"  Stored in database: {job.get('title', 'unknown')} ({('applied' if email_sent else 'scored')})")


def run_pipeline():
    """Run the full job application pipeline end-to-end."""
    logger.info("=" * 50)
    logger.info("STARTING JOB APPLICATION PIPELINE")
    logger.info("=" * 50)

    # Phase 1: Search all job boards with Fara-7B
    logger.info("Phase 1: Searching job boards with Fara-7B...")
    try:
        jobs = search_all_boards()
        logger.info(f"Found {len(jobs)} total jobs across all boards.")
    except Exception as e:
        logger.error(f"Job search failed: {e}")
        return

    # Load your base resume for scoring
    with open("my_resume.txt", "r") as f:
        resume = f.read()

    # Phase 2: Score each job and filter to 4/5+
    logger.info("Phase 2: Scoring jobs against your resume...")
    high_match_jobs = []
    for job in jobs:
        try:
            score, reasoning = score_job(resume, job["description"])
            logger.info(f"  {job['title']} at {job['company']}: {score}/5")

            if score >= 4:
                job["match_score"] = score
                job["match_reasoning"] = reasoning
                high_match_jobs.append(job)
            else:
                # Store low-scoring jobs in database but skip generation
                store_application(job, score, reasoning)
                logger.info(f"    Below threshold - logged and skipped.")
        except Exception as e:
            logger.error(f"  Scoring failed for {job.get('title', 'unknown')}: {e}")

    logger.info(f"Phase 2 complete: {len(high_match_jobs)}/{len(jobs)} jobs scored 4/5 or above.")

    # Save high-match jobs to file
    with open("high_match_jobs.json", "w") as f:
        json.dump(high_match_jobs, f, indent=2)

    # Phase 3: Generate tailored materials for high-match jobs only
    logger.info("Phase 3: Generating materials for high-match jobs...")
    for job in high_match_jobs:
        try:
            # Generate tailored resume and cover letter
            tailored_resume, cover_letter = generate_materials(job["description"], resume)
            logger.info(f"  Generated materials for {job['title']} at {job['company']}.")

            # Send approval email
            send_approval_email(job, tailored_resume, cover_letter)
            logger.info(f"  Approval email sent for {job['title']}.")

            # Store the full application in the database
            store_application(job, job["match_score"], job["match_reasoning"],
                            tailored_resume, cover_letter, email_sent=True)
        except Exception as e:
            logger.error(f"  Failed for {job.get('title', 'unknown')}: {e}")
            # Still store what we have so nothing is lost
            store_application(job, job.get("match_score", 0),
                            job.get("match_reasoning", ""))

    # Final summary
    logger.info("=" * 50)
    logger.info("PIPELINE COMPLETE")
    logger.info(f"  Total jobs found: {len(jobs)}")
    logger.info(f"  High-match jobs (4/5+): {len(high_match_jobs)}")
    logger.info(f"  Low-match jobs (logged, skipped): {len(jobs) - len(high_match_jobs)}")
    logger.info("=" * 50)


if __name__ == "__main__":
    run_pipeline()
Run the full pipeline end-to-end

Time to watch your agent work through the entire workflow. The pipeline will search all three job boards, score every listing, and only generate materials for the ones worth pursuing.

  • Make sure your virtual environment is activated in the terminal.
  • Run the full pipeline by executing this command:
python main.py

The pipeline takes several minutes to complete because Fara-7B is opening a browser and navigating each job board visually. You will see real-time status updates in your terminal as each phase progresses.

What should I see?

Your terminal output shows each phase with timestamps. Phase 1 reports how many jobs were found across all boards. Phase 2 prints each job with its score. Jobs below 4/5 show "Below threshold - logged and skipped." Phase 3 only processes jobs that scored 4 or 5.

At the end, you see a summary with the total jobs found, how many were high-match, and how many were logged but skipped. A pipeline.log file appears in your project folder with the same output for later review.

  • Verify the filtering is working correctly by checking that low-scoring jobs appear in your database but do NOT have generated resumes or cover letters.
  • Confirm that high-scoring jobs (4/5 or above) have both a tailored resume and cover letter stored in the database, and that approval emails arrived in your inbox.

Pipeline crashes partway through?

Check pipeline.log for the last successful step before the error. If Fara-7B times out during the job search phase, your Azure Foundry endpoint may be cold-starting. Try running the pipeline again.

If scoring or generation fails, verify that your .env file has the correct Azure OpenAI credentials. The error messages in the log tell you exactly which job and which phase failed.

help me debug my pipeline run that crashed partway through

Secret mission

Add Cold Email Drafting and Daily Scheduling

Your agent finds jobs and generates materials. But what if it also drafted personalized cold emails to recruiters and ran itself every morning at 8 AM without you lifting a finger? Extend your pipeline into a fully autonomous job search assistant.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Your Azure endpoints consume from the $200 free trial credit whenever they process requests. Your local code, database, and generated documents have no ongoing cost.

Cost warning

Your Fara-7B and GPT-4o-mini endpoints bill per token from your $200 Azure credit. At estimated usage of $0.50-1.50 per day, the credit lasts well beyond the 30-day trial. However, if you leave them deployed and forget about them, other Azure services or unexpected usage could eat into your balance.

If you are done with this project, choose Pause or Delete below to stop all billing immediately.

Resources you used:

  • Azure AI Foundry Fara-7B serverless endpoint (token-based billing from your $200 credit).
  • Azure OpenAI GPT-4o-mini deployment (token-based billing from your $200 credit).
  • Local project files in the job-agent/ folder on your Desktop (code, database, generated resumes, cover letters).

Keep everything running

No action needed. Choose this if you are still actively using your job search agent daily.

  • Your Fara-7B and GPT-4o-mini endpoints remain active and ready to process requests.
  • Monitor your remaining credit in the Azure portal under Cost Management + Billing.
  • At the estimated $0.50-1.50 per day, your $200 credit lasts well beyond the 30-day trial period.
  • Your local files, database, and scheduler continue to work as configured.

Pause - I'll come back to this later

Stop the model deployments in Azure to halt all billing. You can re-deploy them when you want to use the agent again.

  • Open Azure AI Foundry at ai.azure.com.
  • Navigate to your project, then select Deployments in the left sidebar.
  • Find your Fara-7B deployment, click the three-dot menu, and select Delete.
  • Open the Azure portal at portal.azure.com.
  • Navigate to your Azure OpenAI resource, select Model deployments, find your GPT-4o-mini deployment, and delete it.
  • If you set up Windows Task Scheduler or a running scheduler.py process, stop it so it does not attempt to run the agent while endpoints are removed.
  • Your local code, database, and generated documents remain on your machine. Re-deploy the models when you are ready to resume.

Delete - I don't want to use this again

Remove all cloud resources and local project files to start completely fresh.

Delete Azure cloud resources
  • Open Azure AI Foundry at ai.azure.com.
  • Navigate to your project, then select Deployments in the left sidebar.
  • Find your Fara-7B deployment, click the three-dot menu, and select Delete.
  • Open the Azure portal at portal.azure.com.
  • Navigate to your Azure OpenAI resource, select Model deployments, find your GPT-4o-mini deployment, and delete it.
  • To remove the entire resource group (which deletes all associated resources at once), go to Resource groups in the Azure portal, select the resource group you created for this project, and click Delete resource group.
Delete local project files
  • If you set up Windows Task Scheduler, open Task Scheduler and delete the task you created for the job agent.
  • Open your terminal and navigate to your Desktop by running:
cd ~/Desktop
  • Delete the project folder by running:

macOS

rm -rf job-agent

Windows

rmdir /s /q job-agent
  • Verify the folder is gone by running ls (macOS) or dir (Windows). You should no longer see job-agent in the output.

Nice Work!

Nice Work!

You just built an AI-powered job application agent using cutting-edge Computer Use Agent technology. Your agent autonomously browses multiple job boards, scores listings against your resume, and only generates tailored materials for high-match opportunities.

You've learned how to:

  • Deployed Microsoft's Fara-7B as a serverless Computer Use Agent on Azure Foundry and built a multi-board browser automation pipeline that visually navigates LinkedIn Jobs, Indeed, and MyVisaJobs like a human.
  • Created an intelligent resume-job match scoring system using GPT-4o-mini that evaluates each listing on a 1-5 scale and filters out low-quality matches so only jobs scoring 4/5 or above proceed to tailored material generation.
  • Built a complete application tracking system with SQLite and an email approval workflow that sends you generated resumes and cover letters for review before submission.
  • Added cold email automation and daily scheduling to create a fully autonomous job search assistant that runs every morning at 8 AM.

Ready to quiz yourself?