# How the AI Job Search /apply Command Parses Job Posting Input

> Discover how the AI Job Search /apply command parses job posting input through its six-step pipeline. Learn how it validates, archives, extracts, and normalizes data for applications.

- Repository: [Mads Lorentzen/ai-job-search](https://github.com/MadsLorentzen/ai-job-search)
- Tags: internals
- Published: 2026-08-25

---

**The `/apply` command transforms raw job posting text into structured application records through a six-step pipeline that validates input, archives the raw text, extracts company and role via regex fallbacks, detects URLs and deadlines, and writes a normalized CSV row to `documents/applications/`.**

The AI Job Search repository (`MadsLorentzen/ai-job-search`) provides a Claude Code command suite for managing job applications through structured markdown and CSV workflows. The `/apply` command serves as the primary entry point for ingesting job postings, implementing a deterministic parsing strategy specified in [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md) that extracts only essential metadata while preserving the original posting for downstream workflows.

## The Six-Step Parsing Pipeline

The `/apply` command processes job posting input through a sequential pipeline documented in the command specification and validated by the test suite in [`tests/test_apply_records_application.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_apply_records_application.py). Each step transforms unstructured text into structured fields while maintaining data integrity.

### Step 0: Input Validation and Archiving

The command expects a single string argument containing the complete job posting. It first **strips surrounding whitespace** and **fails fast** if the resulting string is empty, enforcing the validation rules defined in [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md) Step 0.

Immediately after validation, the full posting text is written to `documents/postings/<company>_<role>/job_posting.md`. This archive serves as the source of truth for subsequent commands such as `/outcome` and `/interview`, ensuring downstream steps can reference the original posting without re-fetching external URLs.

### Step 1: Company and Role Extraction

The parser identifies the **title line** containing the company name and role title using an ordered list of regular-expression fallbacks. The implementation in the internal helper `parse_posting_title()`—which shares regex utilities with [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py)—attempts multiple patterns until one matches:

- `Company – Role` (em-dash or hyphen separated)
- `Job Title – Company` (reversed order with dash delimiter)
- HTML `<h1>` tags

The first successful capture wins, making the parser tolerant to variations across different job-portal layouts without requiring strict positional parsing.

### Step 2: URL and Deadline Detection

If the posting contains hyperlinks, the parser searches for the first URL matching known job-portal domains and stores it as `posting_url`. This URL enables re-fetching capabilities if the archived copy needs validation against the live posting.

For deadlines, the parser executes case-insensitive regexes such as `Application deadline[:\s]+(?P<date>.+)` and `Close date[:\s]+(?P<date>.+)`. When found, dates are normalized to **ISO-8601 format** (`YYYY-MM-DD`). If no deadline is present, the field remains blank and later salary negotiation steps skip deadline-based filtering, as asserted in [`tests/test_apply_records_application.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_apply_records_application.py).

### Step 3: Record Assembly and Context Propagation

All extracted values—company, role, posting URL, deadline, and the archive path—are assembled into a **14-column CSV row** matching the header defined in [`apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/apply.md). The row is appended to `documents/applications/<company>_<role>/apply.md`.

The structured data is then stored in the **environment context** (`apply_context`), allowing subsequent commands like `/outcome` and `/interview` to reference parsed fields without re-parsing the raw text. This context sharing is validated in [`tests/test_company_research_cache.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_company_research_cache.py) and [`tests/test_outcome_followup.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_outcome_followup.py).

## Implementation Architecture

### Regex Pipeline Strategy

The parser implements a **regex fallback pattern** where an ordered list of patterns executes sequentially until one matches. This approach, shared across [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py), ensures robustness against varying job board formats without requiring strict schema adherence. The pattern list prioritizes common delimiters (em-dash, hyphen, en-dash) and handles both standard and reversed title formats.

### Security Guards and Path Sanitization

Before writing to the filesystem, the command validates that the posting text does not contain relative path components (`..`) that could escape the intended directory structure. This enforces the **trust-boundary** rule specified in [`apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/apply.md) Step 0, preventing directory traversal attacks through malicious input.

### Data Normalization Rules

Company names undergo aggressive normalization: legal suffixes (`A/S`, `ApS`, `Inc.`, `LLC`) and extraneous whitespace are stripped to ensure consistent folder naming under `documents/applications/`. This guarantees that the same company maps to a single canonical path regardless of formatting variations in the source posting, preventing duplicate application folders for the same employer.

## Complete Parsing Implementation

Below is a minimal recreation of the core parsing logic based on the actual implementation in [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py) and the internal helper modules. This demonstrates how raw posting text converts to structured fields:

```python
import re
from datetime import datetime
from pathlib import Path

def clean_posting(text: str) -> str:
    """Strip whitespace per apply.md Step 0 validation."""
    return text.strip()

_TITLE_PATTERNS = [
    re.compile(r"^(?P<company>.+?)\s*[–-]\s*(?P<role>.+)$"),
    re.compile(r"^(?P<role>.+?)\s*[–-]\s*(?P<company>.+)$"),
]

def parse_title(line: str):
    """Extract company/role using regex fallbacks."""
    for pat in _TITLE_PATTERNS:
        m = pat.match(line)
        if m:
            return m.group("company").strip(), m.group("role").strip()
    raise ValueError("Unable to parse company/role from title line")

_URL_RE = re.compile(r"https?://[^\s)]+")

def find_url(text: str) -> str | None:
    """Detect first URL in posting."""
    m = _URL_RE.search(text)
    return m.group(0) if m else None

_DEADLINE_RE = re.compile(
    r"(?:application|close)\s*deadline[:\s]+(?P<date>[\w,\s]+)", re.I
)

def parse_deadline(text: str) -> str | None:
    """Normalize deadline to ISO-8601 or return None."""
    m = _DEADLINE_RE.search(text)
    if not m:
        return None
    raw = m.group("date").strip()
    for fmt in ("%d %B %Y", "%Y-%m-%d", "%B %d, %Y"):
        try:
            return datetime.strptime(raw, fmt).date().isoformat()
        except ValueError:
            continue
    return raw

def build_apply_record(posting: str) -> dict:
    """Complete pipeline from raw text to structured record."""
    clean = clean_posting(posting)
    lines = clean.splitlines()
    
    # Extract metadata

    company, role = parse_title(lines[0])
    url = find_url(clean)
    deadline = parse_deadline(clean)
    
    # Archive raw posting

    archive_dir = Path("documents/postings") / f"{company}_{role}"
    archive_dir.mkdir(parents=True, exist_ok=True)
    archive_path = archive_dir / "job_posting.md"
    archive_path.write_text(clean, encoding="utf-8")
    
    return {
        "company": company,
        "role": role,
        "posting_url": url or "",
        "deadline": deadline or "",
        "archive_path": str(archive_path),
    }

```

This implementation mirrors the actual parsing sequence validated in [`tests/test_apply_records_application.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tests/test_apply_records_application.py), demonstrating the lightweight extraction strategy that preserves the original posting while enabling structured downstream processing.

## Summary

- The `/apply` command implements a **six-step parsing pipeline** defined in [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md) that converts raw job postings into structured CSV records.
- **Regex fallbacks** in `parse_posting_title()` handle multiple title formats, while [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py) provides shared validation utilities.
- Raw postings are **immediately archived** to `documents/postings/` before parsing, ensuring reproducibility for commands like `/outcome` and `/interview`.
- The parser **normalizes company names** and validates paths to prevent directory traversal, enforcing security boundaries specified in the command documentation.
- Parsed data propagates through the **`apply_context`** environment variable, enabling subsequent workflow steps to access structured fields without re-parsing.

## Frequently Asked Questions

### What happens if the job posting doesn't match the expected title format?

The parser uses a **regex fallback strategy** defined in `parse_posting_title()` that attempts multiple patterns sequentially. If standard `Company – Role` formats fail, it tries reversed ordering or HTML tag extraction. Only if all patterns fail does the command raise a validation error, ensuring robust handling of various job board layouts.

### Where does the /apply command store the original job posting text?

The command archives the complete raw text to `documents/postings/<company>_<role>/job_posting.md` immediately after validation, as specified in [`.claude/commands/apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/.claude/commands/apply.md). This archived copy serves as the authoritative source for downstream commands and prevents data loss if the original URL becomes unavailable.

### How does the parser handle different date formats for application deadlines?

The deadline detection uses case-insensitive regexes to locate dates, then attempts parsing against multiple common formats including `%d %B %Y`, `%Y-%m-%d`, and `%B %d, %Y`. Successfully parsed dates are normalized to ISO-8601 (`YYYY-MM-DD`). If parsing fails or no deadline is found, the field remains blank, which triggers different logic in subsequent salary negotiation steps.

### What prevents malicious input from accessing files outside the project directory?

The command implements **trust-boundary validation** that scans input for relative path components (`..`). If detected, the command refuses to execute, preventing directory traversal attacks. This security guard is documented in [`apply.md`](https://github.com/MadsLorentzen/ai-job-search/blob/main/apply.md) Step 0 and implemented in the validation layer shared with [`tools/security_guards.py`](https://github.com/MadsLorentzen/ai-job-search/blob/main/tools/security_guards.py).