5-workflow n8n orchestration system — turns a job-posting URL into a factually-grounded application packet with human-in-loop review gates.
The default failure mode of AI-generated resumes is hallucinated experience — technologies you never used, "contributed to" inflated into "led," invented metrics. Anchor prevents this at the schema level, not the prompt level: the master resume is stored as typed, addressable rows in Postgres (master_resume_entry) rather than a text blob. Tailoring a resume is selection and rephrasing of those rows, not free-form generation. A Grounding Critic agent then checks every output line against its cited source entry before anything ships — new technologies, inflated numbers, or stronger role titles than the source supports all fail the check.
The same architectural pattern that makes Meridian's citations traceable makes Anchor's generated materials factually grounded — by construction, not by instruction.
Paste a LinkedIn/Greenhouse/Lever/Workday URL into the dashboard. The pipeline scrapes the JD, researches the company from 4 parallel sources, scores your match against the JD, drafts a tailored resume + cover letter + LinkedIn message, generates a skill-gap report, renders PDFs, uploads to Google Drive, and creates a Notion page — all automatically, with a Slack gate if the match score is weak enough that a human should decide whether to continue.
flowchart TD
USER["Browser: paste job URL"] -->|POST /webhook/intake| WF1
subgraph WF1["Workflow 1 — Job Intake (7 nodes)"]
V[Validate URL] --> INS[Insert application row]
INS --> RESP["Respond < 500ms"]
end
WF1 -->|Execute Workflow| WF2
subgraph WF2["Workflow 2 — Job Processor (25 nodes)"]
FETCH[Playwright: fetch JD page] --> PARSE[JD Parser Agent]
PARSE --> RESEARCH["4 parallel branches:\nnews / homepage / about / careers"]
RESEARCH --> SYNTH[Company Synthesizer Agent]
SYNTH --> UPSERT["Upsert company + status → researched"]
end
WF2 -->|Execute Workflow| WF3
subgraph WF3["Workflow 3 — Match & Generate (67 nodes)"]
CRITIC[Resume Critic Agent] --> SCORER[Match Scorer Agent]
SCORER -->|"score < 60"| SLACK_GATE["Slack: Continue/Skip?"]
SCORER -->|"score ≥ 60"| TAILOR[Resume Tailorer Agent]
TAILOR --> GROUND[Grounding Critic Agent]
GROUND -->|pass| MATERIALS["Cover Letter + LinkedIn\n+ Skill Gap agents"]
GROUND -->|"fail (retry once)"| TAILOR
GROUND -->|"fail twice"| ESCALATE[Slack: escalation alert]
MATERIALS --> PDF[PDF render via Playwright]
PDF --> DRIVE[Upload to Google Drive]
DRIVE --> NOTION[Create Notion page]
NOTION --> DONE["status → awaiting_review"]
end
subgraph WF4["Workflow 4 — Follow-up Scheduler (14 nodes)"]
CRON4["Cron: daily 8am"] --> FIND[Find due applications]
FIND --> DECIDE[Follow-up Decision Agent]
DECIDE --> DIGEST4[Slack digest]
end
subgraph WF5["Workflow 5 — Weekly Reflection (8 nodes)"]
CRON5["Cron: Sunday 7pm"] --> AGG[Aggregate last 4 weeks]
AGG --> PATTERN["Pattern Detector Agent\n(min N=5 guard)"]
PATTERN --> DIGEST5[Slack digest]
end
subgraph ERR["Error Workflow (6 nodes)"]
ETRIG[n8n error trigger] --> RETRY["Retry (max 2, backoff)"]
RETRY --> ALERT[Slack alert]
end
WF1 -.->|errorWorkflow| ERR
WF2 -.->|errorWorkflow| ERR
WF3 -.->|errorWorkflow| ERR
PG[(Postgres\n10 tables)] --- WF1
PG --- WF2
PG --- WF3
PG --- WF4
PG --- WF5
LLM["Ollama qwen2.5:7b\nvia FastAPI wrapper"] --- WF2
LLM --- WF3
LLM --- WF4
LLM --- WF5
Dashboard — Next.js 14, direct Postgres queries, JWT auth:
| Application Kanban | Application Detail |
|---|---|
![]() |
![]() |
More canvases: Error Handler · Job Intake · Job Processor · Match & Generate · Follow-up Scheduler · Weekly Reflection
Job Intake exists to answer under 500ms so the browser never hangs on a slow scrape — it validates the URL, inserts a row, and fires the actual processing (Workflow 2) asynchronously. That split between "acknowledge fast" and "do the slow work" is why this is a separate workflow rather than one step in Workflow 2.
Job Processor decides what the company actually is before anything gets scored — it scrapes the JD, parses it into structured fields, then researches the company from 4 independent branches (news, homepage, about page, careers page) in parallel, each failure-tolerant so one dead link doesn't kill the run. A synthesizer agent merges those branches into one company profile before handing off.
Match & Generate is where the grounding constraint actually gets enforced. A critic scores fit against the JD; below 60 the pipeline pauses for a human Slack decision rather than guessing whether it's worth tailoring. Above that, the tailorer drafts a resume from the structured entries, the Grounding Critic checks it line-by-line against cited sources, and a failure triggers one retry with the violations fed back as instructions — a second failure escalates to a human instead of shipping unverified content.
Follow-up Scheduler runs daily and exists separately from the main pipeline because it operates on a different trigger (time elapsed, not a new application) and a different table slice (applications past their nudge window).
Weekly Reflection aggregates a full month of applications and only runs its Pattern Detector once there are 5+ applications (a guard against speculating from too few data points) — it's a slow, low-frequency job that doesn't belong coupled to any single application's lifecycle.
20 synthetic job applications (spanning good/medium/poor fit) were run through Anchor's full 11-agent chain and through a naive single-prompt baseline with no critic and no grounding instructions. Both outputs were checked by the same Grounding Critic agent.
| Grounding pass rate | Violations (total) | |
|---|---|---|
| Anchor (11-agent chain + Grounding Critic, 1 retry) | 10% (2/20) | 74 |
| Baseline (single prompt, no critic) | 0% (0/20) | 53 |
Mean match score: 74.2/100. Tier distribution: 8 hot (≥75), 11 warm (60–74), 1 cold (<60).
The 10% pass rate reflects the grounding system working correctly against qwen2.5:7b's tendency to paraphrase aggressively — the critic catches violations that would otherwise ship silently. The architecture is designed for stronger models (GPT-4, Claude) where the pass rate would be significantly higher; qwen2.5:7b was chosen to keep the build zero-cost (ADR-002).
Full detail: eval/results_summary.md · eval/grading_rubric.md
| ADR | Decision | Alternative rejected |
|---|---|---|
| 001 | n8n for orchestration | Custom Python DAG |
| 002 | Ollama (local, free) | Paid LLM APIs |
| 003 | Postgres as single source of truth | n8n static data |
| 004 | Manual URL paste | Job board scraping |
| 005 | Anchor drafts; I send manually | Auto-submit |
| 006 | Structured resume rows | Text blob |
- Search for jobs or scrape job boards
- Auto-submit applications (Anchor drafts; the user sends)
- Help anyone other than its one user — no multi-tenancy
- Have user accounts or auth beyond a single admin login
- Be a SaaS product
Every "but what if it also..." idea is in FUTURE.md.
Every AI agent call across all workflows writes an agent_run row to Postgres:
| Field | What it captures |
|---|---|
application_id |
Which application this run belongs to |
workflow_name |
Which n8n workflow triggered it |
agent_name |
Which agent (jd_parser, match_scorer, grounding_critic, etc.) |
output_json |
The agent's full structured output (JSONB) |
input_hash |
SHA-256 of the prompt sent to the LLM |
latency_ms |
How long the LLM call took |
critic_passed |
Boolean — did this agent's output pass its quality check |
The dashboard's Decisions page surfaces this as an expandable audit log — for any application, exactly what each of the 11 agents decided, what data it saw, and whether the critic approved it.
Prerequisites: macOS (Homebrew), PostgreSQL 16, Node.js ≥ 18, Python 3.11+, Ollama with qwen2.5:7b pulled.
# 1. Postgres
brew services start postgresql@16
export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"
createdb anchor
psql -d anchor -f db/schema.sql
psql -d anchor -f db/seed_master_resume.sql
# 2. Environment
cp .env.example .env # configure SLACK_WEBHOOK_URL, ports
# 3. LLM wrapper
python3 -m venv .venv
.venv/bin/pip install -r llm/requirements.txt
.venv/bin/uvicorn llm.server:app --port 8001
# 4. Fetch/PDF service
.venv/bin/pip install -r fetch/requirements.txt
.venv/bin/uvicorn fetch.server:app --port 8002
# 5. n8n
npx n8n start # http://localhost:5678
# Import workflows from n8n/workflows/*.json
# Configure credentials: Postgres, Google Drive OAuth2, Notion API
# 6. Dashboard
cd dashboard
cp .env.local.example .env.local # set DATABASE_URL, JWT_SECRET, ADMIN credentials
npm install && npm run dev # http://localhost:3000anchor/
├── n8n/workflows/ 6 exported workflow JSON files (127 nodes total)
├── prompts/ 12 versioned agent prompts (.md)
├── llm/ FastAPI wrapper around Ollama (disk cache, /complete endpoint)
├── fetch/ Playwright microservice (JD fetch + PDF render)
├── pdf/templates/ Jinja2 HTML templates for resume + cover letter PDFs
├── db/ schema.sql (10 tables), migrations/, seed data
├── dashboard/ Next.js 14 App Router (login, kanban, detail, setup, audit log)
├── eval/ 20×2 eval outputs, benchmark script, results summary
└── docs/
├── planning/ anchor_planning.md (authoritative spec)
├── decisions/ 6 ADRs
└── canvas-screenshots/ 10 PNGs (workflows + dashboard)
| Layer | Tech |
|---|---|
| Orchestration | n8n (127 nodes, 6 workflows) |
| Database | PostgreSQL (10 tables) |
| Scraping/PDF | Playwright |
| LLM | Ollama qwen2.5:7b via FastAPI wrapper |
| Dashboard | Next.js 14 (App Router) · JWT auth |
| Integrations | Slack, Google Drive OAuth2, Notion API |
- Multi-user support — sign-up, per-user data isolation,
user_idon all tables - Resume profiles — multiple resume variants (AI/ML, PM, Backend) with profile selector on intake
- Stronger LLM backend — config switch for a paid API to improve grounding pass rate
- Docker deployment —
docker compose upfor the full stack
See FUTURE.md for the full list.

