Agents with create_agent

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against langchain 1.4.3, langchain-core 1.6.5, langgraph 1.2.12 and langchain-ollama 1.1.0 on Python 3.12, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this site’s documentation using Ollama’s llama3.1:8b chat model and the nomic-embed-text embedding model over the pgvector store built in Integrating with LangChain. Ollama was not available while the snippets were checked, so every output shown here was produced by running the code against the real packages with a scripted fake chat model (a small subclass of GenericFakeChatModel) and a one-line stub in place of the pgvector retriever; no output comes from a live model. The fake model replays the messages it was given, so the outputs prove the wiring (which hook ran, which message was appended, which state key was filled), not the quality of any model’s answers.

An agent is a model that calls tools in a loop until the task is done. create_agent, imported from langchain.agents, is the LangChain 1.x factory that builds that loop as a compiled LangGraph graph. You supply a model, tools and a system prompt; you extend the behaviour with middleware instead of writing graph code. The framework-neutral theory of the loop (planning, reflection, when a workflow beats an agent) is in Agent Loop & Architectures and is not repeated here. The tools the agent calls are built in Tools, and the model classes in Chat Models & Messages.

Your first agent

create_agent takes the model as its first argument and the tools as a list. A system prompt is a keyword argument. The snippet below builds DocsAssistant’s smallest agent. The ScriptedChat helper is the fake model used for every example on this page: it replays a list of AIMessage objects, one per model call, and its bind_tools returns itself because the agent calls bind_tools on the model to register the tools.

DocsAssistant’s smallest agent
from langchain.agents import create_agent
from langchain.tools import tool
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage


class ScriptedChat(GenericFakeChatModel):
    """Replays scripted AIMessages and tolerates bind_tools()."""

    def bind_tools(self, tools, **kwargs):
        return self


def scripted(*messages):
    return ScriptedChat(messages=iter(messages))


@tool
def search_docs(query: str) -> str:
    """Search the documentation site and return matching sections."""
    return "ann-indexes.adoc :: HNSW :: HNSW ef_search trades recall for latency."   # pgvector in DocsAssistant


call = {"name": "search_docs", "args": {"query": "HNSW ef_search"}, "id": "call_1", "type": "tool_call"}
model = scripted(
    AIMessage(content="", tool_calls=[call]),
    AIMessage(content="ef_search sets how many candidates HNSW explores per query; higher values raise recall and latency."),
)
agent = create_agent(
    model,
    tools=[search_docs],
    system_prompt="You are DocsAssistant. Answer only from search_docs results and name the page.",
)
result = agent.invoke({"messages": [{"role": "user", "content": "What does ef_search do?"}]})
for m in result["messages"]:
    print(type(m).__name__, "|", m.content or m.tool_calls)
Output
HumanMessage | What does ef_search do?
AIMessage | [{'name': 'search_docs', 'args': {'query': 'HNSW ef_search'}, 'id': 'call_1', 'type': 'tool_call'}]
ToolMessage | ann-indexes.adoc :: HNSW :: HNSW ef_search trades recall for latency.
AIMessage | ef_search sets how many candidates HNSW explores per query; higher values raise recall and latency.

The returned dictionary holds the whole conversation under messages: the human message, the model’s tool call, the ToolMessage the agent created by running search_docs, and the final answer. Nobody wrote the loop that ran the tool and called the model a second time; that is what create_agent provides. The agent returned by the factory is a CompiledStateGraph, so every LangGraph method (invoke, stream, get_state, get_graph) works on it. See LangChain — Agents and the reference for create_agent.

The parameters that matter for most agents are model, tools, system_prompt, middleware, response_format, state_schema, context_schema, checkpointer, store and name. The sections below take them in turn.

The model

The model argument is either a model instance (a BaseChatModel such as the ChatOllama used in Chat Models & Messages) or a string of the form "provider:model", which create_agent resolves the same way init_chat_model does. Constructing an agent does not contact the provider, so the following runs without an Ollama server:

A string model identifier resolves to a chat model instance
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model

agent = create_agent("ollama:llama3.1:8b", tools=[search_docs])
print(type(agent).__name__)

model = init_chat_model("ollama:llama3.1:8b", temperature=0)
print(type(model).__name__)
Output
CompiledStateGraph
ChatOllama

The string form is convenient, but it uses the provider’s defaults. When you need temperature=0, a timeout or a rate limiter, build the instance yourself and pass it in. For a hosted deployment the same call takes "anthropic:claude-sonnet-5" or "openai:gpt-6-sol" (each needs its provider package, such as langchain-anthropic or langchain-openai, and an API key in the provider’s environment variable; these two were not run; model ids change, so check your provider for current ones). The model must support tool calling, otherwise the loop has nothing to loop over. The agent binds the tools itself, so pass an unbound model. See LangChain — Models.

Tools and the system prompt

tools accepts LangChain tools, plain Python callables (converted for you) and provider tool dictionaries. Building tools, injecting runtime data and returning artifacts are covered in Tools; the agent adds a tool node that runs the calls the model emits and appends each result as a ToolMessage.

system_prompt sets the agent’s behaviour and accepts a string or a SystemMessage. Both forms produced a working agent when tried against the installed version:

The two static forms of system_prompt
from langchain_core.messages import SystemMessage

a = create_agent(scripted(AIMessage("ok")), tools=[], system_prompt="Be terse.")
b = create_agent(scripted(AIMessage("ok")), tools=[], system_prompt=SystemMessage(content="Be terse."))
print(a.invoke({"messages": [{"role": "user", "content": "x"}]})["messages"][-1].content)
print(b.invoke({"messages": [{"role": "user", "content": "x"}]})["messages"][-1].content)
Output
ok
ok

A fixed string is the right default. When the prompt has to depend on who is asking, use the @dynamic_prompt middleware decorator, which receives the model request and returns the system prompt for this call. The request carries the agent’s runtime, so the prompt can read the per-run context. DocsAssistant adapts its register to the audience declared in the context; the recording model below stores the system message it received so the result is visible:

A dynamic system prompt read from runtime context
from dataclasses import dataclass

from langchain.agents.middleware import ModelRequest, dynamic_prompt


@dataclass
class Context:
    audience: str


@dynamic_prompt
def audience_prompt(request: ModelRequest) -> str:
    base = "You are DocsAssistant. Answer from search_docs results."
    if request.runtime.context.audience == "beginner":
        return base + " Explain terms simply."
    return base


seen = {}


class Recording(ScriptedChat):
    def _generate(self, messages, *args, **kwargs):
        seen["system"] = messages[0].content        # the first message sent to the model
        return super()._generate(messages, *args, **kwargs)


agent = create_agent(
    Recording(messages=iter([AIMessage("ok")])),
    tools=[search_docs],
    middleware=[audience_prompt],
    context_schema=Context,
)
agent.invoke({"messages": [{"role": "user", "content": "x"}]}, context=Context(audience="beginner"))
print(seen)
Output
{'system': 'You are DocsAssistant. Answer from search_docs results. Explain terms simply.'}

Middleware writing and the other prompt-shaping patterns are on Middleware; the reasoning behind adapting the prompt per request is in Context Engineering for Agents. See LangChain — Custom middleware.

The agent loop

The compiled graph has two working nodes, model and tools, between start and end. The model node calls the chat model with the system prompt plus the conversation so far. If the reply contains tool_calls, control goes to the tools node, which runs them, appends the ToolMessage results and returns to model. If the reply has no tool calls, the run ends and the last message is the answer. Asking the compiled agent for its graph confirms this:

The graph of the first agent
print(list(agent.get_graph().nodes))
Output
['__start__', 'model', 'tools', '__end__']

Every middleware you add that defines a hook adds a node or wraps one of these two (see LangChain reference — create_agent). The lifecycle is:

  • before_agent runs once, when a run starts.

  • before_model runs before every model call.

  • wrap_model_call wraps each model call; it can change the request, call the handler more than once, or skip it.

  • after_model runs after every model reply, before any tool runs.

  • wrap_tool_call wraps each tool call.

  • after_agent runs once, when the run ends.

before_agent, before_model, after_model and after_agent are node-style hooks (they receive the state and return updates); wrap_model_call and wrap_tool_call are wrap-style hooks that surround the call and can retry or short-circuit it.

flowchart TD S([start]) --> BA[before_agent] BA --> BM[before_model] BM --> WM[wrap_model_call around the model call] WM --> AM[after_model] AM --> D{tool calls in the reply?} D -->|yes| WT[wrap_tool_call around each tool] WT --> BM D -->|no| AA[after_agent] AA --> E([end: final messages])
Figure 1. Where each middleware hook runs in the agent loop

The order is easy to check rather than trust. The next agent has one middleware per hook, each appending its name to a list, and runs DocsAssistant’s search once:

Observing the hook order across one tool round trip
from langchain.agents.middleware import (
    after_agent, after_model, before_agent, before_model, wrap_model_call, wrap_tool_call,
)

log = []


@before_agent
def ba(state, runtime):
    log.append("before_agent")


@before_model
def bm(state, runtime):
    log.append("before_model")


@wrap_model_call
def wm(request, handler):
    log.append("wrap_model_call:enter")
    response = handler(request)
    log.append("wrap_model_call:exit")
    return response


@after_model
def am(state, runtime):
    log.append("after_model")


@wrap_tool_call
def wt(request, handler):
    log.append(f"wrap_tool_call:{request.tool_call['name']}")
    return handler(request)


@after_agent
def aa(state, runtime):
    log.append("after_agent")


model = scripted(AIMessage(content="", tool_calls=[call]), AIMessage("Done."))
agent = create_agent(model, tools=[search_docs], middleware=[ba, bm, wm, am, wt, aa])
agent.invoke({"messages": [{"role": "user", "content": "q"}]})
print(log)
print(list(agent.get_graph().nodes))
Output
['before_agent', 'before_model', 'wrap_model_call:enter', 'wrap_model_call:exit', 'after_model', 'wrap_tool_call:search_docs', 'before_model', 'wrap_model_call:enter', 'wrap_model_call:exit', 'after_model', 'after_agent']
['__start__', 'model', 'tools', 'ba.before_agent', 'bm.before_model', 'am.after_model', 'aa.after_agent', '__end__']

before_agent and after_agent appear once; everything between them repeats for each trip round the loop, which here is two model calls with one tool call in between. Node-style hooks became real graph nodes (bm.before_model and so on), while the wrap-style hooks did not, because they run inside the model and tools nodes. See LangChain — Middleware overview.

A loop needs a brake. LangGraph counts every node execution as a step and stops the run with GraphRecursionError when a limit is passed, by default 10007 steps in the installed langgraph 1.2.12, or whatever recursion_limit the run’s config sets. A model that keeps requesting the same tool is stopped by it. The scripted model below asks for search_docs fifty times and the run is capped at six steps:

Capping a runaway loop with recursion_limit
from langgraph.errors import GraphRecursionError

repeat = scripted(*[
    AIMessage("", tool_calls=[{"name": "search_docs", "args": {"query": "x"}, "id": f"l{i}", "type": "tool_call"}])
    for i in range(50)
])
agent = create_agent(repeat, tools=[search_docs])
try:
    agent.invoke({"messages": [{"role": "user", "content": "loop"}]}, config={"recursion_limit": 6})
except GraphRecursionError as exc:
    print("GraphRecursionError:", str(exc).split(". ")[0])
Output
GraphRecursionError: Recursion limit of 6 reached without hitting a stop condition

recursion_limit is a blunt safety net. Limits that end the run cleanly (call-count limits, retries, fallbacks) are middleware and are covered in Middleware. See LangChain — Agents.

Dynamic model selection

The model given to create_agent is the default. To choose a different one per call, for example a cheaper model for short conversations and a stronger one for long ones, write a @wrap_model_call middleware that overrides the model on the request. request.override(model=…​) returns a modified copy of the request, and passing it to the handler performs the call with that model:

Choosing the model per call from the conversation length
from langchain.agents.middleware import ModelRequest, ModelResponse, wrap_model_call

small = scripted(AIMessage("small model answer"))       # in production: ChatOllama(model="llama3.1:8b")
large = scripted(AIMessage("large model answer"))       # in production: a hosted model such as claude-sonnet-5


@wrap_model_call
def pick_model(request: ModelRequest, handler) -> ModelResponse:
    turns = len(request.state["messages"])
    return handler(request.override(model=large if turns > 2 else small))


agent = create_agent(small, tools=[], middleware=[pick_model])
short = agent.invoke({"messages": [{"role": "user", "content": "hi"}]})
long = agent.invoke({"messages": [
    {"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}, {"role": "user", "content": "c"},
]})
print(short["messages"][-1].content)
print(long["messages"][-1].content)
Output
small model answer
large model answer

Two caveats from the official page. A model that already went through bind_tools cannot be used with structured output, so the models handed to the middleware must be unbound; and the routing rule sees the request, so it can just as well use request.runtime.context (for example the user’s plan) as the message count. See LangChain — Models for the section on dynamic model selection and Chat Models & Messages for ModelFallbackMiddleware, which handles the related case of switching model on failure.

Structured responses with response_format

When the caller needs data rather than prose, pass response_format. The agent still runs its loop, tools included, and adds a validated object to the final state under the structured_response key next to messages. A bare Pydantic class works; wrap it in ToolStrategy or ProviderStrategy (from langchain.agents.structured_output) to choose how the schema is requested and how errors are handled. The mechanics of those strategies, validation retries and the strategy that a bare class resolves to are the subject of Structured Output; this snippet shows only the agent-level contract.

DocsAssistant returns a validated answer after searching
from typing import Literal

from pydantic import BaseModel, Field


class DocsAnswer(BaseModel):
    answer: str
    pages: list[str] = Field(description="Pages the answer is based on")
    confidence: Literal["low", "high"]


search = {"name": "search_docs", "args": {"query": "HNSW"}, "id": "c1", "type": "tool_call"}
final = {"name": "DocsAnswer", "id": "c2", "type": "tool_call", "args": {
    "answer": "ef_search trades recall for latency.", "pages": ["ann-indexes.adoc"], "confidence": "high"}}
agent = create_agent(
    scripted(AIMessage("", tool_calls=[search]), AIMessage("", tool_calls=[final])),
    tools=[search_docs],
    response_format=DocsAnswer,
)
result = agent.invoke({"messages": [{"role": "user", "content": "What is ef_search?"}]})
print(repr(result["structured_response"]))
print(list(result.keys()))
Output
DocsAnswer(answer='ef_search trades recall for latency.', pages=['ann-indexes.adoc'], confidence='high')
['messages', 'structured_response']

Because the scripted model has no native structured-output support, the bare class was handled as a tool call named after the schema, which is why the second scripted message calls DocsAnswer. With a hosted model whose profile advertises native support, the same line of code asks the provider to enforce the schema instead. See LangChain — Structured output.

State, threads and context

Three separate things travel with a run, and confusing them is a common source of bugs:

Table 1. What each piece carries
Piece What it holds How you supply it

State (AgentState)

The messages list plus any custom fields your tools and middleware read and write. It changes during the run.

The invoke input dictionary; declare extra fields with state_schema= (a TypedDict subclass of AgentState).

Thread (thread_id)

Which conversation the checkpointed state belongs to, so a second call continues the first.

config={"configurable": {"thread_id": …​}} plus a checkpointer= on the agent.

Context (context_schema)

Immutable per-run data such as the user id or feature flags, readable in tools and middleware as runtime.context.

The context= argument of invoke and stream.

The snippet combines all three. A tool reads the user id from the context, a custom topics field lives in the state, and InMemorySaver keeps the thread between two calls:

State, thread and context together
from dataclasses import dataclass

from langchain.agents import AgentState, create_agent
from langchain.tools import ToolRuntime, tool
from langgraph.checkpoint.memory import InMemorySaver


@dataclass
class Context:
    user_id: str


class DocsState(AgentState):
    topics: list[str]


@tool
def whoami(runtime: ToolRuntime[Context]) -> str:
    """Return the id of the current user."""
    return runtime.context.user_id


who = {"name": "whoami", "args": {}, "id": "w1", "type": "tool_call"}
agent = create_agent(
    scripted(AIMessage("", tool_calls=[who]), AIMessage("You are u-42."), AIMessage("Second turn, history kept.")),
    tools=[whoami],
    context_schema=Context,
    state_schema=DocsState,
    checkpointer=InMemorySaver(),
    name="docs_assistant",
)
config = {"configurable": {"thread_id": "t-1"}}
ctx = Context(user_id="u-42")

first = agent.invoke({"messages": [{"role": "user", "content": "who am I?"}], "topics": ["auth"]},
                     config=config, context=ctx)
print(first["messages"][-1].content, first["topics"])

second = agent.invoke({"messages": [{"role": "user", "content": "and again?"}]}, config=config, context=ctx)
print(len(second["messages"]), second["messages"][-1].content)
print(agent.get_state(config).values["topics"], agent.name)
Output
You are u-42. ['auth']
6 Second turn, history kept.
['auth'] docs_assistant

The second call sent only one new message, yet the result holds six: the four from the first run plus the new human and AI messages, and the topics value from the first call is still in the checkpoint. Without a checkpointer each call starts empty; in production it is a Postgres-backed saver rather than InMemorySaver, a topic of Short-Term Memory & Checkpointers. Cross-conversation memory keyed by user is the store= argument, covered in Long-Term Memory Stores. The optional name matters when the agent is embedded as a node in a larger graph, as in Multi-Agent & Deep Agents. Keep the user id in the context, not in the prompt or in a tool argument, so the model cannot alter it. See LangChain — Runtime and LangChain — Short-term memory.

Streaming a run

invoke returns only when the loop ends. To show progress, stream yields one item per step. With stream_mode="updates" each item maps the name of the node that just ran to the state update it produced:

Streaming node updates
agent = create_agent(
    scripted(AIMessage("", tool_calls=[search]), AIMessage("HNSW trades recall for latency.")),
    tools=[search_docs],
)
for step in agent.stream({"messages": [{"role": "user", "content": "q"}]}, stream_mode="updates"):
    for node, update in step.items():
        print(node, [type(m).__name__ for m in update["messages"]])
Output
model ['AIMessage']
tools ['ToolMessage']
model ['AIMessage']

The node names are model and tools; code written for create_react_agent that filtered on the node name "agent" must be updated. The other stream modes, token streaming and serving a stream over HTTP are on Streaming. See LangChain — Streaming.

Replacing create_react_agent

Before LangChain and LangGraph 1.0 the recommended way to build this loop was langgraph.prebuilt.create_react_agent, and many tutorials and books still show it. It still exists in langgraph 1.2.12 but is deprecated: constructing one emits a LangGraphDeprecatedSinceV10 warning whose message points to from langchain.agents import create_agent. The v1 migration guide lists the mapping:

Table 2. From create_react_agent to create_agent
create_react_agent create_agent Note

from langgraph.prebuilt import create_react_agent

from langchain.agents import create_agent

Import path and function name change.

prompt="…​"

system_prompt="…​"

Renamed. A callable prompt becomes a @dynamic_prompt middleware.

pre_model_hook=

before_model middleware

Runs before each model call.

post_model_hook=

after_model middleware

Runs after each model reply.

Pausing before tool execution for approval

HumanInTheLoopMiddleware(interrupt_on={…​})

The migration guide recommends it for tool approval, configured per tool. See Human-in-the-Loop & Time Travel.

langgraph.prebuilt agent state helpers

langchain.agents.AgentState with state_schema=

A TypedDict; extra fields are declared there or by middleware.

Prompted output response_format=("prompt", Schema)

ToolStrategy / ProviderStrategy from langchain.agents.structured_output

Prompted output was removed.

Streamed node name "agent"

"model"

Update any filter on node names.

Static values in config["configurable"]

context= with context_schema=

Typed runtime context.

Migrating DocsAssistant is a matter of swapping the import and the keyword. The old form is shown so the deprecation and the renamed node are visible; the new form is the agent used throughout this page:

The same agent before and after the migration
import warnings

from langchain.agents import create_agent

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    from langgraph.prebuilt import create_react_agent                     # v0 style, deprecated

    old = create_react_agent(
        scripted(AIMessage("", tool_calls=[search]), AIMessage("done")), [search_docs], prompt="Be terse.")
print(caught[0].category.__name__)

new = create_agent(
    scripted(AIMessage("", tool_calls=[search]), AIMessage("done")), tools=[search_docs], system_prompt="Be terse.")

stream = lambda a: [list(s)[0] for s in a.stream({"messages": [("user", "q")]}, stream_mode="updates")]
print(stream(old))
print(stream(new))
Output
LangGraphDeprecatedSinceV10
['agent', 'tools', 'agent']
['model', 'tools', 'model']

Behaviour is otherwise the same loop, so an existing agent is normally migrated by editing these lines and moving any hook logic into middleware. Legacy chains, retrievers and the hub module moved to langchain-classic; the migration digest is in Ecosystem & Packages. See LangChain — v1 migration guide and LangChain — v1 release notes.

Where to go next

An agent is only as safe as the tools it holds. Before DocsAssistant is allowed to open a GitHub issue it needs an approval gate and a least-privilege token; the design rules are in Tool Design and the mechanics in Human-in-the-Loop & Time Travel. When a single agent is not enough, Multi-Agent & Deep Agents builds on create_agent; a comparison with other frameworks is in Frameworks Landscape.

Summary

Table 3. create_agent at a glance
Concern What to use Executed here

Build an agent

create_agent(model, tools=[…​], system_prompt=…​)

First agent, message trace

Choose the model

Instance or "provider:model" string; @wrap_model_call with request.override(model=…​) per call

String ids; dynamic model

Shape the prompt

String or SystemMessage; @dynamic_prompt for per-request

Both static forms; audience prompt

The loop

model and tools nodes; hooks before_agent, before_model, wrap_model_call, after_model, wrap_tool_call, after_agent

Hook order log; graph nodes

Typed result

response_format=Schema, read result["structured_response"]

DocsAnswer run

Keep history and per-run data

checkpointer plus thread_id; context_schema plus context=; state_schema

Thread and context run

Stop a runaway loop

config={"recursion_limit": n}, GraphRecursionError

Limit of six steps

Migrate

create_react_agent to create_agent, hooks to middleware, node "agent" to "model"

Deprecation warning and node names