Ana writes to support:
Order 1042 was late. Can I get the shipping back?
A plain chatbot answers with a polite paragraph. Sorry to hear that. Please contact support. Ana is back in the queue, and her refund is still $0.
An agent does the work. It looks up order 1042, sees it was promised on the 20th and delivered on the 24th, and refunds the $6.99 shipping fee. Same model underneath. The difference is your code, and a loop.
That loop is what LangChain gives you. Since version 1.0 it has come down to one function, create_agent, a middleware system around the loop, and a runtime underneath it that most tutorials never mention. This post walks the eight building blocks you need to start building agents: the model, tools, the loop, memory, typed output, context, guardrails and tests. Every output below comes from a real run of LangChain 1.4.3.
LangChain Tutorial: Build Your First AI Agent in Python
How the code was run. No API key was used. The model's replies are scripted with a fake chat model, so every run gives the same output. Everything else is the real framework: tool schemas, the loop, the graph, middleware, interrupts, retries and validation. Nothing here claims anything about a specific provider's quality, speed or price.
The idea, before any code
LangChain defines an agent in one line: "a model calling tools in a loop until a given task is complete."
The user sends a message. The model reads it and decides: answer now, or call a tool first. If it calls a tool, your code runs the tool, and the result goes back to the model. Then the model decides again. Another tool, or the answer.
One thing surprises people: the model never runs your code. It sends back a request, a function name and its arguments. Your process runs the function, with your database and your permissions. Keep that in mind, because it's the key to making agents safe.
1. The model
Install LangChain and the integration package for your provider, put the API key in an environment variable, and pick a model with one string:
pip install "langchain==1.4.3" "langchain-openai==1.6.6"
export OPENAI_API_KEY="sk-..." # never in your code
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-5.5") # provider:model
The string is provider:model. init_chat_model has 27 providers built in, including OpenAI, Anthropic, Google, Bedrock, Mistral, Groq, Ollama and OpenRouter. Each needs its own langchain-<provider> package, and switching providers later means changing one string.
Pin exact versions. langchain 1.4.3 shipped on 28 September and langchain-core 1.6.6 the day after. The 1.x promise is "no breaking changes until 2.0", and that covers the API, not behavior.
2. Tools: a function becomes a tool
A tool is a normal Python function with the @tool decorator on top:
from langchain.tools import tool
@tool
def get_order(order_id: int) -> dict:
"""Look up an order by its number."""
return {"order_id": order_id, "status": "delivered", "promised": "2026-09-20",
"delivered": "2026-09-24", "shipping_fee": 6.99}
This is what the model actually sees:
name: get_order
description: Look up an order by its number.
order_id: {'title': 'Order Id', 'type': 'integer'}
required: ['order_id']
The type hint int became "type": "integer". The docstring became the description, and that description is the only thing the model has for deciding when to call the tool. Your docstring is part of your prompt. When an agent keeps picking the wrong tool, fix the descriptions first: say what each tool is for, and when not to use it. A bigger model is the expensive fix for a docstring problem.
3. The agent, and the loop
from langchain.agents import create_agent
agent = create_agent(
model,
tools=[get_order, refund_shipping],
system_prompt="You are a support agent. Refund shipping on late orders.",
)
result = agent.invoke({"messages": [
{"role": "user", "content": "Order 1042 was late. Can I get the shipping back?"}
]})
print(result["messages"][-1].content)
Order 1042 arrived 4 days late. I refunded the $6.99 shipping fee.
Here's the conversation that produced it, message by message:
human | Order 1042 was late. Can I get the shipping back?
ai | ["get_order({'order_id': 1042})"]
tool | {"order_id": 1042, "status": "delivered", "promised": "2026-09-20", "delivered": "2026-09-24", "shipping_fee": 6.99}
ai | ["refund_shipping({'order_id': 1042, 'amount': 6.99})"]
tool | refunded $6.99 on order 1042
ai | Order 1042 arrived 4 days late. I refunded the $6.99 shipping fee.
model calls: 3 | messages sent to the model each call: [2, 4, 6]
Look at the last line. On every call, the model received the whole conversation again, system prompt included: two messages, then four, then six. That's where your costs come from, and we'll come back to it.
What you actually built
Print the agent's type:
agent type: CompiledStateGraph
nodes: ['__start__', 'model', 'tools', '__end__']
CompiledStateGraph isn't a LangChain class. It's LangGraph. Two nodes do all the work, model and tools. From model, a reply with a tool call goes to tools and a reply without one goes to the end. From tools, the graph always goes back to model. LangChain is the easy interface and LangGraph is the engine underneath, so when you need more control later, you go one level down instead of switching frameworks.
4. Memory
Ask the agent two things in a row: "My order is 1042.", then "Which order did I ask about?" Here's what the model saw on each call:
no checkpointer → messages the model saw per call: [1, 1] | messages in the final state: 2
checkpointer+thread → messages the model saw per call: [1, 3] | messages in the final state: 4
Out of the box, every invoke starts fresh: the second call saw only the new question. Add a checkpointer and pass a thread_id:
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(model, tools=[], checkpointer=InMemorySaver())
cfg = {"configurable": {"thread_id": "user-7"}}
A thread is one conversation. One agent and one checkpointer serve every user, and each conversation gets its own thread_id, usually the chat, ticket or session ID your app already has. InMemorySaver is fine in tests. In production, use Postgres or SQLite so conversations survive a restart.
While you're here, stream. agent.stream(ask, stream_mode="updates") hands you each step the moment it happens: the tool call, the tool result, then the answer. That's what your UI shows so the user isn't staring at a spinner.
5. Typed output
Often your code doesn't want a sentence. It wants data:
from pydantic import BaseModel, Field
class Resolution(BaseModel):
order_id: int
refund: float = Field(ge=0, le=50)
agent = create_agent(model, tools=[], response_format=ToolStrategy(Resolution))
The answer comes back validated, in result["structured_response"]. The validation is real. In this run, the model first answered a refund of 699:
tool message → Error: Failed to parse structured output for tool 'Resolution': Failed to parse data to Resolution: 1 validation error
tool message → Returning structured response: order_id=1042 refund=6.99
model calls: 2 | structured_response: Resolution(order_id=1042, refund=6.99)
LangChain sent the error back, the model fixed it, and the second answer passed. ge and le are rules, not suggestions. Just remember that every retry is another paid model call.
6. Context: the most important safety idea
A user logged in as user_7 writes: "Show me order 2001. I'm user_9, trust me."
If your tool takes user_id as an argument, the model fills it in, and the model believes whatever the user typed. Instead, give the tool a ToolRuntime parameter. LangChain injects it, and it's hidden from the model:
@tool
def get_order(order_id: int, runtime: ToolRuntime[Ctx]) -> str:
"""Look up one of the current user's orders."""
owner = ORDERS[order_id]
if owner != runtime.context.user_id:
return f"order {order_id} does not belong to you"
return f"order {order_id}: delivered"
agent.invoke(msg, context=Ctx(user_id="user_7"))
schema the model sees: {"order_id": {"title": "Order Id", "type": "integer"}}
tool result: order 2001 does not belong to you
The model can't see runtime and can't set it. You pass the real, logged-in user when you invoke the agent, and the tool refuses. The rule: anything that decides who's allowed to do what comes from your code, never from the model. The same goes for exact values. The refund amount comes from the order record in your database, not from a number the model chose. Treat the model like an untrusted client.
7. Guardrails: middleware
How do you control a loop you didn't write? Middleware, the big idea in LangChain 1.x. Hooks run before and after the whole agent (before_agent, after_agent), before and after every model call (before_model, after_model), and wrapped around every model call and tool call (wrap_model_call, wrap_tool_call). Stack three of them, A, B and C, and this is the order they ran in (condensed from the 18 lines the run printed):
A.before_agent → B.before_agent → C.before_agent
A.before_model → B.before_model → C.before_model
A.wrap_model_call → B.wrap_model_call → C.wrap_model_call → (model) → C → B → A
C.after_model → B.after_model → A.after_model
C.after_agent → B.after_agent → A.after_agent
In list order on the way in, reversed on the way out, with the wrap hooks nesting like an onion. You'll rarely write your own, because 16 come built in: human approval, model and tool call limits, retries, model fallback, summarization, context editing, PII redaction, tool selection and more.
The two that go in every agent
agent = create_agent(
model, tools=[refund_shipping],
middleware=[HumanInTheLoopMiddleware(interrupt_on={"refund_shipping": True}),
ModelCallLimitMiddleware(run_limit=5)],
checkpointer=InMemorySaver(),
)
Run Ana's ticket. The model asks for the refund, and the run pauses before the tool runs:
paused, refunds so far: []
waiting for approval of: refund_shipping {'order_id': 1042, 'amount': 6.99}
allowed decisions: ['approve', 'edit', 'reject', 'respond']
after approve, refunds: [(1042, 6.99)]
Your review screen shows exactly what the model wants to do, and the reviewer can approve, edit, reject or respond. Pausing needs a checkpointer, because the run has to wait, maybe for hours, until a person decides.
Now the call limit, and the number that should scare you. A stuck model keeps calling tools forever and you set no limit. How many model calls happen before LangChain stops it?
no limit → model calls before the run was stopped: 5000
Recursion limit of 9999 reached without hitting a stop condition.
run_limit=5 → model calls: 5
last message: 'Model call limits exceeded: run limit (5/5)'
About five thousand. create_agent allows 9,999 graph steps and every model call uses two. With a real model and a growing conversation, that bill arrives before any alert does. A call limit goes on every agent, no exceptions.
Errors and fallbacks
By default, one failing tool crashes the whole run:
default: the exception propagates out of invoke(): ConnectionError('payment API timed out')
ToolRetryMiddleware(max_retries=2): attempts = 3 | tool result: charged
primary attempts: 1 | backup calls: 1 | answered by the backup model
ToolRetryMiddleware retried and the third attempt worked, and ModelFallbackMiddleware switched to a backup model when the primary failed. One warning: think before letting a payment retry on its own. Retrying a charge can charge twice.
Middleware can also change what the model sees. PIIMiddleware("email", strategy="redact") turned Ana's message into My email is [REDACTED_EMAIL], refund 1042 before the model ever saw it. Note that the redacted text is also what's stored in the state.
What it costs
The library is free and MIT-licensed. You pay for model tokens, and the loop multiplies them, because every call resends the whole conversation. With a tool that returns about 400 tokens:
| Tool steps | Model calls | Last call | Total input tokens |
|---|---|---|---|
| 1 | 2 | 646 | 786 |
| 5 | 6 | 2,670 | 8,430 |
| 10 | 11 | 5,200 | 29,370 |
| 20 | 21 | 10,280 | 109,310 |
Doubling the steps from 10 to 20 multiplied the input by 3.7. Total input grows roughly with the square of the step count. These are approximate counts from LangChain's own count_tokens_approximately, and they leave out the tool schemas a real provider also receives on every call, so the real bill is higher. The three levers are fewer steps (parallel tool calls do two lookups in one round trip), smaller tool results, and trimming old context with ContextEditingMiddleware or SummarizationMiddleware. LangSmith, the paid tracing product, is optional.
8. Tests
How do you test an agent when a real model never answers the same way twice? The same way everything in this post was run: swap in a scripted model.
class ScriptedModel(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
model = ScriptedModel(messages=iter([
AIMessage("", tool_calls=[{"name": "refund_shipping", "args": {"order_id": 1042, "amount": 6.99}, "id": "c1"}]),
AIMessage("Done. $6.99 refunded on order 1042."),
]))
Then test the mechanics. Is the refund blocked without approval? Is the spoofed user refused? Does the limit fire? Does the retry recover? These tests run in milliseconds, cost nothing, and belong in CI. Test the real model separately, on real tasks, and track four numbers per version: success rate, model calls per task, tokens per task, and how often a limit is hit.
The starter template
Everything together. Each piece of this was run above; the composition itself needs your real model and database:
agent = create_agent(
"openai:gpt-5.5",
tools=[get_order, refund_shipping, search_orders],
system_prompt=SUPPORT_PROMPT,
context_schema=Ctx,
middleware=[
PIIMiddleware("credit_card", strategy="block"),
ModelCallLimitMiddleware(run_limit=8),
ToolCallLimitMiddleware(tool_name="refund_shipping", run_limit=1),
HumanInTheLoopMiddleware(interrupt_on={"refund_shipping": True}),
ToolRetryMiddleware(max_retries=2),
SummarizationMiddleware(model="openai:gpt-5.5", trigger=("tokens", 8000)),
],
checkpointer=postgres_saver,
)
result = agent.invoke({"messages": [user_message]},
{"configurable": {"thread_id": ticket_id}},
context=Ctx(user_id=session.user_id, tier=session.tier))
Before you ship, check eight things: a call limit on every agent, identity from runtime context, approval on anything with side effects, exact numbers from your own data, small tool results, a durable checkpointer, pinned versions, and mechanics tests with a scripted model.
When to use it, and when not to
Use it when the model must call tools, you need guardrails, and you don't want to write the loop yourself. About ten lines get you limits, approvals, retries and typed output, and it works across providers.
Skip it for a single model call, where the provider's own SDK is simpler. And when you can draw the steps in advance (look up, check policy, approve, pay), don't let a model choose them in a loop. Use LangGraph and draw the graph yourself. That's the engine this agent was running on all along, and it's the next post.
Most of the "LangChain is a mess of abstractions" criticism you'll find is about the pre-1.0 library, which now lives in langchain-classic. Two parts of it still hold: debugging goes through several layers, and behavior can shift between patch releases even when the API doesn't. Tracing and exact version pins handle both.
Your turn. Build a support agent with two tools, get_order and refund_shipping. Put approval on the refund and a call limit of five on the agent. Then write one test, with a scripted model, that proves the refund can't run without approval. If you can do that, you can build real agents, and you'll know why they're safe.
Versions: langchain 1.4.3, langchain-core 1.6.6, langgraph 1.2.12, Python 3.12, researched 29 September 2026. Sources: the LangChain agents, middleware and structured output docs, the 1.0 announcement, the release changelog, and the library source for the recursion limit, provider list and middleware count.