{"slug": "building-agentic-workflows-in-python-with-langgraph", "title": "Building Agentic Workflows in Python with LangGraph", "summary": "LangGraph provides a graph-based structure for building agentic workflows in Python, using state, nodes, and edges to manage execution flow, tool calls, and conversation memory. The framework allows developers to define agents as graphs where each reasoning step, tool call, and response is captured in a shared state object, making the entire process inspectable and persistent across invocations.", "body_md": "In this article, you will learn how to build a complete agentic workflow in Python with LangGraph, from a single model call to a tool-using agent with persistent conversation memory.\n\nTopics we will cover include:\n\n- How state, nodes, and edges combine to define the execution flow of a LangGraph agent.\n- How to register a tool and route the model’s tool calls through the graph’s reasoning loop.\n- How a checkpointer persists conversation history across separate graph invocations.\n\nLet’s not waste any more time.\n\n## Introduction\n\nMost [AI agent](https://www.ibm.com/think/topics/ai-agents) setups handle the single-turn case well: take a question, call a model, and return an answer. The harder problems appear soon after that. An agent may need to query your database, remember the context from earlier messages, or give you visibility into exactly what the model decided and why. Solving those challenges without building custom plumbing for every use case is where many implementations begin to break down.\n\n[LangGraph](https://www.langchain.com/langgraph) provides a clean structure for handling each of these problems. An agent is represented as a graph, where nodes are units of work, edges define what runs next, and a shared state object carries the complete message history through every step. The model runs inside a node, so every reasoning step, tool call, and response becomes part of the graph’s state. That makes the entire execution flow visible, inspectable, and available to any node that runs afterward.\n\nIn this article, you’ll learn how to understand the state, node, and edge primitives that every LangGraph graph is built on; manage conversation history automatically with `MessagesState`\n\n; call a language model inside a node and connect it to a graph; register a tool and route tool calls back through the model; trace the complete message sequence to see exactly what the model does at each step; and persist conversations across separate invocations with a checkpointer. We’ll build the graph from the ground up, starting with the installation steps.\n\n## Setting Up\n\nInstall the required packages:\n\n```\npip install langgraph langchain-openai python-dotenv\n\n1\n\npip install langgraph langchain-openai python-dotenv\n```\n\nThen create a .env file in your project root with your OpenAI API key:\n\n```\nOPENAI_API_KEY=\"your_key_here\"\n\n1\n\nOPENAI_API_KEY=\"your_key_here\"\n```\n\nLoad it at the top of your script before any LangChain or LangGraph imports:\n\n``` python\nfrom dotenv import load_dotenv\nload_dotenv()\n\n12\n\nfrom dotenv import load_dotenvload_dotenv()\n```\n\n[python-dotenv](https://pypi.org/project/python-dotenv/) reads the .env file and sets the key as an environment variable.\n\n## Understanding State, Nodes, and Edges\n\n[Every LangGraph graph is built from the following three components](https://docs.langchain.com/oss/python/langgraph/graph-api#graphs). Getting them right upfront saves confusion when the graph gets more complex.\n\n**State** is a [ TypedDict](https://typing.python.org/en/latest/spec/typeddict.html) that acts as the shared memory for the entire graph. Every node reads from it and writes updates back to it. Nothing passes between nodes any other way. Fields you don’t update in a node stay unchanged; you only return what you want to modify.\n\n**Nodes** are plain Python functions. A node takes the current state as its argument and returns a dictionary of the fields it wants to update. Registering a function with [ add_node](https://reference.langchain.com/python/langgraph/graph/state/StateGraph/add_node) is what makes it part of the graph without the need for a special decorator or base class. If you pass just the function without a name string, LangGraph uses the function name automatically.\n\n**Edges** define execution order. [ add_edge(A, B)](https://reference.langchain.com/python/langgraph/graph/state/StateGraph/add_edge) means: after node A finishes, run node B.\n\n[add_conditional_edges](https://reference.langchain.com/python/langgraph/graph/state/StateGraph/add_conditional_edges)means: after node A finishes, call a routing function and go wherever it points. Every graph needs\n\n`START`\n\nas its entry point and at least one path to `END`\n\n.By default, when a node returns a value for a state field, that value replaces what was there. For fields that should accumulate across nodes — a log, a message history — you annotate the field with a [reducer function](https://docs.langchain.com/oss/python/langgraph/graph-api#reducers). In the following example, operator.add on a list field means append, not replace:\n\n``` python\nfrom typing import Annotated\nimport operator\nfrom typing_extensions import TypedDict\nfrom langgraph.graph import StateGraph, START, END\n\nclass TicketState(TypedDict):\n    customer_message: str\n    log: Annotated[list, operator.add]\n\ndef log_received(state: TicketState) -> dict:\n    return {\"log\": [f\"Received: {state['customer_message']}\"]}\n\ndef log_assigned(state: TicketState) -> dict:\n    return {\"log\": [\"Assigned to support queue\"]}\n\nbuilder = StateGraph(TicketState)\nbuilder.add_node(\"log_received\", log_received)\nbuilder.add_node(\"log_assigned\", log_assigned)\nbuilder.add_edge(START, \"log_received\")\nbuilder.add_edge(\"log_received\", \"log_assigned\")\nbuilder.add_edge(\"log_assigned\", END)\ngraph = builder.compile()\n\nresult = graph.invoke({\"customer_message\": \"My invoice looks wrong\", \"log\": []})\nprint(result)\n\n12345678910111213141516171819202122232425\n\nfrom typing import Annotatedimport operatorfrom typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END class TicketState(TypedDict):    customer_message: str    log: Annotated[list, operator.add] def log_received(state: TicketState) -> dict:    return {\"log\": [f\"Received: {state['customer_message']}\"]} def log_assigned(state: TicketState) -> dict:    return {\"log\": [\"Assigned to support queue\"]} builder = StateGraph(TicketState)builder.add_node(\"log_received\", log_received)builder.add_node(\"log_assigned\", log_assigned)builder.add_edge(START, \"log_received\")builder.add_edge(\"log_received\", \"log_assigned\")builder.add_edge(\"log_assigned\", END)graph = builder.compile() result = graph.invoke({\"customer_message\": \"My invoice looks wrong\", \"log\": []})print(result)\n```\n\nThis outputs:\n\n```\n{'customer_message': 'My invoice looks wrong', 'log': ['Received: My invoice looks wrong', 'Assigned to support queue']}\n\n1\n\n{'customer_message': 'My invoice looks wrong', 'log': ['Received: My invoice looks wrong', 'Assigned to support queue']}\n```\n\nBoth nodes wrote to log, and both entries are there. `customer_message`\n\ncame through untouched because neither node returned it. This is exactly how `MessagesState`\n\nhandles its messages field, using a slightly more specialized reducer called `add_messages`\n\nthat also handles deduplication and ordering of message objects.\n\n## Managing Conversation History with `MessagesState`\n\nEvery node in a LangGraph graph reads the current state and writes updates back to it. For a conversational agent, state needs to carry the full message history — user inputs, model responses, tool outputs — so the model always has the context it needs when deciding what to do next.\n\nLangGraph ships a built-in state type for exactly this: [ MessagesState](https://reference.langchain.com/python/langgraph/graph/message/MessagesState). It’s a\n\n`TypedDict`\n\nwith a single messages field that uses the `add_messages`\n\nreducer instead of plain overwriting. Every time a node returns new messages, they get appended to the existing list rather than replacing it. You don’t have to stitch together conversation history manually.\n\n``` python\nfrom langgraph.graph import MessagesState\n\n1\n\nfrom langgraph.graph import MessagesState\n```\n\nThis is the state definition you’d need for most single-agent graphs. You can extend it with additional fields, say a `customer_id`\n\n, a `priority`\n\nflag, anything your nodes need. But messages is already there and already wired to accumulate.\n\n## Calling the Model Inside a Node\n\nWith the state in place, the core node of any LangGraph agent is a function that passes the current message list to a model and appends its response. The model returns an [ AIMessage](https://reference.langchain.com/python/langchain-core/messages/ai/AIMessage); returning it inside a dict keyed to “\n\n`messages`\n\n” is all it takes to add it to state.\n\n``` python\nfrom langchain_openai import ChatOpenAI\nfrom langchain_core.messages import SystemMessage\n\nllm = ChatOpenAI(model=\"gpt-4o-mini\")\n\ndef run_model(state: MessagesState) -> dict:\n    system = SystemMessage(\"You are a support agent for a SaaS product. \"\n                           \"Be concise and helpful.\")\n    response = llm.invoke([system] + state[\"messages\"])\n    return {\"messages\": [response]}\n\n12345678910\n\nfrom langchain_openai import ChatOpenAIfrom langchain_core.messages import SystemMessage llm = ChatOpenAI(model=\"gpt-4o-mini\") def run_model(state: MessagesState) -> dict:    system = SystemMessage(\"You are a support agent for a SaaS product. \"                           \"Be concise and helpful.\")    response = llm.invoke([system] + state[\"messages\"])    return {\"messages\": [response]}\n```\n\n[ChatOpenAI wraps the OpenAI API with LangChain’s standard chat model interface](https://reference.langchain.com/python/langchain-openai/chat_models/base/ChatOpenAI). Swapping to a different provider — Anthropic, Google, a local model via Ollama — means changing the import and the model string; the rest of the node stays the same. The [ SystemMessage](https://reference.langchain.com/python/langchain-core/messages/system/SystemMessage) sets the model’s role on every call without being stored in state, keeping the persistent history clean.\n\nWire it into a graph and run it:\n\n``` python\nfrom langgraph.graph import StateGraph, START, END\nfrom langchain_core.messages import HumanMessage\n\nbuilder = StateGraph(MessagesState)\nbuilder.add_node(\"run_model\", run_model)\nbuilder.add_edge(START, \"run_model\")\nbuilder.add_edge(\"run_model\", END)\n\ngraph = builder.compile()\n\nresult = graph.invoke({\"messages\": [HumanMessage(\"My dashboard isn't loading. What should I try?\")]})\nprint(result[\"messages\"][-1].content)\n\n123456789101112\n\nfrom langgraph.graph import StateGraph, START, ENDfrom langchain_core.messages import HumanMessage builder = StateGraph(MessagesState)builder.add_node(\"run_model\", run_model)builder.add_edge(START, \"run_model\")builder.add_edge(\"run_model\", END) graph = builder.compile() result = graph.invoke({\"messages\": [HumanMessage(\"My dashboard isn't loading. What should I try?\")]})print(result[\"messages\"][-1].content)\n```\n\n`result[\"messages\"]`\n\nis the full list: the original `HumanMessage`\n\nplus the `AIMessage`\n\nthe model produced. `[-1]`\n\ngets the most recent one.\n\n## Registering a Tool and Routing Tool Calls\n\nThe model can answer general questions from its training data, but anything specific to your data — account details, subscription tier, ticket history — requires a [tool call](https://www.ibm.com/think/topics/tool-calling). The model decides when a tool is needed; your code defines what it does.\n\nDefine a tool with the `@tool`\n\ndecorator:\n\n``` python\nfrom langchain_core.tools import tool\n\n@tool\ndef get_customer_tier(customer_id: str) -> str:\n    \"\"\"Look up the subscription tier for a customer by their ID.\n    Returns 'free', 'pro', or 'enterprise'.\"\"\"\n    tiers = {\n        \"cust_1001\": \"enterprise\",\n        \"cust_2002\": \"pro\",\n        \"cust_3003\": \"free\",\n    }\n    return tiers.get(customer_id, \"not found\")\n\n123456789101112\n\nfrom langchain_core.tools import tool @tooldef get_customer_tier(customer_id: str) -> str:    \"\"\"Look up the subscription tier for a customer by their ID.    Returns 'free', 'pro', or 'enterprise'.\"\"\"    tiers = {        \"cust_1001\": \"enterprise\",        \"cust_2002\": \"pro\",        \"cust_3003\": \"free\",    }    return tiers.get(customer_id, \"not found\")\n```\n\nThe docstring is what the model reads when deciding whether to call this tool and what arguments to pass. Keep it precise because vague docstrings lead to missed calls or malformed arguments.\n\nBind the tool to the model so it knows the tool exists, and update the node:\n\n```\ntools = [get_customer_tier]\nllm_with_tools = llm.bind_tools(tools)\n\ndef run_model(state: MessagesState) -> dict:\n    system = SystemMessage(\"You are a support agent for a SaaS product. \"\n                           \"Use available tools when you need account-specific information.\")\n    response = llm_with_tools.invoke([system] + state[\"messages\"])\n    return {\"messages\": [response]}\n\n12345678\n\ntools = [get_customer_tier]llm_with_tools = llm.bind_tools(tools) def run_model(state: MessagesState) -> dict:    system = SystemMessage(\"You are a support agent for a SaaS product. \"                           \"Use available tools when you need account-specific information.\")    response = llm_with_tools.invoke([system] + state[\"messages\"])    return {\"messages\": [response]}\n```\n\n[ bind_tools](https://reference.langchain.com/python/langchain-core/language_models/chat_models/BaseChatModel/bind_tools) sends the tool’s schema to the model alongside every request. When the model decides to use it, the response comes back as an\n\n`AIMessage`\n\nwith a `tool_calls`\n\nfield populated rather than plain text in content.Add a [ToolNode](https://reference.langchain.com/python/langgraph.prebuilt/tool_node/ToolNode) to handle execution and wire the routing:\n\n``` python\nfrom langgraph.prebuilt import ToolNode, tools_condition\n\ntool_node = ToolNode(tools)\n\nbuilder = StateGraph(MessagesState)\nbuilder.add_node(\"run_model\", run_model)\nbuilder.add_node(\"tools\", tool_node)\n\nbuilder.add_edge(START, \"run_model\")\nbuilder.add_conditional_edges(\"run_model\", tools_condition)\nbuilder.add_edge(\"tools\", \"run_model\")\n\ngraph = builder.compile()\n\n12345678910111213\n\nfrom langgraph.prebuilt import ToolNode, tools_condition tool_node = ToolNode(tools) builder = StateGraph(MessagesState)builder.add_node(\"run_model\", run_model)builder.add_node(\"tools\", tool_node) builder.add_edge(START, \"run_model\")builder.add_conditional_edges(\"run_model\", tools_condition)builder.add_edge(\"tools\", \"run_model\") graph = builder.compile()\n```\n\n`ToolNode`\n\nreads the `tool_calls`\n\nfrom the last `AIMessage`\n\n, runs the matching function with the arguments the model specified, and wraps the result in a `ToolMessage`\n\nappended to state. `tools_condition`\n\nchecks the last `AIMessage`\n\nafter every model call. If `tool_calls`\n\nis non-empty it routes to “`tools`\n\n“, otherwise it routes to “`__end__`\n\n“. The edge from “`tools`\n\n” back to “`run_model`\n\n” is what closes the loop: it sends the tool result back to the model so it can produce a final answer.\n\n## Tracing the Reasoning Loop\n\nBefore moving on, consider what actually happens inside the graph when the model uses a tool, because there’s more going on than the final output suggests.\n\n```\nresult = graph.invoke({\"messages\": [\n    HumanMessage(\"Can you check what plan customer cust_1001 is on?\")\n]})\n\nfor msg in result[\"messages\"]:\n    print(type(msg).__name__, \":\", msg.content or msg.tool_calls)\n\n123456\n\nresult = graph.invoke({\"messages\": [    HumanMessage(\"Can you check what plan customer cust_1001 is on?\")]}) for msg in result[\"messages\"]:    print(type(msg).__name__, \":\", msg.content or msg.tool_calls)\n```\n\nSample output:\n\n```\nHumanMessage : Can you check what plan customer cust_1001 is on?\nAIMessage : [{'name': 'get_customer_tier', 'args': {'customer_id': 'cust_1001'}, 'id': 'call_Rx7kLmNpQ2wJtA3s', 'type': 'tool_call'}]\nToolMessage : enterprise\nAIMessage : Customer cust_1001 is on the enterprise plan.\n\n1234\n\nHumanMessage : Can you check what plan customer cust_1001 is on?AIMessage : [{'name': 'get_customer_tier', 'args': {'customer_id': 'cust_1001'}, 'id': 'call_Rx7kLmNpQ2wJtA3s', 'type': 'tool_call'}]ToolMessage : enterpriseAIMessage : Customer cust_1001 is on the enterprise plan.\n```\n\nHere, we have four messages and two model calls. The first model call produces an `AIMessage`\n\nwith `tool_calls`\n\npopulated and content empty. The model is signaling what it wants to do, not answering yet. `tools_condition`\n\nsees that, routes to `ToolNode`\n\n, which runs `get_customer_tier(\"cust_1001\")`\n\nand appends a `ToolMessage`\n\nwith the result.\n\nThe edge back to `run_model`\n\nfires again. Now the model has all three prior messages in context, understands the lookup succeeded, and writes the final `AIMessage`\n\nwith the answer in content. `tools_condition`\n\nruns one more time, finds no tool calls, and ends the graph.\n\nThis loop — model call, tool execution, model call again — is the standard [ReAct pattern](https://www.ibm.com/think/topics/react-agent). Every tool use costs two model calls: one to decide what to look up, one to interpret the result. That’s a useful thing to know when thinking about latency and cost as you add more tools.\n\n## Persisting Conversations Across Calls\n\nEvery [graph.invoke()](https://reference.langchain.com/python/langgraph/pregel/main/Pregel/invoke) above starts with a fresh graph state. Without [persistence](https://docs.langchain.com/oss/python/langgraph/persistence), the model doesn’t remember previous exchanges.\n\nTo persist state between calls, attach a checkpointer when compiling the graph:\n\n``` python\nfrom langgraph.checkpoint.memory import InMemorySaver\n\ncheckpointer = InMemorySaver()\ngraph = builder.compile(checkpointer=checkpointer)\n\n1234\n\nfrom langgraph.checkpoint.memory import InMemorySaver checkpointer = InMemorySaver()graph = builder.compile(checkpointer=checkpointer)\n```\n\nThen pass the same `thread_id`\n\non every invocation:\n\n```\nconfig = {\"configurable\": {\"thread_id\": \"ticket-7741\"}}\n\ngraph.invoke(\n    {\"messages\": [HumanMessage(\"Hi, I can't access my account.\")]},\n    config,\n)\n\nresult = graph.invoke(\n    {\"messages\": [HumanMessage(\"My ID is cust_2002, can you check my plan?\")]},\n    config,\n)\n\nprint(result[\"messages\"][-1].content)\n\n12345678910111213\n\nconfig = {\"configurable\": {\"thread_id\": \"ticket-7741\"}} graph.invoke(    {\"messages\": [HumanMessage(\"Hi, I can't access my account.\")]},    config,) result = graph.invoke(    {\"messages\": [HumanMessage(\"My ID is cust_2002, can you check my plan?\")]},    config,) print(result[\"messages\"][-1].content)\n```\n\nSample output:\n\n```\nYou're on the pro plan, cust_2002. Since you're having trouble accessing your account, I'd recommend resetting your password first. Pro accounts also have priority support available if the issue continues.\n\n1\n\nYou're on the pro plan, cust_2002. Since you're having trouble accessing your account, I'd recommend resetting your password first. Pro accounts also have priority support available if the issue continues.\n```\n\nThe second invocation sees the conversation from the first because the checkpointer restored the thread’s state before execution and saved the updated state afterward. Using a different `thread_id`\n\nstarts with a separate, empty state.\n\n`InMemorySaver`\n\nstores checkpoints in process memory, making it useful for development and testing. In production, you typically replace it with a persistent checkpointer backed by a database or other durable storage. The rest of your graph code stays the same.\n\n[Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers) persist **graph state** for a thread. If your application also needs to persist data independently of any conversation, such as user profiles, preferences, or long-term memories shared across multiple threads, use a **Store**. [Stores](https://docs.langchain.com/oss/python/langgraph/stores) complement checkpointers by providing durable application-level storage that graphs can access during execution.\n\n## Wrapping Up\n\nIn this article, you built a complete LangGraph agent from the ground up. Along the way, you learned how state flows through a graph, how nodes execute work, how tools fit into the execution loop, and how a checkpointer preserves conversations across separate invocations. Those same building blocks scale from simple chatbots to much more sophisticated agent workflows.\n\nOne of LangGraph’s strengths is that each piece is independent. You can swap language models, register new tools, or change how conversations are persisted without redesigning the rest of the graph. Everything communicates through shared state, which keeps the graph predictable and easy to extend.\n\nThe same ideas also carry over to [multi-agent systems](https://www.ibm.com/think/topics/multiagent-system). A coordinator that routes requests to specialist agents is still a graph with state, nodes, and conditional edges. The architecture becomes larger, but the underlying primitives stay the same.\n\nIf you’d like to explore further, the following resources are a good place to continue:\n\n[LangGraph persistence docs](https://langchain-ai.github.io/langgraph/concepts/persistence/)[Tool calling with LangChain](https://python.langchain.com/docs/how_to/tool_calling/)[MessagesState and add_messages](https://langchain-ai.github.io/langgraph/concepts/low_level/#messagesstate)[LangGraph prebuilt components](https://langchain-ai.github.io/langgraph/reference/prebuilt/)\n\nHappy building!", "url": "https://wpnews.pro/news/building-agentic-workflows-in-python-with-langgraph", "canonical_source": "https://machinelearningmastery.com/building-agentic-workflows-in-python-with-langgraph/", "published_at": "2026-07-20 11:27:18+00:00", "updated_at": "2026-07-20 14:06:38.460466+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "large-language-models", "artificial-intelligence"], "entities": ["LangGraph", "LangChain", "OpenAI", "Python"], "alternates": {"html": "https://wpnews.pro/news/building-agentic-workflows-in-python-with-langgraph", "markdown": "https://wpnews.pro/news/building-agentic-workflows-in-python-with-langgraph.md", "text": "https://wpnews.pro/news/building-agentic-workflows-in-python-with-langgraph.txt", "jsonld": "https://wpnews.pro/news/building-agentic-workflows-in-python-with-langgraph.jsonld"}}