1 n8n Agent, 40 Open Roles: The JD-Embedding Trick

A recruiting agency shows up: four people, 40 open roles across three clients, inbound resumes stacking up faster than anyone can screen. They want AI scoring. Every tutorial you find tells you to hardcode the job description into a system prompt and duplicate the workflow per role. That pattern breaks at role five, and it's the reason most builders quit halfway through the engagement.
Here's the pattern that actually holds in production: one workflow, one agent node, a vector store that owns every JD. The workflow never changes when a new role opens — you insert one row.
The scaling problem with per-role agents
The "one agent per job description" pattern fails on three axes at once: maintenance cost, prompt collision, and auditability. At 40 roles you are not maintaining one workflow forty times — you are maintaining forty workflows that drift independently, and when the hiring manager tweaks requirements on a Senior Backend role, you have no idea whether the prompt update actually landed on the right graph.
Prompt collision is the subtler failure. When two roles are similar — say, Senior Backend Engineer and Staff Backend Engineer — hardcoded prompts start scoring candidates against the wrong ladder because the system message drifted during a rushed update. You will not catch it in QA. You will catch it when a hiring manager asks why a Staff candidate got a 71.
Here is the actual comparison for a client running 40 concurrent open roles:
| Metric | Per-role agent pattern | Single-agent + JD embeddings |
|---|---|---|
| Workflows to maintain | 40 | 1 |
| Time to add a new role | ~15 min prompt tuning | ~30 sec insert row |
| Time to update requirements | Edit correct workflow, redeploy | Update one row |
| Audit trail per score | Manual per workflow | One table, one schema |
| Prompt collision risk | High on similar roles | None |
| Setup time (40 roles) | ~10 hours | ~40 min |
The second column is the one you can bill for. The first column is the one you regret in month two.
The graph, node by node
Six nodes. Seven if you want Slack pings for top candidates. Here is the shape:
[Webhook]
↓
[Extract from File / Vision OCR]
↓
[Supabase pgvector lookup by role_id]
↓
[AI Agent — gpt-4o-mini, generic system prompt]
↓
[JSON validator + retry]
↓
[Airtable / ATS write]
↓
[Slack notify if score ≥ 80] (optional)
What each node actually does
- Webhook trigger. Accepts JSON with two required fields:
resume(file or text) androle_id. That's the entire contract with whatever front-end the client uses — Airtable form, careers page, Greenhouse webhook, doesn't matter. - Resume parser. Built-in Extract from File node for text PDFs. For scanned PDFs, route to a vision model. Cap output at 8,000 tokens — anything longer is résumé padding you're paying to read.
- JD vector lookup. Keyed fetch by
role_idfrom Supabase pgvector or Pinecone. Under 100 ms. No embedding math at runtime for the JD side — you already embedded and stored it at role-creation time. - Scoring agent. One AI node, generic system prompt, dynamic user message.
- JSON validator. Parse. If malformed, retry once, then push to human review. Never write nulls to the ATS silently.
- ATS write-back. Candidate ID, role ID, four scores, rationale, recommendation, timestamp, model version. The last field is not optional.
The JD embedding step nobody teaches
The trick that unlocks the pattern is treating job descriptions as first-class data instead of prompt fragments. You embed each JD once, store it with the role ID as metadata, and fetch it at runtime as plain text context.
The setup script for a new role — you run this once per opening, takes about 30 seconds and costs roughly half a cent on text-embedding-3-small:
import openai
from supabase import create_client
sb = create_client(SUPABASE_URL, SUPABASE_KEY)
def register_role(role_id: str, title: str, jd_text: str):
emb = openai.embeddings.create(
model="text-embedding-3-small",
input=jd_text
).data[0].embedding
sb.table("job_roles").insert({
"role_id": role_id,
"title": title,
"jd_text": jd_text, # raw text — this is what we inject
"jd_embedding": emb, # for future semantic search across roles
"active": True,
}).execute()
At runtime the n8n node runs the equivalent of:
select jd_text, title
from job_roles
where role_id = $1 and active = true;
That is it. No cosine similarity at scoring time. The embedding column exists so you can later do things like "find candidates from closed roles who match this new opening" — a separate workflow, not the scoring path.
Why store the embedding if you don't use it for scoring? Because the moment the client asks "which of our 40 open roles is closest to this senior data engineer résumé we just got inbound," you have that answer for free. It's the same table.
The scoring prompt that reads role context dynamically
The prompt is a template. The context is dynamic. This is the entire reason the pattern works.
System prompt (never changes, ever):
You are a technical recruiter reviewing a candidate against a specific job.
You will receive:
- A JOB DESCRIPTION
- A CANDIDATE RESUME
Score the candidate 0–100 on four dimensions:
1. skills_match — does the candidate have the required technical skills?
2. experience_match — do their past roles map to the responsibilities?
3. seniority_fit — are they at, above, or below the target level?
4. red_flags — gaps, job hops <12mo, missing must-haves (higher = fewer flags)
Return JSON only, matching this schema:
{
"skills_match": { "score": int, "rationale": str },
"experience_match": { "score": int, "rationale": str },
"seniority_fit": { "score": int, "rationale": str },
"red_flags": { "score": int, "rationale": str },
"overall": int,
"recommendation": "advance" | "hold" | "reject"
}
Rationales are one sentence each. Do not include any prose outside the JSON.
User message (built at runtime from nodes 2 and 3):
=== JOB DESCRIPTION ({{ $json.title }}) ===
{{ $json.jd_text }}
=== CANDIDATE RESUME ===
{{ $node["Parse Resume"].json.text }}
That is the whole trick. The system prompt is generic. The user message injects the specific JD text pulled by role ID plus the parsed résumé. One agent node handles every role you will ever add.
A few production notes on the prompt itself:
red_flagsscored inversely (100 = clean) keeps the four dimensions summable intooverallwithout special casing.- The
recommendationenum is what the recruiter actually reads first. Scores are for sorting; the enum is for triage. - Do not ask the model to explain candidates it rejects at length. One sentence per dimension is enough — long rationales invite hallucinated "concerns" the model invents to fill space.
The numbers from a live deployment
Running on gpt-4o-mini with the JD text cached as plain-text lookup (no re-embedding at runtime):
- Cost: $0.14 per 100 résumés scored, end to end.
- Latency: 2.3 s average per candidate. Vector lookup under 100 ms. Rest is the model call.
- Throughput: 40 concurrent open roles handled by a single deployed workflow.
- Setup time: 38 minutes from empty n8n canvas to first scored candidate.
- Onboarding a new role: one row insert. ~30 seconds.
Compare against the per-role pattern: 40 roles × 15 minutes of prompt tuning is 10 hours of build, plus ongoing maintenance on 40 drift-prone workflows. The economic case writes itself.
Meanwhile, ATS platforms like Greenhouse and monday.com sell this exact primitive — resume scoring against a JD — as a paid seat add-on typically around $39/seat/month. If you are an agency, you ship this to the client once and they own it. If you are the small business, you build it in an afternoon.
One legal warning, non-negotiable
Do not use this to auto-reject candidates. Score them, sort them, surface the top of the funnel to a human. Automated rejection at scale has real legal exposure — the EEOC has issued guidance on AI in hiring under Title VII, New York City's Local Law 144 requires bias audits for automated employment decision tools, and the EU AI Act classifies recruitment systems as high-risk. Treat the agent as a triage layer, not a decision layer. Log the model version on every score so you can defend the process later.
Failure modes to handle before you ship
The happy path is easy. The three things that will bite you in month two:
- Malformed JSON from the model. ~1 in 400 calls on gpt-4o-mini in my logs. Validate, retry once with
response_format: {"type": "json_object"}, then route to human review. Never write nulls silently. - JD updates that don't propagate. When a hiring manager edits requirements, you need
updated_aton the role row and a signal to re-score in-flight candidates from that role. Otherwise you get two candidates with the same résumé scored against different JDs and nobody can explain why. - Résumés over 8k tokens. Truncate or summarize before the scoring call. A 14k-token résumé costs 3x more and scores worse — the model dilutes across noise. I truncate at 8k and log a warning.
Why bizflowai.io helps with this
The JD-embedding pattern is one of the recruiting-ops primitives we ship for clients at bizflowai.io — usually as part of a broader intake-to-hire workflow that includes résumé parsing, scoring, ATS write-back, and Slack routing. The build itself is a few hours; the value is in the parts most tutorials skip: retry logic on model failures, model-version logging for defensible audit trails, and the discipline of keeping humans on the reject path. If you want the pattern deployed against your ATS rather than built from scratch, that's the kind of engagement we take on.
Want more like this?
I publish practical AI automation, GenAI engineering, and faceless content workflows on YouTube every week.
Subscribe to bizflowai.io on YouTube — never miss a new tutorial.
Planning an AI automation project or need a second opinion on your architecture?
Connect with me on LinkedIn — Lazar Milicevic, GenAI Engineer & bizflowai.io Founder.
Visit bizflowai.io for our services, case studies, and AI consulting.
Frequently asked questions
What is the scalable pattern for AI resume scoring across many job roles?
Instead of building one agent per role with the job description hardcoded in the system prompt, use a single workflow with one agent node and a vector store holding every job description. At runtime, the resume comes in, the workflow looks up the matching JD by role ID, injects it as context, and the agent scores. Adding a role means inserting one row; the workflow never changes.
How do I build a resume scoring workflow in n8n?
Use six nodes: a webhook trigger receiving resume and role ID, a resume parser (Extract from File for PDFs, vision model for scans), a JD vector lookup by role ID from Supabase pgvector or Pinecone, a scoring agent (gpt-4o-mini) with a generic system prompt and dynamic JD/resume injection, a JSON validator with retry and human fallback, and a writeback node to Airtable or the ATS.
Why does storing job descriptions in a vector store matter for AI recruiting?
Storing JDs as embeddings with role ID metadata lets one generic workflow serve unlimited roles. You avoid prompt collision, hardcoded system prompts, and the maintenance nightmare of updating dozens of agents when hiring managers change requirements. Lookups are keyed by role ID, so they run in under 100 milliseconds, cost fractions of a cent, and remain deterministic and auditable.
When should I use gpt-4o-mini versus a larger model for resume scoring?
gpt-4o-mini is sufficient for structured resume scoring against a job description. It handles skills match, experience match, seniority fit, and red flag scoring reliably when given clear instructions to return JSON. Real-world deployment shows 0.14 dollars per 100 resumes end to end with 2.3 second average latency per candidate. Reserve larger models like Opus for tasks requiring deeper reasoning.
Why should I log the model version with each resume score?
Logging the model version alongside candidate ID, role ID, scores, rationale, and timestamp creates an audit trail. When a hiring manager asks why a candidate scored 62, you need to know which prompt and model produced the result. Models drift over time, so version tracking is essential for defensible scoring decisions and for debugging when scores change unexpectedly across pipeline runs.