The moves that matter
| Move | Think of it as | What actually happens |
|---|---|---|
| State | The clipboard passed around | A TypedDict. Every node gets it and returns a partial update — never the whole thing. |
| Reducer | How to merge two clipboards | Annotated[list, add_messages] means "append, don't replace". Without it, each node would wipe the history. |
| Node | One box in the flowchart | A function: state in, dict out. think and act are the only two here. |
| Edge | An arrow | add_edge("act", "think"). Always goes there next. |
| Conditional edge | A diamond in the flowchart | A function returning the name of the next node. The only decision the graph makes. |
| @tool | A job the model can ask for | Turns a Python function's name, arguments and docstring into a schema the model can see. |
| bind_tools | Handing over the menu | Attaches those schemas to the request. It grants no power — the model can only ask. |
| compile / invoke | Build it, then run it | compile() freezes the graph; invoke() runs from START until END. |
The file, in the order it makes sense
- State — one field,
messages. The agent's entire memory is the message list, and it only exists during the run. - The tool —
read_card, a normal function. Its docstring is the prompt: that text is what the model reads when deciding whether to call it. Write it for the model, not for a code reviewer. - The model — one line:
ChatAnthropic(...).bind_tools(TOOLS). The only AI in the file. - think() — call the model with everything so far, append what comes back. Three lines.
- act() — run whatever the model asked for, append each result as a
ToolMessagecarrying the id of the request it answers. That id is how the model knows which answer is which. - next_move() — the whole decision: did the last message carry
tool_calls? Go toact. Otherwise END. - The graph — four lines of wiring. The edge from
actback tothinkis the loop. Remove it and you have a single API call with extra ceremony.
Safe to try
None of these can cost anything or break anything — the tool in this sample is one hard-coded card.
pip install -U "langchain[anthropic]" # langchain 1.4, langgraph 1.2 — it pulls both
python agent.py # no key: it fails at the model call, everything else is readable
print(agent.get_graph().draw_ascii()) # the flowchart, from the compiled graph itself
for m in final["messages"]: print(m.type, m.content[:80]) # the whole run, turn by turn
Swap model for a fake object with an .invoke() that returns a canned message and the entire loop runs offline. That is how you test an agent's plumbing without a key or a bill.
The everyday loop
Write the tool. Write its docstring as if the model were a new colleague who can only read that one sentence. Run it and read every message, not just the last one — the tool calls are where you see whether it understood. Then fix the docstring, not the model.
Practice, start to finish
- Run it as-is. You should see a draft that mentions the other steps on the billing card — proof it called the tool.
- Break the docstring: change it to "Returns data." The model stops calling it, or calls it at the wrong moment. That is how much the docstring is doing.
- Add a second tool that returns today's date. Two tools in the list, nothing else to change: the loop already handles any number.
- Set
recursion_limitto 2. You get an error instead of an answer — which is what a stop condition feels like when it fires. - Print the messages after the run. System, human, AI-with-tool-call, tool, AI-with-answer. Five messages is a whole agent run.
When it goes wrong
| Symptom | What it really is | Way out |
|---|---|---|
| It never calls the tool | The docstring does not describe a situation the model recognises | Rewrite the docstring as "use this when…". Name the trigger, not the return type. |
| It calls it forever | The tool answers without resolving anything, so nothing changes turn to turn | Make the tool's answer conclusive, and keep recursion_limit low. |
GraphRecursionError | The stop condition fired — working as designed | Read the messages to see what it kept retrying. Do not just raise the limit. |
| Answers drift after a few turns | The message list grows, and every turn re-reads all of it | Keep runs short. Summarise and restart rather than appending forever. |
| Tool raises, run dies | An exception is not something a model can read | Catch it and return the text, as act() does here. |
| Wrong argument types | The model is filling in a schema it half-understands | Type the parameters properly and validate inside the tool. The schema is a contract, not a suggestion. |
Leave this alone for now: giving a sample agent a tool that writes to anything real. Read-only tools until you have watched a full run and read every message it produced.
Words you will see
add_messages appends; the default replaces.act(). Same job, already written.next_move().langchain.agents. Was create_react_agent before the 1.0 releases.Deliberately not here
Checkpointers and resumable runs, streaming tokens, human-in-the-loop interrupts, subgraphs, multi-agent handoffs, and LangSmith tracing — every one of them is a good idea and none of them is legible until the loop above is. Also skipped: retrieval, which is just another tool, and deployment, which is a different problem entirely. When you want a human gate in the middle of a run, that is the moment to come back for interrupts.
create_agent (in langchain.agents, taking system_prompt=), moved imports to langchain.messages / langchain.tools / langchain.chat_models, replaced the pre/post model hooks with middleware, and pushed the legacy chains into a separate langchain-classic package. ToolNode and tools_condition stayed in langgraph.prebuilt. Anything written before 2026 — including the notebooks that ship with Gulli’s Agentic Design Patterns — uses the old names and will not run as written.LangGraph · primer · September 20, 2026 · The file it explains: @ Singularity/Forge/! Main/agent-samples/01-step-drafter/agent.py