Three parts in, JobCopilot had layers that worked but couldn't talk to each other. Part 1 gave me a structured-output layer — ParseOk/ParseError as data. Part 2 gave me retrieval — build_context() pulling the top-8 resume chunks for a job description. The pipeline was one line on paper; in code it was nested calls, and the awkward questions had no home: who retries when the model drifts into prose? Who stops the retries? Where does the score live between "parsed" and "shown to the user"?
I did what a React dev does in a new language: I nested functions. The retry loop closed over a closure that closed over a callback, and I couldn't tell you who owned what. Rewriting the flow as a graph — my first with LangGraph — made the nesting disappear. Not because a framework absorbed it, but because the mental model changed: functions stop calling each other; state flows through a graph, and nodes return patches of that state.
It survived everything I threw at it — after I fixed one wire that made every job come back "error", no matter how perfectly the model answered.
<!-- NOTE TO V: the JobCopilot mirror isn't on this machine, so code below is the canonical pattern rebuilt from the part-1/part-2 contracts (ParseOk/ParseError, build_context, TOP_K=8). Before publishing: align file names, node names and prompt wording with your repo. -->
1. The state is a pydantic model; nodes return patches
If the graph is the wiring, state is everything a run knows. I made it one pydantic model — deliberately the same discipline as part 1:
# state.py — the whole run in one model
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
THRESHOLD = 60 # score at or above this = "worth applying"
MAX_CALLS = 3 # 1 first try + 2 repairs — the budget from part 1
class AgentState(BaseModel):
"""One run of the copilot, end to end."""
jd_text: str = ""
context: str = "" # resume chunks, from part 2
last_raw: str = "" # what the model last said, verbatim
error: str | None = None # latest parse failure, if any
attempt: int = 0 # how many LLM calls so far
match_score: int | None = None
missing_skills: list[str] = Field(default_factory=list)
verdict: Literal["match", "no_match", "error"] | None = None
<!-- NOTE TO V: `THRESHOLD = 60` and `MAX_CALLS = 3` are illustrative constants; swap in your real repo values. -->
The rule that makes a graph a graph: a node never calls another node. It receives state, does one job, and returns a dict with only what changed — a patch. LangGraph merges the patch and hands state to the next node. retrieve fills context; judge fills the score fields; nobody knows about anybody else.
Fields without a custom reducer are last-writer-wins — I proved it to myself before trusting it:
def note_a(state: AgentState) -> dict:
return {"missing_skills": ["Python"]}
def note_b(state: AgentState) -> dict:
return {"missing_skills": ["Terraform"]}
# a -> b in that order: missing_skills == ["Terraform"] — b overwrote a
For my state that's correct by design: every field is overwrite-on-purpose, so zero reducers. If a list should accumulate across nodes, opt in with Annotated:
from typing import Annotated
def merge_lists(left: list[str] | None, right: list[str] | None) -> list[str]:
if left is None:
return right or []
if right is None:
return left
return list(left) + [x for x in right if x not in left]
class AccumState(BaseModel):
missing_skills: Annotated[list[str], merge_lists] = Field(default_factory=list)
# two nodes appending "Python" then "Terraform" -> ["Python", "Terraform"]
And why pydantic for state instead of the TypedDict every tutorial starts with? A patch is data from somewhere else, and at merge time nobody type-checks a plain dict:
# TypedDict state, merged by dict spread — type-checkers scream, Python shrugs:
>>> {**{"attempt": 0, "verdict": None}, "verdict": 21, "attempt": "two"}
{"attempt": "two", "verdict": 21} # garbage, happily stored
# pydantic model:
>>> PS(attempt="two", verdict=21)
pydantic.ValidationError: attempt — Input should be a valid integer...
Same lesson as part 1: shape is a contract, and contracts live in pydantic, not in comments.
2. The graph is a handful of declared edges
Here is the entire graph.py — wiring, not logic:
# graph.py
from langgraph.graph import END, START, StateGraph
from nodes import Chat, fail_node, judge_node, retrieve_node, route_after_parse
from state import AgentState
def build_graph(chat: Chat):
g = StateGraph(AgentState)
g.add_node("retrieve", retrieve_node)
g.add_node("judge", lambda s: judge_node(s, chat)) # closure: node gets the LLM client
g.add_node("fail", fail_node)
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "judge")
g.add_conditional_edges(
"judge",
route_after_parse, # returns "ok" | "retry" | "give_up"
{"ok": END, "retry": "judge", "give_up": "fail"},
)
g.add_edge("fail", END)
return g.compile()
Coming from React, this shape clicked instantly: it's a component tree. Components don't import each other to call each other — data flows down a declared structure, and the structure decides what happens next.
<!-- NOTE TO V: align node names (`retrieve` / `judge` / `fail`) and the `chat(prompt)` thin-client signature with your repo; `chat` is the same one-call LLM wrapper the structured-output article assumed. -->
The lambda is deliberate: the graph is built once with a concrete LLM client, and no node ever imports a provider. That's what makes the graph testable — hand it a fake chat and the whole flow runs with zero network. Which is exactly how I caught the bug.
3. All routing logic in one boring function — and the wire that said "error" to everything
add_conditional_edges takes a router: a pure function from state to a string, plus a mapping from strings to destinations. Mine is twelve lines and holds the entire loop policy of the agent:
# nodes.py
from state import MAX_CALLS
def route_after_parse(state: AgentState) -> str:
"""All loop logic in one boring function."""
if state.error is None:
return "ok"
if state.attempt < MAX_CALLS:
return "retry"
return "give_up"
Compare with my first wiring attempt, written because the router concept felt foreign:
g.add_edge("retrieve", "judge")
g.add_edge("judge", "fail") # "temporarily — I'll wire the real branches next"
<!-- NOTE TO V: adjust this anecdote to what actually happened in your repo; keep the lesson either way. -->
Then I ran it — with a fake chat returning a perfect JSON verdict. Result: verdict = "error". Every single job. The parse was flawless, the score valid, and the graph marched it into fail_node anyway: I had declared exactly one outgoing edge from judge, and a graph never "falls through". Nothing raised, nothing warned — the state machine politely followed the only road I built:
>>> # plain edge judge -> fail, no conditional edges, perfect JSON from the model
>>> app.invoke(AgentState(jd_text="junior"))
verdict = "error" # a valid parse, executed through the wrong edge
The fix was the conditional edges above, plus two habits that came with it:
- Route values are data, not flow control. If the router returns a key that's not in the mapping, LangGraph doesn't guess — it throws
KeyError at runtime. I know because my typo did exactly that. An unmapped route crashes loudly; a wrongly-wired edge fails silently. I'll take the crash.
- Loops are declared, not nested.
"retry": "judge" makes a cycle in the graph. The loop exists as an edge, its budget lives in MAX_CALLS, and its stop condition is one pure function I can unit-test without LLM, network, or shame.
The shift, internalized: in nested functions, control flow was who calls whom — invisible at a glance. In the graph, it's data in the edges — visible in one screenful.
4. Closing the repair loop with state, not recursion
Last piece: judge_node, the only node that talks to the model. It reads state to decide which prompt to send — first pass or repair — and returns a patch either way:
# nodes.py
from schemas import ParseError, ParseOk, parse_match
from state import AgentState, THRESHOLD
MATCH_PROMPT = """You are a skeptical technical recruiter.
Compare the job description to the candidate's resume excerpt.
Reply with ONLY a JSON object:
{{"match_score": <int 0-100>, "missing_skills": [<string, ...>]}}
JOB DESCRIPTION:
{jd}
RESUME EXCERPT:
{context}
"""
FIX_PROMPT = """Your previous reply could not be parsed.
PREVIOUS REPLY:
{previous}
PARSER ERROR:
{error}
Reply with ONLY a valid JSON object:
{{"match_score": <int 0-100>, "missing_skills": [<string, ...>]}}
"""
def judge_node(state: AgentState, chat: Chat) -> dict:
"""One LLM call, one parse, a patch of state. Loops are the graph's job now."""
if state.error is None:
prompt = MATCH_PROMPT.format(jd=state.jd_text, context=state.context)
else:
prompt = FIX_PROMPT.format(previous=state.last_raw, error=state.error)
raw = chat(prompt)
result = parse_match(raw) # the part-1 contract, unchanged
patch: dict = {"last_raw": raw, "attempt": state.attempt + 1}
if isinstance(result, ParseOk):
score = result.data.match_score
patch.update(
match_score=score,
missing_skills=result.data.missing_skills,
verdict="match" if score >= THRESHOLD else "no_match",
error=None,
)
else:
patch.update(error=result.message)
return patch
def fail_node(state: AgentState) -> dict:
"""The model never produced valid JSON. Say so — as state, not a stack trace."""
return {"verdict": "error"}
<!-- NOTE TO V: these prompts are skeleton versions; align wording with your actual `MATCH_PROMPT`/`FIX_PROMPT` from the structured-output layer. -->
Because build_graph takes chat as a parameter, the end-to-end test is a fake answer card — call one drifts into prose, call two complies:
def flaky_llm():
calls = {"n": 0}
def chat(prompt: str) -> str:
calls["n"] += 1
if calls["n"] == 1:
return "Sure! The match is quite good, I'd say around 70 out of 100."
return '{"match_score": 70, "missing_skills": ["Terraform"]}'
return chat
app = build_graph(flaky_llm())
final = app.invoke(AgentState(jd_text="Senior Frontend Engineer (React, TS)"))
verdict: match | score: 70 | attempts: 2 | error: None
The prose landed in last_raw, parse_match filled error, the router said "retry", and the second call got FIX_PROMPT with the model's own prose quoted back at it — the part-1 trick, now driven by edges instead of a while-loop. When the model never complies:
verdict: error | error: no JSON object found in model output | attempts: 3
No exception slicing through three stack frames. fail_node writes verdict: "error" and the graph ends — for an agent run overnight over forty postings, a run that ends with a recorded failure beats ten runs that crashed and took the batch with them. Part 1's "errors as data", promoted from one function to the shape of the whole system.
<!-- NOTE TO V: outputs above are from my fake-LLM smoke run (langgraph 1.2.15, pydantic 2.14.1); re-check against your repo's pinned versions. Worth re-confirming on your version: last-writer-wins merge and `KeyError` on an unmapped route. -->
Wrapping up
The first graph, in one sentence: state is a pydantic contract, nodes return patches, a router returns strings, loops live in edges.
Three things this React-dev-learning-Python took from it:
- Patches, not calls. The merge is last-writer-wins by default; accumulators are opt-in via
Annotated. Every field in my state is overwrite-on-purpose — zero reducers, but verified behavior before trusting it.
- Routing is a pure function. Twelve lines, no LLM, unit-testable — and an unmapped return value crashes with a
KeyError. That's the framework refusing to guess, and I've come to love it.
- Giving up is a node, not a throw.
fail writes verdict: "error" and the graph ends cleanly. The batch survives; the log tells the truth.
And the bug that taught me most: a graph executes exactly the edges you declared — my single judge → fail wire turned perfect parses into errors with zero pushback from the framework. A state machine has no common sense. That's the feature.
Next Tuesday, part 4: evals. The judge says 72 — but is 72 right? Fixtures of job descriptions with expected score ranges, a negative case that must score LOW (a Python-backend posting should not match a frontend resume), and the first time JobCopilot tells me I'm wrong.
Follow if you want to watch the rest of the copilot get built — including the parts that break. Build log + daily code: t.me/novamind_hub. Part 1, in case you missed it: Stop prompting for valid JSON: an LLM output layer that does not break.
Do you build agent graphs with a framework or hand-roll the loop? And what's the first thing your graph did that you didn't expect?