Tailoring a resume to each job posting is tedious, and it’s the kind of task people hand to ChatGPT. The problem is what comes back. Ask a chat model to “make my resume fit this job” and it will happily give you a skill the job wants, a metric that sounds right, or a bullet that blends two jobs into one achievement. It reads well, and it isn’t true. On a resume, that’s worse than a weak bullet.
So I built Job Application Copilot: a LangGraph agent that tailors a resume to a posting, but only from evidence it can point to, and never finalizes anything without a human approving it.
The design goals:
The stack is Python, FastAPI, LangGraph and Postgres with pgvector on the back end, and React with TypeScript and MUI on the front end.
The agent is a LangGraph state graph with six steps:
If nothing in the posting matches at all, the graph skips drafting and goes straight to the end. There is nothing honest to write in that case.
Steps tick off on the left as they finish, streamed from the server:
Why LangGraph: the hard part of this app isn’t the model calls, it’s the . A run stops halfway, waits minutes or days for a person, and then continues with their decision. LangGraph has that built in with interrupt() and a Postgres checkpointer that saves the state after every step. It also gives each step a retry policy, which matters on a free tier that returns "high demand" errors now and then:
LLM_RETRY = RetryPolicy(initial_interval=10, backoff_factor=2, max_attempts=3, retry_on=is_transient)graph.add_node("parse_posting", partial(parse_posting, deps=deps), retry_policy=LLM_RETRY)
Retrieval decides what the model is allowed to claim, so the index is built around one question: which job did this come from?
The resume is split into one chunk per bullet, and each chunk is tagged with the role heading it sits under, like “Backend Engineer · Paylane (fintech) | Jan 2020 — Feb 2023”. The summary and skills list are indexed too, but marked as profile chunks, because they describe me in general and don’t prove anything about a specific job.
Search is hybrid: pgvector similarity plus Postgres full-text search, merged with Reciprocal Rank Fusion. Vector search finds “built a payments API” for “backend services”, and full-text search finds the exact “FastAPI” or “pgvector” a posting asks for. All the requirement queries are embedded in one batched request, which keeps a whole run to a handful of API calls.
Why one chunk per bullet: it lets every claim in the draft point to one line of evidence, and lets the code check later that a bullet’s evidence all comes from the same role.
The first draft prompt told the model to use only the evidence it cites. I ran it on my real resume and a real posting, and the draft looked good until I compared it with the evidence:
None of these are dramatic hallucinations. They are the quiet kind a reader would believe, and that a recruiter might ask about in an interview. A prompt is a request, not a guarantee, so I added deterministic checks that run on every draft:
work = [e for e in cited if not e.is_profile]if not work: return None, "only backed by the summary or skills list"anchors = list(dict.fromkeys(e.anchor for e in work))if len(anchors) > 1: flags.append(f"combines different roles: {'; '.join(anchors)}")if unsupported := sorted(numbers_in(bullet.text) - numbers_in(cited_text)): flags.append(f"numbers not in the cited evidence: {', '.join(unsupported)}")
Flags don’t delete a bullet. They tell the reviewer exactly what to double-check. With the checks in place and a tighter prompt, the second run on the same posting produced seven bullets with no flags, in 53 seconds instead of 174.
The same checks run on edits a person makes. To show it, I added an invented “35%” to a bullet in the demo, and the check caught it right away:
Why not ask another model to check the draft: these failures have exact definitions. A number is either in the evidence or it isn’t, and a bullet’s evidence either comes from one role or it doesn’t. A few lines of Python answer that every time, for free, and a test can prove they work. An LLM judge is useful when the question is fuzzy, and these aren’t.
When the draft is ready, the graph s with interrupt() and sends the draft, the flags, and the coverage of every requirement. The reviewer can approve it, ask for changes ("lead with the LLM work"), which sends the draft back to the drafting step with that feedback, or edit the text directly. Edits come back for review instead of finishing, so any new flags are seen before approving.
Next to the draft, the coverage table shows the reasoning for each requirement, including the missing ones, which are useful to know before an interview:
The interesting bug was in how resume works. When a d graph is resumed, LangGraph runs the review step again from the top, and interrupt() returns the saved decision. Those decisions are saved and replayed in order. So if a decision is invalid (say, "ask for changes" with no feedback) and the step raises an error, that same invalid decision is replayed on every later resume, and the run can never get past it.
The fix is to never raise. The step validates the decision and, if it’s invalid, asks again with the error attached:
request = review_request(state)while True: try: decision = ReviewDecision.model_validate(interrupt(request)) break except ValidationError as e: request = {**request, "error": "; ".join(err["msg"] for err in e.errors())}
The FastAPI service runs each run in the background in segments: from the start to the review, from a decision to the next or the end, or from a retry. Progress goes to the browser over Server-Sent Events: an event when a step starts, one when it finishes with a summary (“11 strong, 1 partial, 1 missing”), and one when the run s, finishes, or fails.
Because LangGraph saves the state after every step, two things came almost for free:
The React UI is a run list, a live step list, the review panel, and the finished resume with copy and download buttons. Every run has its own URL, so a reload or a bookmark brings back the exact state. Two bugs from this part are worth sharing. Elapsed times restart at zero in every segment, so the first version showed a negative duration for the redrafting step after a revision. And the browser’s EventSource reconnects on its own when a stream ends, so the client has to close it on the final event, or it keeps asking the server for a run that has already finished.
The finished resume, with the usage line in the top right:
Every run counts its model calls, tokens, and the time it spent working (not the time it waited for me). A LangChain callback adds up the token usage each call reports, and the totals are saved with the run, so the header can say “4 model calls · 8.1K tokens · 11s”.
For the details, the same callbacks send each step to Langfuse, which I run locally in Docker, so tracing costs nothing and no resume leaves my machine. Each segment is one trace, and all of a run’s traces share one session per run, so a run with a revision reads as one timeline. The Trace link on the run page opens it:
Why it was worth it: the traces showed something the logs had hidden. Once the main model’s free daily quota (20 requests a day for this model) was used up, every step still tried it first, got a 429 error, and only then fell back to the lighter model. Each run was paying for one failed call per step, all day.
Gemini’s error says which quota ran out and how long until it resets. So now, when a daily-quota error comes back, the app skips the main model until the reset time and goes straight to the fallback. Per-minute limits clear on their own, so they don’t trigger it:
def with_structured_output(self, schema): runnable = self.primary.with_structured_output(schema) fallback = self.fallback.with_structured_output(schema) if self.clock() < self.primary_resumes_at: return fallback # the daily quota is used up; don't spend a call finding that out again watched = runnable.with_config(callbacks=[_QuotaWatcher(self)]) return watched.with_fallbacks([fallback], exceptions_to_handle=FALLBACK_ON)
One small fix on top: Langfuse prices Gemini calls at the paid rates by default. A $0 model definition in the project settings makes the cost column match what the free tier actually charges.
I built this with Claude Code as a pair programmer. It wrote most of the code, the tests, and the first drafts of the docs. My job was the decisions: what the agent must never do, which failures belonged in code and which in the prompt, reading the first real draft line by line against the evidence, and asking why a run made more calls than it should. The grounding checks came from that first comparison, and the quota cooldown came from questioning a number in a trace.
The project has 70 backend tests and 24 front-end tests. The backend tests run the graph with a fake model and a real Postgres, so they cover pausing, resuming, revising, retrying after a failure, and recovering after a restart. The grounding checks have tests for each failure from the first real run, and the cooldown has tests with a fake clock. The front-end tests use a fake EventSource to play back a run event by event.
The checks catch what can be checked exactly. They can’t tell whether a bullet overstates something that is in the evidence, like turning “helped with” into “led”. That still needs the human review, which is why approval is required. The demo in this article uses a fictional sample resume, so the numbers are small, and a long career would produce more evidence and longer prompts.
Next up:
The code is on GitHub: github.com/Eric-Souza/job-application-copilot. With Docker and a free Gemini key, docker compose up -d, python -m app.cli ingest and npm run dev get you to the screens above.
Would you trust an AI with your resume, and what would it have to show you first? Leave a comment.
I built an AI that tailors my resume, then spent most of the time stopping it from making things up was originally published in Dev Genius on Medium, where people are continuing the conversation by highlighting and responding to this story.