End-to-end job search automation: AI analysis · CV tailoring · follow-up scheduling · Gmail classification · inbox auto-labeling · live portfolio dashboard
"I didn't want another spreadsheet. I wanted a system that thinks."
CareerOS is a fully automated job search operating system. Paste a job description — within minutes it produces a tailored CV, cover letter, cold outreach emails, interview prep, ATS keyword analysis, and a follow-up schedule. Everything commits automatically to a private GitHub repo and surfaces in Obsidian.
The system runs entirely on your own machine. No SaaS subscriptions. No per-request costs beyond Groq's generous free tier.
Paste any job description and watch the pipeline run. The dashboard shows a real pipeline — the Analyze panel fires the actual n8n webhook when run locally.
┌─────────────────────────────────────────────────────────────┐
│ INPUT │
│ Job Description (via Form or Webhook) │
└────────────────────────┬────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ n8n WORKFLOW ENGINE │
│ (Docker · localhost:5679) │
│ │
│ Extract Job Fields ──► Parse Job Fields │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ TIER 1 — Core Application (Groq · 3 calls) │ │
│ │ Resume Analysis · Cover Letter · Job Fit Score │ │
│ │ ATS Keyword Optimization (pure JS, no LLM) │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ Wait 30s │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ TIER 2 — Cold Outreach (Groq · 4 calls) │ │
│ │ Intro Email · Follow-Up · Company Research │ │
│ │ LinkedIn Messages │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ Wait 30s │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ TIER 3 — Interview Prep (Groq · 4 calls) │ │
│ │ Skills Gap · Interview Q&A · Talking Points │ │
│ │ Salary Guide │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ Wait 45s │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ TIER 4 — Portfolio Matching (Groq · 1 call) │ │
│ │ GitHub API fetch · Repo relevance analysis │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ Wait 60s │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ TIER 5 — Tracking (no LLM) │ │
│ │ Follow-up schedule · Status tracking │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ Wait 60s │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ TIER 6 — Tailored Resume (Groq · 1 call) │ │
│ │ Full CV rewrite against real job requirements │ │
│ └──────────────────┬───────────────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────────────┐ │
│ │ Merge Results → GitHub Commit → Email Notification │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌──────────────┴───────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ GitHub (private)│ │ Email Notification │
│ job-search-data │ │ (SMTP · Gmail) │
│ │ └──────────────────────┘
│ Job_Applications│
│ └── Active/ │
│ Company.md │◄── Obsidian Git plugin
│ Dashboard/ │ auto-pulls every 10 min
│ My_Materials/ │
└──────────────────┘
| Workflow | Trigger | What it does |
|---|---|---|
| Email Listener | Gmail trigger (on receive) | Classifies incoming emails by keyword pattern into: rejection, interview_invite, offer, acknowledgement. Detects rejection stage (phone screen / technical / final round) and generates a stage-appropriate response template. Commits an email log entry to the private GitHub repo and sends an email notification with the draft response. |
| Follow-Up Scheduler | 4 cron triggers (9:00 / 9:30 / 10:00 / 10:30) | Each trigger handles one follow-up type only (day_3 / day_7 / day_30 / day_90) — splitting the load so each run stays under Groq's 6000 TPM limit. Reads application files from GitHub, checks which are due today, generates personalized email drafts via Groq, and sends a digest for manual review. Emails are never auto-sent to recruiters — human approval required. |
| Gmail Auto-Labeler | Scheduled every 30 min | Fetches inbox messages by message (not thread — different ID format breaks downstream nodes), deduplicates by threadId in a Code node running "Run Once for All Items" so $input.all() spans all messages. Applies exactly one label per thread based on newest message. Time-based escalation: FROM RECRUITER → NEED TO REPLY after 12 hrs. Gmail API fields are case-sensitive: From, To (capital), labels: [{id, name}] — not labelIds. isReply detection uses subject Re: prefix only for outgoing emails — using thread labels caused false REPLIED on first recruiter outreach. |
The system monitors your job-search Gmail account continuously via two separate workflows.
Email Listener fires on every incoming email matching job-search keywords. It classifies the email and branches into three paths:
Incoming email
│
▼
Classify Email ──► Route by Type
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Rejection Interview Invite Offer
│ │ │
Detect stage Log + Notify Log + Notify
(phone/tech/ (action req'd) (review offer)
final round)
│
Generate stage-aware
response template
│
GitHub commit (Email_Log/)
│
Email notification
with draft response
Gmail Auto-Labeler runs every 30 minutes and applies exactly one label per thread based on the most recent message:
| Label | Condition |
|---|---|
N8N |
Sent from me to myself (workflow notifications) |
WAITING FOR RESPONSE |
First outreach I sent, no Re: prefix, no prior thread label |
REPLIED |
I replied to an existing thread (subject starts with Re:) |
FROM RECRUITER |
Incoming email, within 12-hour window, no reply from me yet |
NEED TO REPLY |
Incoming email, 12-hour window passed without my reply |
The labeler is 5 nodes: Every 30 Minutes → Fetch Emails (Gmail) → Fix Logic (Code, dedup + classify) → Clear All (Gmail, removes old labels) → Apply Label (Gmail). The Clear+Apply pattern ensures exactly one label per thread with no stale labels.
Production note:
Fix Logiccontainsconst TIME_LIMIT = 12 * 60 * 60 * 1000;— change to60 * 1000for testing (1 minute escalation).
The system maintains a local Obsidian vault that stays in sync with the private GitHub repo via the Obsidian Git plugin (auto-pull every 10 minutes).
What's wired (automatic):
Dashboard/Main_Dashboard.md— updated on every new application by the main workflow. Contains a Dataview table that auto-renders from frontmatter across all application files.- Each application
.mdfile inJob_Applications/Active/with frontmatter fields:company,position,link,date_sent,stage,resume_version,referral,salary_range,contact,notes - Email log entries committed to
Obsidian_Vault/Email_Log/on every classified email
What requires manual setup (not yet done):
- Kanban board (
Job_Tracker_Kanban.md) — drag cards between stages manually as status changes - Calendar plugin — daily journaling of interviews, calls, and events
- Dataview plugin installation
How the three views work together:
The .md frontmatter is the single source of truth. The Dataview table in Main_Dashboard.md re-renders automatically when any field changes. The Kanban board gives a drag-and-drop stage view (5 seconds to update). The Calendar gives a daily log of what happened.
| Layer | Technology | Why |
|---|---|---|
| Workflow engine | n8n (self-hosted, Docker) | Visual graph I can show in interviews; handles retries, scheduling, error routing out of the box |
| LLM inference | Groq API — LLaMA 3.1 8B | Free tier, ~200 tokens/sec, OpenAI-compatible API |
| Data store | GitHub API (private repo) | Version history on every file, human-readable, zero cost, works natively with Obsidian |
| Local dashboard | Obsidian + Dataview | Queries live from .md frontmatter — no manual maintenance |
| Portfolio demo | Vanilla HTML/CSS/JS | No framework overhead; SHA-256 password auth via native Web Crypto API |
| Deployment | GitHub Pages | Zero hosting cost, auto-deploys on push |
These are the interesting problems — worth understanding before an interview.
GitHub as a database
Each application is a .md file with YAML frontmatter. GitHub gives version history, diffs on every field change, and a human-readable audit trail. Obsidian's Dataview plugin queries the same files locally. For this use case it's strictly better than SQLite.
GitHub SHA pattern
The GitHub API returns 422 if you try to overwrite an existing file without passing its current sha. Every commit node does a GET first to extract the sha, then passes sha || undefined in the PUT body. First run creates the file; subsequent runs update it.
Rate limit architecture Groq's free tier allows 6000 TPM. With 14 LLM calls per job submission, every consecutive pair of Groq requests has a 30-second wait node between them. The follow-up scheduler splits by follow-up type across 4 triggers (9:00, 9:30, 10:00, 10:30) so each run only processes one type — keeping each batch well under the limit.
Fire-and-forget webhook
The demo fires the n8n webhook and immediately shows a simulated result. The actual pipeline takes 8–10 minutes. Blocking the UI would destroy the interview experience — so the fetch() call is fire-and-forget (.catch(()=>{})) and the terminal animation always completes cleanly.
n8n $input.all() positional indexing bug
n8n's merge node passes items positionally — $input.all()[0] breaks when a node has multiple upstream inputs. Fixed by reading nodes by name ($('Tier 1 - Core Application').first().json) instead of position. This is a non-obvious n8n behavior that cost several debugging sessions.
Human-in-the-loop design Follow-up emails are drafted but never auto-sent. The scheduler generates drafts and emails a digest for manual approval. Deliberate decision: anything that goes to a real recruiter requires a human sign-off.
Gmail deduplication
The Gmail Trigger fetches by message, not by thread — because switching to the thread resource breaks downstream node IDs (different format). Deduplication by threadId happens in a Code node running in "Run Once for All Items" mode so $input.all() can compare across all messages in one pass.
Email classification: case-sensitive field names
Gmail API returns From and To with capital letters, and labels as an array of {id, name} objects — not labelIds. This caused silent failures until discovered. The isReply detection uses subject Re: prefix only for outgoing emails — using thread labels for this caused false REPLIED on first outreach to recruiters (thread already had FROM_RECRUITER label from an earlier message).
Stage-aware rejection responses The email listener doesn't just detect rejections — it detects which stage the rejection happened at (phone screen / technical / final round) based on body keywords. Each stage gets a different response template with appropriate tone and content.
CareerOS/
├── index.html # Portfolio demo (GitHub Pages)
├── docker-compose.yml # n8n local setup
├── Dockerfile # n8n container configuration
├── Makefile # make start / stop / logs / restart
├── .env.example # Environment variables template
├── .gitignore # Git ignore patterns
│
├── .github/
│ └── workflows/
│ └── deploy.yml # CI/CD: validates JSONs + deploys to GitHub Pages
│
├── n8n-workflows/
│ ├── workflow-all-tiers.json # Main pipeline (6 tiers: resume → portfolio)
│ ├── workflow-followup-scheduler.json# Daily follow-up digest (4 cron triggers)
│ ├── workflow-email-listener.json # Gmail classifier (rejection/invite/offer)
│ ├── workflow-gmail-labeler.json # Inbox auto-labeling every 30 min
│ └── helper-get-label-ids.json # Helper: fetch Gmail label IDs
│
├── prompts/
│ ├── 01-resume.md # Tier 1: Resume analysis prompt
│ ├── 02-cover-letter.md # Tier 1: Cover letter prompt
│ ├── 03-cold-email.md # Tier 2: Cold outreach prompt
│ ├── 04-interview-prep.md # Tier 3: Interview prep prompt
│ ├── 05-talking-points.md # Tier 3: Talking points prompt
│ ├── 06-skills-gap.md # Tier 3: Skills gap analysis prompt
│ ├── 07-portfolio-github.md # Tier 4: Portfolio matching prompt
│ ├── 08-salary-negotiation.md # Tier 3: Salary negotiation prompt
│ └── PROMPTS.md # Complete prompts reference (all tiers)
│
├── sample-outputs/
│ ├── sample_application_file.md # Example: full application with all tiers
│ ├── sample_resume_analysis.md # Example: Tier 1 resume analysis output
│ ├── sample_cover_letter.md # Example: Tier 1 cover letter output
│ ├── sample_cold_email.md # Example: Tier 2 cold outreach output
│ └── sample_interview_prep.md # Example: Tier 3 interview prep output
│
└── docs/
├── SETUP.md # Installation and local setup guide
├── ARCHITECTURE.md # System design and data flow diagrams
├── TROUBLESHOOTING.md # Common issues and debugging guide
├── CHANGELOG.md # Version history and feature updates
└── OBSIDIAN_SYNC.md # Obsidian vault integration guide
- Setup Guide — Installation, prerequisites, and local setup
- Architecture — System design, data flow, and component breakdown
- Troubleshooting — Common issues, debugging, and real bugs fixed
- Changelog — Version history and feature updates
- Obsidian Integration — Vault setup, Git sync, and Dataview queries
- Prompts Reference — All LLM prompts used in workflows
See docs/SETUP.md for full instructions.
Quick start:
git clone https://github.com/odeliyach/CareerOS.git
cd CareerOS
cp .env.example .env # fill in your tokens
make start # starts n8n on localhost:5679Then import the workflow JSONs from n8n-workflows/ via n8n UI → Import from file.
-
fetchLiveData()— read real application stats from private GitHub repo into the demo dashboard - Application Status Auto-Update from recruiter emails (Phase 2a)
- Weekly Summary workflow (Sunday 9am digest)
- Docker deployment so webhook is always live (not localhost-only)
- Obsidian Dataview · Kanban · Calendar setup
This section exists for me. Putting it here because it's honest.
Things I'd do differently with more time: containerize n8n properly so the webhook is always accessible (not just when my laptop is on), add a real database instead of GitHub files for querying across applications, write tests for the workflow logic.
Things I'm proud of: the rate-limit architecture, the GitHub SHA pattern, the fire-and-forget demo design, and the fact that I actually use this system daily.