October 4, 2026
I Gave Recruiters a Chatbot That Won't Oversell Me
Parley now has a recruiter mode. Paste a job posting and it tells you what I have done, what I haven't, and what is close.
Parley now has a recruiter mode, and its main rule is that it tells you what I haven't done.
Tap "I'm a recruiter" in the chat, or type /recruiter. Paste a job description or drop a link to the posting. You get two sections back: strong matches, and adjacent experience. Nothing in either section comes from outside my published profile.
Why I built it
A resume match tool has one incentive: make the fit look good. Keyword overlap becomes "5 years of Vue" because the posting said Vue and I said JavaScript. That wastes a recruiter's time and mine.
I have shipped a lot of React. I have not shipped Vue in production. I would rather a recruiter learn that in the first answer than in the third interview. So the rule I gave the model is simple: make the strongest honest case, and never claim a skill, tool, title, employer, date, credential or number that the profile does not support.
What it does
The first answer after a job description has two parts.
- Strong matches. Requirements I clearly meet, each tied to a named role, project, publication or metric.
- Adjacent experience. Every requirement without clear production evidence, one bullet each.
Salary, availability, notice period, work authorization, relocation and scheduling are not answered at all. Parley points to my email for those. Those answers belong to me, not a model.
Job text, links and answers are not stored or logged by the app. They live in the conversation and nothing else. The design is in spec 0002 (docs/specs/0002-recruiter-mode.md).
Gaps come first
The first version was honest but loose. I ran it against a real Vue posting in production. It did not claim Vue, but it hand-waved the gap with "patterns transfer." That is true of everything and says nothing.
Commit 439261d changed the rule: one bullet per requirement without evidence, naming the closest technology, where I used it, and why it transfers.
The second run exposed a different problem. Some bullets were labeled with the posting's own wording, like "5+ years of production Vue.js." The body of the bullet was honest, but a skimming reader sees the label and nothing else. Commit a1c8649 fixed that. Each gap bullet is now labeled with the skill name alone and opens with "not shown in his published production work." The sentence that states the gap comes before the sentence that sells the neighbor.
Both fixes came from reading real output, not from thinking harder about the prompt. The prompt looked fine both times.
The posting is hostile input
A link means my server fetches a URL a stranger typed. That is a server-side request forgery risk, and I wrote about the wider problem in Who's Watching the Vibe Coders?. The short version there was that an agent will do what the text in front of it says. A job posting is text I did not write.
So the fetch is guarded (1c377d9, apps/blog/src/lib/recruiter/):
- The address is checked as the connection resolves. Loopback, private, link-local, cloud metadata, carrier-grade NAT, multicast and reserved ranges are refused, for IPv4 and IPv6.
- Only ports 80 and 443 are allowed.
- Every redirect hop is checked again, and the fetch follows at most 3.
- It stops after 8 seconds or 2 MB.
- Extraction prefers the schema.org
JobPostingJSON-LD block. If the page has none, it strips to visible text, capped at 20,000 characters.
Pasted text and fetched text both go into the prompt inside a <job_description> block, and the system prompt says everything inside it is third-party data. Instructions in a posting are ignored. If someone stuffs "ignore your rules" into a job description, that sentence is just a requirement nobody can match.
I chose a guarded fetch in my own code over Anthropic's server-side web fetch tool. The hosted tool removes the address handling from my code, but it adds a tool loop, latency and token cost, I cannot test it without calling the model, and sites that block automated readers still fail. The comparison is in ADR 0002 (docs/adr/0002-recruiter-mode-model-and-jd-intake.md).
When a link cannot be read, Parley says so and asks for the pasted text. It does not guess what the posting says.
Two models, one chat
Recruiter turns run on Claude Sonnet 5.5 at medium effort. Everything else stays on Claude Haiku 4.5. The chat header shows which one is active.
The reason is cost and fit. Per ADR 0002, Sonnet 5.5 is $2 input and $10 output per million tokens. Haiku 4.5 is $1 and $5. Opus 5.5 is $4 and $20, and I chose Sonnet over it. Recruiter turns carry a full job description and a profile, and they need careful reading. Ordinary chat questions do not. Running one stronger model for everything would double the cost of conversations that Haiku already handles well.
I kept the Vercel AI SDK. Switching to the Anthropic SDK would have broken the streaming the chat UI depends on. That is a tradeoff, and I made it knowingly. One consequence is that the installed provider version predates Sonnet 5.5, so its typed model list does not include it. I had to confirm live that the request is accepted.
What it costs to protect
A public chat endpoint costs money on every call, and the recruiter path costs more and can fetch a page. Without limits, anyone can run up my bill or use the fetch as a page-to-text proxy. Commit 20d5057 added rate limits: per-IP burst and sustained limits on /api/chat in the Vercel Firewall, plus a stricter recruiter limit checked in the route. The full reasoning is in ADR 0003. That is a post of its own, so I will leave it there.
What I would change
I have no usage numbers. I don't know how many recruiters have tried it or whether they liked it, and nothing in the repo says. I won't make up a result.
Here is what I know is limited:
- The mode lives in browser memory. Reload the page and it ends.
- Per-IP limits slow abuse from one address but not a distributed attack. Visitors behind one shared address share the limits. Vercel BotID is the next layer, and I have not added it.
- A refused model turn ends with an error message in the chat. The AI SDK does not expose server-side refusal fallbacks.
- I tested the Vue case against a live posting, and I wrote tests for the fetch limits, the extraction and the prompt wrapper. Real recruiters will paste things I haven't thought of.
If you try it and Parley oversells me, tell me. That is a bug, and I want the example.